Files
op-packages/luci-app-tailscale/root/usr/sbin/tailscale_secrets
T

556 lines
15 KiB
Bash
Executable File

#!/bin/sh
set -eu
SECRETS_FILE="${TAILSCALE_SECRETS_FILE:-/etc/tailscale/luci-secrets.json}"
JQ_CMD="${JQ_CMD:-jq}"
UCI_CMD="${UCI_CMD:-uci}"
FLOCK_BIN="${FLOCK_BIN:-flock}"
umask 077
fail() {
printf '%s\n' "$*" >&2
exit 1
}
normalize_api_url() {
url="${1:-http://127.0.0.1:3000}"
[ -n "$url" ] || url='http://127.0.0.1:3000'
while [ "${url%/}" != "$url" ]; do
url="${url%/}"
done
case "$url" in
http://?*|https://?*) ;;
*) return 1 ;;
esac
printf '%s\n' "$url"
}
valid_key() {
case "$1" in
authkey|adguard_password|adguard_api_url|adguard_username) return 0 ;;
*) return 1 ;;
esac
}
valid_ref() {
case "${1:-}" in
''|*[!A-Za-z0-9_.-]*) return 1 ;;
*) return 0 ;;
esac
}
secrets_dir() {
dir="${SECRETS_FILE%/*}"
[ "$dir" != "$SECRETS_FILE" ] || dir='.'
printf '%s\n' "$dir"
}
ensure_secrets_dir() {
dir="$(secrets_dir)"
[ ! -L "$dir" ] || fail "refusing symlinked secrets directory"
[ -d "$dir" ] || mkdir -p "$dir"
chmod 0700 "$dir"
}
lock_secrets() {
ensure_secrets_dir
lock_file="$(secrets_dir)/.luci-secrets.lock"
[ ! -L "$lock_file" ] || fail "refusing symlinked secrets lock"
exec 9>"$lock_file"
chmod 0600 "$lock_file"
"$FLOCK_BIN" -x 9 || fail "failed to lock protected credentials"
}
replace_store() (
source_file="$1"
dir="$(secrets_dir)"
target_tmp="$(mktemp "$dir/.luci-secrets-store.XXXXXX")"
cleanup_replace() {
rm -f "$target_tmp"
}
trap cleanup_replace 0 1 2 15
cat "$source_file" >"$target_tmp"
chmod 0600 "$target_tmp"
mv -f "$target_tmp" "$SECRETS_FILE"
target_tmp=
)
store_is_valid() {
"$JQ_CMD" -e '
def valid_ref:
type == "string" and
length > 0 and
all(explode[];
(. >= 48 and . <= 57) or
(. >= 65 and . <= 90) or
(. >= 97 and . <= 122) or
. == 45 or . == 46 or . == 95
);
.schema == 2 and
(.active_ref | valid_ref) and
(.versions | type == "object") and
(.versions[.active_ref] | type == "object") and
all(.versions[]; type == "object")
' "$SECRETS_FILE" >/dev/null 2>&1
}
ensure_store() (
ensure_secrets_dir
[ ! -L "$SECRETS_FILE" ] || fail "refusing symlinked secrets file"
dir="$(secrets_dir)"
if [ ! -e "$SECRETS_FILE" ]; then
initial_tmp="$(mktemp "$dir/.luci-secrets-initial.XXXXXX")"
cleanup_initial() {
rm -f "$initial_tmp"
}
trap cleanup_initial 0 1 2 15
"$JQ_CMD" -nc '{schema:2,active_ref:"base",versions:{base:{}}}' >"$initial_tmp"
replace_store "$initial_tmp"
rm -f "$initial_tmp"
trap - 0 1 2 15
return 0
fi
if store_is_valid; then
chmod 0600 "$SECRETS_FILE"
return 0
fi
"$JQ_CMD" -e '
type == "object" and
(has("schema") | not) and
(([keys[]] - ["authkey", "adguard_password", "adguard_api_url", "adguard_username"]) | length == 0) and
all(.[]; type == "string")
' "$SECRETS_FILE" >/dev/null 2>&1 || fail "invalid protected credential store"
legacy_tmp="$(mktemp "$dir/.luci-secrets-legacy.XXXXXX")"
cleanup_legacy_store() {
rm -f "$legacy_tmp"
}
trap cleanup_legacy_store 0 1 2 15
"$JQ_CMD" -c '{schema:2,active_ref:"legacy",versions:{legacy:.}}' "$SECRETS_FILE" >"$legacy_tmp"
replace_store "$legacy_tmp"
rm -f "$legacy_tmp"
trap - 0 1 2 15
)
active_ref() {
ensure_store
"$JQ_CMD" -er '.active_ref' "$SECRETS_FILE"
}
version_exists() {
ref="$1"
valid_ref "$ref" || return 1
ensure_store
"$JQ_CMD" -e --arg ref "$ref" '.versions[$ref] | type == "object"' "$SECRETS_FILE" >/dev/null 2>&1
}
new_ref() {
dir="$(secrets_dir)"
while :; do
ref_marker="$(mktemp "$dir/.luci-secret-ref.XXXXXX")"
ref_suffix="${ref_marker##*.}"
rm -f "$ref_marker"
ref="v$(date +%s 2>/dev/null || printf 0)-$$-$ref_suffix"
if ! version_exists "$ref"; then
printf '%s\n' "$ref"
return 0
fi
done
}
stage_update() (
update_file="$1"
base_ref="${2:-}"
ensure_store
[ -n "$base_ref" ] || base_ref="$(active_ref)"
version_exists "$base_ref" || fail "unknown protected credential base reference"
ref="$(new_ref)"
dir="$(secrets_dir)"
staged_tmp="$(mktemp "$dir/.luci-secrets-stage.XXXXXX")"
cleanup_stage() {
rm -f "$staged_tmp"
}
trap cleanup_stage 0 1 2 15
"$JQ_CMD" -ce \
--arg base "$base_ref" \
--arg ref "$ref" \
--slurpfile update "$update_file" \
'.versions[$ref] = (.versions[$base] * $update[0])' \
"$SECRETS_FILE" >"$staged_tmp"
replace_store "$staged_tmp"
rm -f "$staged_tmp"
trap - 0 1 2 15
printf '%s\n' "$ref"
)
activate_ref() (
ref="$1"
version_exists "$ref" || fail "unknown protected credential reference"
dir="$(secrets_dir)"
activate_tmp="$(mktemp "$dir/.luci-secrets-activate.XXXXXX")"
cleanup_activate() {
rm -f "$activate_tmp"
}
trap cleanup_activate 0 1 2 15
"$JQ_CMD" -ce --arg ref "$ref" '.active_ref = $ref' "$SECRETS_FILE" >"$activate_tmp"
replace_store "$activate_tmp"
rm -f "$activate_tmp"
trap - 0 1 2 15
)
activate_and_persist_ref() {
ref="$1"
old_ref="$(active_ref)"
activate_ref "$ref"
if ! "$UCI_CMD" -q set "tailscale.settings.secrets_ref=$ref" || ! "$UCI_CMD" commit tailscale; then
activate_ref "$old_ref" || true
fail "failed to persist protected credential reference"
fi
}
get_value() {
key="$1"
valid_key "$key" || fail "invalid secret key"
ensure_store
"$JQ_CMD" -er --arg key "$key" '.versions[.active_ref][$key] | select(type == "string")' "$SECRETS_FILE"
}
get_required_value() {
value="$(get_value "$1")" || return 1
[ -n "$value" ] || return 1
printf '%s\n' "$value"
}
has_value() {
get_required_value "$1" >/dev/null 2>&1
}
authkey_update_file() (
dir="$(secrets_dir)"
update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")"
trap 'rm -f "$update_file"' 0 1 2 15
"$JQ_CMD" -Rse 'select(length > 0) | {authkey: ., authkey_pending: true}' >"$update_file" || {
rm -f "$update_file"
fail "auth key must not be empty"
}
printf '%s\n' "$update_file"
trap - 0 1 2 15
)
adguard_update_file() (
dir="$(secrets_dir)"
payload_file="$(mktemp "$dir/.luci-secrets-payload.XXXXXX")"
update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")"
trap 'rm -f "$payload_file" "$update_file"' 0 1 2 15
cat >"$payload_file"
"$JQ_CMD" -e '
type == "object" and
(.password | type == "string" and length > 0) and
(.api_url | type == "string") and
(.username | type == "string")
' "$payload_file" >/dev/null 2>&1 || fail "invalid AdGuard credential payload"
api_url="$("$JQ_CMD" -er '.api_url' "$payload_file")" || fail "invalid AdGuard credential payload"
[ -n "$api_url" ] || fail "invalid AdGuard credential payload"
normalized_api_url="$(normalize_api_url "$api_url")" || fail "invalid AdGuard credential payload"
"$JQ_CMD" -ce --arg api_url "$normalized_api_url" '
if (
type == "object" and
(.password | type == "string" and length > 0) and
(.api_url | type == "string") and
(.username | type == "string")
) then {
adguard_password: .password,
adguard_api_url: $api_url,
adguard_username: .username
} else error("invalid AdGuard credential payload") end
' "$payload_file" >"$update_file" || {
rm -f "$update_file"
fail "invalid AdGuard credential payload"
}
rm -f "$payload_file"
printf '%s\n' "$update_file"
trap - 0 1 2 15
)
batch_update_file() (
dir="$(secrets_dir)"
payload_file="$(mktemp "$dir/.luci-secrets-payload.XXXXXX")"
update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")"
trap 'rm -f "$payload_file" "$update_file"' 0 1 2 15
cat >"$payload_file"
"$JQ_CMD" -e '
type == "object" and
((.authkey == null) or (.authkey | type == "string" and length > 0)) and
((.adguard == null) or (
(.adguard | type == "object") and
(.adguard.password | type == "string" and length > 0) and
(.adguard.api_url | type == "string") and
(.adguard.username | type == "string")
)) and
((.authkey != null) or (.adguard != null))
' "$payload_file" >/dev/null 2>&1 || fail "invalid credential batch payload"
has_adguard="$("$JQ_CMD" -r 'if .adguard == null then "0" else "1" end' "$payload_file")" || \
fail "invalid credential batch payload"
normalized_api_url=
if [ "$has_adguard" = "1" ]; then
api_url="$("$JQ_CMD" -er '.adguard.api_url' "$payload_file")" || fail "invalid credential batch payload"
[ -n "$api_url" ] || fail "invalid credential batch payload"
normalized_api_url="$(normalize_api_url "$api_url")" || fail "invalid credential batch payload"
fi
"$JQ_CMD" -ce --arg api_url "$normalized_api_url" '
if (
type == "object" and
((.authkey == null) or (.authkey | type == "string" and length > 0)) and
((.adguard == null) or (
(.adguard | type == "object") and
(.adguard.password | type == "string" and length > 0) and
(.adguard.api_url | type == "string") and
(.adguard.username | type == "string")
)) and
((.authkey != null) or (.adguard != null))
) then
(if .authkey == null then {} else {authkey: .authkey, authkey_pending: true} end) +
(if .adguard == null then {} else {
adguard_password: .adguard.password,
adguard_api_url: $api_url,
adguard_username: .adguard.username
} end)
else error("invalid credential batch payload") end
' "$payload_file" >"$update_file" || {
rm -f "$update_file"
fail "invalid credential batch payload"
}
rm -f "$payload_file"
printf '%s\n' "$update_file"
trap - 0 1 2 15
)
set_authkey() {
update_file="$(authkey_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
ref="$(stage_update "$update_file")"
activate_and_persist_ref "$ref"
rm -f "$update_file"
trap - 0 1 2 15
}
stage_authkey() {
base_ref="${1:-}"
update_file="$(authkey_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
stage_update "$update_file" "$base_ref"
rm -f "$update_file"
trap - 0 1 2 15
}
set_adguard() {
update_file="$(adguard_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
ref="$(stage_update "$update_file")"
activate_and_persist_ref "$ref"
rm -f "$update_file"
trap - 0 1 2 15
}
set_batch() {
update_file="$(batch_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
ref="$(stage_update "$update_file")"
activate_and_persist_ref "$ref"
rm -f "$update_file"
trap - 0 1 2 15
}
stage_batch() {
base_ref="${1:-}"
update_file="$(batch_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
stage_update "$update_file" "$base_ref"
rm -f "$update_file"
trap - 0 1 2 15
}
authkey_pending() {
ensure_store
"$JQ_CMD" -e '
.versions[.active_ref] |
(.authkey_pending == true) and
(.authkey | type == "string" and length > 0)
' "$SECRETS_FILE" >/dev/null 2>&1
}
clear_authkey() (
ensure_store
dir="$(secrets_dir)"
clear_tmp="$(mktemp "$dir/.luci-secrets-clear-authkey.XXXXXX")"
cleanup_clear_authkey() {
rm -f "$clear_tmp"
}
trap cleanup_clear_authkey 0 1 2 15
"$JQ_CMD" -ce \
'.versions |= with_entries(.value |= del(.authkey, .authkey_pending))' \
"$SECRETS_FILE" >"$clear_tmp"
replace_store "$clear_tmp"
rm -f "$clear_tmp"
trap - 0 1 2 15
)
matches_adguard() {
requested_url="$(normalize_api_url "${1:-}")"
requested_user="${2:-}"
has_value adguard_password || return 1
bound_url="$(get_value adguard_api_url)" || return 1
bound_user="$(get_value adguard_username)" || return 1
[ "$requested_url" = "$(normalize_api_url "$bound_url")" ] && [ "$requested_user" = "$bound_user" ]
}
adguard_password_for() {
matches_adguard "${1:-}" "${2:-}" || return 1
get_required_value adguard_password
}
write_migration_update() (
authkey="$1"
password="$2"
api_url="$3"
username="$4"
auth_present="$5"
adguard_present="$6"
dir="$(secrets_dir)"
auth_file="$(mktemp "$dir/.luci-migrate-auth.XXXXXX")"
password_file="$(mktemp "$dir/.luci-migrate-password.XXXXXX")"
api_url_file="$(mktemp "$dir/.luci-migrate-url.XXXXXX")"
username_file="$(mktemp "$dir/.luci-migrate-user.XXXXXX")"
update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")"
trap 'rm -f "$auth_file" "$password_file" "$api_url_file" "$username_file" "$update_file"' 0 1 2 15
printf '%s' "$authkey" >"$auth_file"
printf '%s' "$password" >"$password_file"
printf '%s' "$api_url" >"$api_url_file"
printf '%s' "$username" >"$username_file"
"$JQ_CMD" -nc \
--arg auth_present "$auth_present" \
--arg adguard_present "$adguard_present" \
--rawfile authkey "$auth_file" \
--rawfile password "$password_file" \
--rawfile api_url "$api_url_file" \
--rawfile username "$username_file" \
'(if $auth_present == "1" and ($authkey | length > 0) then {authkey:$authkey} else {} end) +
(if $adguard_present == "1" and ($password | length > 0) then {
adguard_password:$password,
adguard_api_url:$api_url,
adguard_username:$username
} else {} end)' >"$update_file"
rm -f "$auth_file" "$password_file" "$api_url_file" "$username_file"
printf '%s\n' "$update_file"
trap - 0 1 2 15
)
migrate_legacy() {
ensure_store
auth_present=0
adguard_present=0
legacy_authkey=
legacy_adguard_password=
if legacy_authkey="$("$UCI_CMD" -q get tailscale.settings.authkey 2>/dev/null)"; then
auth_present=1
fi
if legacy_adguard_password="$("$UCI_CMD" -q get tailscale.settings.adguard_password 2>/dev/null)"; then
adguard_present=1
fi
legacy_api_url="$("$UCI_CMD" -q get tailscale.settings.adguard_api_url 2>/dev/null || true)"
legacy_username="$("$UCI_CMD" -q get tailscale.settings.adguard_username 2>/dev/null || true)"
legacy_api_url="$(normalize_api_url "$legacy_api_url")"
ref="$(active_ref)"
if { [ "$auth_present" = "1" ] && [ -n "$legacy_authkey" ]; } || \
{ [ "$adguard_present" = "1" ] && [ -n "$legacy_adguard_password" ]; }; then
update_file="$(write_migration_update \
"$legacy_authkey" \
"$legacy_adguard_password" \
"$legacy_api_url" \
"$legacy_username" \
"$auth_present" \
"$adguard_present")"
trap 'rm -f "$update_file"' 0 1 2 15
ref="$(stage_update "$update_file" "$ref")"
rm -f "$update_file"
trap - 0 1 2 15
fi
configured_ref="$("$UCI_CMD" -q get tailscale.settings.secrets_ref 2>/dev/null || true)"
changed=0
if [ -z "$configured_ref" ] || [ "$auth_present" = "1" ] || [ "$adguard_present" = "1" ]; then
"$UCI_CMD" -q set "tailscale.settings.secrets_ref=$ref" || fail "failed to stage protected credential reference"
changed=1
fi
if [ "$auth_present" = "1" ]; then
"$UCI_CMD" -q delete tailscale.settings.authkey || true
changed=1
fi
if [ "$adguard_present" = "1" ]; then
"$UCI_CMD" -q delete tailscale.settings.adguard_password || true
changed=1
fi
[ "$changed" = "0" ] || "$UCI_CMD" commit tailscale || fail "failed to commit protected credential migration"
activate_ref "$ref"
}
command="${1:-}"
case "$command" in
get|has|active-ref|authkey-pending|clear-authkey|set-authkey|stage-authkey|set-adguard|set-batch|stage-batch|activate|matches-adguard|adguard-password-for|migrate)
lock_secrets
;;
*)
fail "usage: $0 get|has|active-ref|authkey-pending|clear-authkey|set-authkey|stage-authkey|set-adguard|set-batch|stage-batch|activate|matches-adguard|adguard-password-for|migrate"
;;
esac
case "$command" in
get)
get_required_value "${2:-}"
;;
has)
has_value "${2:-}"
;;
active-ref)
active_ref
;;
authkey-pending)
authkey_pending
;;
clear-authkey)
clear_authkey
;;
set-authkey)
set_authkey
;;
stage-authkey)
stage_authkey "${2:-}"
;;
set-adguard)
set_adguard
;;
set-batch)
set_batch
;;
stage-batch)
stage_batch "${2:-}"
;;
activate)
activate_ref "${2:-}"
;;
matches-adguard)
matches_adguard "${2:-}" "${3:-}"
;;
adguard-password-for)
adguard_password_for "${2:-}" "${3:-}"
;;
migrate)
migrate_legacy
;;
esac