mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 00:52:51 +08:00
556 lines
15 KiB
Bash
Executable File
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
|