🗽 Sync 2026-09-29 04:49:38

This commit is contained in:
github-actions[bot] committed 2026-09-29 04:49:38 +08:00
1 parent 1ba92ebff1
commit cec9f6bff2
87 files changed
+25281

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
#!/bin/sh
# Copyright (C) 2026 jearton
# SPDX-License-Identifier: GPL-3.0-only
PRECHECK_BIN="${PRECHECK_BIN:-/usr/sbin/tailscale_adguard_dns_switch}"
SECRETS_BIN="${SECRETS_BIN:-/usr/sbin/tailscale_secrets}"
OPENCLASH_BYPASS_BIN="${OPENCLASH_BYPASS_BIN:-/usr/sbin/tailscale_openclash_bypass}"
POLICY_ROUTING_BIN="${POLICY_ROUTING_BIN:-/usr/sbin/tailscale_policy_routing}"
JQ_BIN="${JQ_BIN:-jq}"
print_methods() {
printf '%s\n' '{"secret_status":{},"openclash_bypass_status":{},"policy_routing_status":{},"adguard_preflight":{"candidate":"","api_url":"","username":"","password_set":"","password":"","default_upstreams":"","tailnet_upstreams":"","health_domain":"","expected_ips":""},"set_secrets":{"authkey_set":"","authkey":"","adguard_password_set":"","adguard_password":"","api_url":"","username":"","base_ref":""}}'
}
fail_json() {
message="$1"
"$JQ_BIN" -nc --arg error "$message" '{code: 2, ready: "fail", error: $error}'
exit 1
}
parse_request() {
request="$(cat)"
printf '%s' "$request" | "$JQ_BIN" -e '
type == "object" and
(.candidate | type == "string") and
(.api_url | type == "string") and
(.username | type == "string") and
(.password_set | type == "string") and
(.password | type == "string") and
(.default_upstreams | type == "string") and
(.tailnet_upstreams | type == "string") and
(.health_domain | type == "string") and
(.expected_ips | type == "string")
' >/dev/null 2>&1 || fail_json "invalid request"
candidate="$(printf '%s' "$request" | "$JQ_BIN" -r '.candidate')"
api_url="$(printf '%s' "$request" | "$JQ_BIN" -r '.api_url')"
username="$(printf '%s' "$request" | "$JQ_BIN" -r '.username')"
password_set="$(printf '%s' "$request" | "$JQ_BIN" -r '.password_set')"
password="$(printf '%s' "$request" | "$JQ_BIN" -r '.password')"
default_upstreams="$(printf '%s' "$request" | "$JQ_BIN" -r '.default_upstreams')"
tailnet_upstreams="$(printf '%s' "$request" | "$JQ_BIN" -r '.tailnet_upstreams')"
health_domain="$(printf '%s' "$request" | "$JQ_BIN" -r '.health_domain')"
expected_ips="$(printf '%s' "$request" | "$JQ_BIN" -r '.expected_ips')"
case "$candidate" in
0|1) ;;
*) fail_json "invalid candidate mode" ;;
esac
case "$password_set" in
0|1) ;;
*) fail_json "invalid password mode" ;;
esac
if [ "$candidate" = "1" ]; then
case "$api_url" in
http://?*|https://?*) ;;
*) fail_json "AdGuard API URL must use HTTP or HTTPS" ;;
esac
fi
}
run_preflight() {
if [ "$candidate" = "1" ] && [ "$password_set" = "0" ]; then
"$SECRETS_BIN" matches-adguard "$api_url" "$username" >/dev/null 2>&1 || \
fail_json "saved AdGuard password is not bound to this API URL and username"
fi
if [ "$candidate" = "1" ]; then
if output="$(
TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE=1 \
TAILSCALE_ADGUARD_PREFLIGHT_API_URL="$api_url" \
TAILSCALE_ADGUARD_PREFLIGHT_USERNAME="$username" \
TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET="$password_set" \
TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD="$password" \
TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS="$default_upstreams" \
TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS="$tailnet_upstreams" \
TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN="$health_domain" \
TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS="$expected_ips" \
"$PRECHECK_BIN" --preflight
)"; then
code=0
else
code=$?
fi
else
if output="$("$PRECHECK_BIN" --preflight)"; then
code=0
else
code=$?
fi
fi
printf '%s\n' "$output" | "$JQ_BIN" -Rn --argjson code "$code" '
reduce inputs as $line ({code: $code};
($line | index("=")) as $separator |
if $separator == null then .
else . + {($line[0:$separator]): $line[$separator + 1:]}
end
)
'
}
run_secret_status() {
"$SECRETS_BIN" migrate >/dev/null 2>&1 || fail_json "failed to migrate protected credentials"
authkey_set=0
adguard_password_set=0
"$SECRETS_BIN" has authkey >/dev/null 2>&1 && authkey_set=1
"$SECRETS_BIN" has adguard_password >/dev/null 2>&1 && adguard_password_set=1
"$JQ_BIN" -nc \
--arg authkey_set "$authkey_set" \
--arg adguard_password_set "$adguard_password_set" \
'{authkey_set:$authkey_set,adguard_password_set:$adguard_password_set}'
}
run_openclash_bypass_status() {
if output="$("$OPENCLASH_BYPASS_BIN" status 2>&1)"; then
:
else
fail_json "failed to read OpenClash bypass status"
fi
printf '%s\n' "$output" | "$JQ_BIN" -se 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1 || \
fail_json "invalid OpenClash bypass status"
printf '%s\n' "$output"
}
run_policy_routing_status() {
if output="$("$POLICY_ROUTING_BIN" status 2>&1)"; then
:
else
fail_json "failed to read Tailscale policy-routing status"
fi
printf '%s\n' "$output" | "$JQ_BIN" -se 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1 || \
fail_json "invalid Tailscale policy-routing status"
printf '%s\n' "$output"
}
run_set_secrets() {
request="$(cat)"
printf '%s' "$request" | "$JQ_BIN" -e '
def valid_ref_or_empty:
type == "string" and
all(explode[];
(. >= 48 and . <= 57) or
(. >= 65 and . <= 90) or
(. >= 97 and . <= 122) or
. == 45 or . == 46 or . == 95
);
type == "object" and
(.authkey_set | type == "string" and (. == "0" or . == "1")) and
(.authkey | type == "string") and
(.adguard_password_set | type == "string" and (. == "0" or . == "1")) and
(.adguard_password | type == "string") and
(.api_url | type == "string") and
(.username | type == "string") and
(.base_ref | valid_ref_or_empty) and
(if .authkey_set == "1" then (.authkey | length > 0) else true end) and
(if .adguard_password_set == "1" then
(.adguard_password | length > 0)
else true end)
' >/dev/null 2>&1 || fail_json "invalid credential batch request"
authkey_set="$(printf '%s' "$request" | "$JQ_BIN" -r '.authkey_set')"
adguard_password_set="$(printf '%s' "$request" | "$JQ_BIN" -r '.adguard_password_set')"
base_ref="$(printf '%s' "$request" | "$JQ_BIN" -r '.base_ref')"
case "$base_ref" in
*[!A-Za-z0-9_.-]*) fail_json "invalid credential batch request" ;;
esac
if [ "$adguard_password_set" = "1" ]; then
api_url="$(printf '%s' "$request" | "$JQ_BIN" -r '.api_url')"
case "$api_url" in
http://?*|https://?*) ;;
*) fail_json "invalid credential batch request" ;;
esac
fi
if [ "$authkey_set" = "0" ] && [ "$adguard_password_set" = "0" ]; then
"$JQ_BIN" -nc --arg ref "$base_ref" '{code:0,ref:$ref}'
return 0
fi
if ! ref="$(printf '%s' "$request" | "$JQ_BIN" -c '
{
authkey: (if .authkey_set == "1" then .authkey else null end),
adguard: (if .adguard_password_set == "1" then {
password: .adguard_password,
api_url: .api_url,
username: .username
} else null end)
}
' | "$SECRETS_BIN" stage-batch "$base_ref")"; then
fail_json "failed to stage protected credentials"
fi
case "$ref" in
''|*[!A-Za-z0-9_.-]*) fail_json "invalid protected credential reference" ;;
esac
"$JQ_BIN" -nc --arg ref "$ref" '{code:0,ref:$ref}'
}
case "${1:-}" in
list)
print_methods
;;
call)
case "${2:-}" in
secret_status)
run_secret_status
;;
openclash_bypass_status)
run_openclash_bypass_status
;;
policy_routing_status)
run_policy_routing_status
;;
adguard_preflight)
parse_request
run_preflight
;;
set_secrets)
run_set_secrets
;;
*) exit 1 ;;
esac
;;
*)
exit 1
;;
esac
@@ -0,0 +1,609 @@
#!/bin/sh
# Copyright (C) 2026 jearton
# SPDX-License-Identifier: GPL-3.0-only
UCI_CMD="${UCI_CMD:-uci}"
SECRETS_BIN="${SECRETS_BIN:-/usr/sbin/tailscale_secrets}"
CURL_CMD="${CURL_CMD:-curl}"
FLOCK_CMD="${FLOCK_CMD:-flock}"
JQ_CMD="${JQ_CMD:-jq}"
LOGGER_CMD="${LOGGER_CMD:-logger}"
NSLOOKUP_CMD="${NSLOOKUP_CMD:-nslookup}"
PGREP_CMD="${PGREP_CMD:-pgrep}"
NETSTAT_CMD="${NETSTAT_CMD:-netstat}"
UBUS_CMD="${UBUS_CMD:-ubus}"
SLEEP_CMD="${SLEEP_CMD:-sleep}"
DATE_CMD="${DATE_CMD:-date}"
CURL_CONNECT_TIMEOUT="${CURL_CONNECT_TIMEOUT:-3}"
CURL_MAX_TIME="${CURL_MAX_TIME:-10}"
STATE_DIR="${STATE_DIR:-/var/run/tailscale_adguard_dns_switch}"
TAG="tailscale_adguard_dns"
FAILURE_LOG_INTERVAL=300
umask 077
log_msg() {
priority="$1"
message="$2"
"$LOGGER_CMD" -p "$priority" -t "$TAG" "$message"
}
ensure_state_dir() {
if [ -L "$STATE_DIR" ]; then
echo "refusing symlink state directory: $STATE_DIR" >&2
return 1
fi
if [ -e "$STATE_DIR" ]; then
[ -d "$STATE_DIR" ] || return 1
owner="$(ls -nd "$STATE_DIR" 2>/dev/null | awk '{print $3}')"
[ -n "$owner" ] && [ "$owner" = "$(id -u)" ] || return 1
else
mkdir -m 0700 "$STATE_DIR" || return 1
fi
chmod 0700 "$STATE_DIR"
}
state_temp_file() {
prefix="$1"
ensure_state_dir || return 1
mktemp "$STATE_DIR/$prefix.XXXXXX"
}
write_state_atomic() {
destination="$1"
temp_file="$(state_temp_file state)" || return 1
if ! cat >"$temp_file" || ! mv -f "$temp_file" "$destination"; then
rm -f "$temp_file"
return 1
fi
}
state_key() {
printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g'
}
log_rate_limited() {
priority="$1"
key="$(state_key "$2")"
message="$3"
last_file="$STATE_DIR/$key.last_error"
last_seen=
now="$($DATE_CMD +%s 2>/dev/null || true)"
case "$now" in
''|*[!0-9]*) now=0 ;;
esac
[ -r "$last_file" ] && last_seen="$(cat "$last_file" 2>/dev/null || true)"
case "$last_seen" in
''|*[!0-9]*) last_seen= ;;
esac
if [ -z "$last_seen" ] || [ $((now - last_seen)) -ge "$FAILURE_LOG_INTERVAL" ]; then
ensure_state_dir || return 1
log_msg "$priority" "$message"
printf '%s\n' "$now" | write_state_atomic "$last_file"
fi
}
candidate_mode() {
[ "${TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE:-0}" = "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"
}
persisted_uci_get() {
"$UCI_CMD" -q get "tailscale.settings.$1" 2>/dev/null || true
}
protected_adguard_password() {
if candidate_mode; then
url="${TAILSCALE_ADGUARD_PREFLIGHT_API_URL:-}"
user="${TAILSCALE_ADGUARD_PREFLIGHT_USERNAME:-}"
else
url="$(persisted_uci_get adguard_api_url)"
user="$(persisted_uci_get adguard_username)"
fi
url="$(normalize_api_url "$url")" || return 0
"$SECRETS_BIN" adguard-password-for "$url" "$user" 2>/dev/null || true
}
uci_get() {
if candidate_mode; then
case "$1" in
adguard_api_url) printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_API_URL:-}"; return 0 ;;
adguard_username) printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_USERNAME:-}"; return 0 ;;
adguard_password)
if [ "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET:-0}" = "1" ]; then
printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD:-}"
return 0
fi
protected_adguard_password
return 0
;;
adguard_health_domain) printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN:-}"; return 0 ;;
esac
fi
if [ "$1" = "adguard_password" ]; then
protected_adguard_password
return 0
fi
persisted_uci_get "$1"
}
uci_get_raw() {
"$UCI_CMD" -q get "$1" 2>/dev/null || true
}
uci_list() {
option="$1"
if candidate_mode; then
case "$option" in
adguard_default_upstreams) value="${TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS:-}" ;;
adguard_tailnet_upstreams) value="${TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS:-}" ;;
adguard_health_expected_ips) value="${TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS:-}" ;;
*) value=__tailscale_no_candidate_value__ ;;
esac
if [ "$value" != "__tailscale_no_candidate_value__" ]; then
printf '%s\n' "$value" | sed '/^$/d'
return 0
fi
fi
"$UCI_CMD" -q get "tailscale.settings.$option" 2>/dev/null | tr ' ' '\n' | sed '/^$/d'
}
enabled() {
[ "$(uci_get adguard_dns_switch_enabled)" = "1" ]
}
dedupe_lines() {
awk 'NF && !seen[$0]++'
}
default_upstreams() {
uci_list adguard_default_upstreams
}
tailnet_upstreams() {
uci_list adguard_tailnet_upstreams
}
default_conditionals_without_tailnet_dns() {
default_upstreams | awk 'index($0, "100.100.100.100") == 0 && index($0, "[") > 0'
}
default_plain_without_tailnet_dns() {
default_upstreams | awk 'index($0, "100.100.100.100") == 0 && index($0, "[") == 0'
}
profile_lines() {
local profile="$1"
case "$profile" in
up)
{
default_conditionals_without_tailnet_dns
tailnet_upstreams
default_plain_without_tailnet_dns
default_upstreams | grep -F '100.100.100.100' || true
} | dedupe_lines
;;
down)
default_upstreams | grep -Fv '100.100.100.100' | dedupe_lines
;;
*)
echo "unknown profile: $profile" >&2
return 2
;;
esac
}
expected_ips() {
uci_list adguard_health_expected_ips
}
health_domain() {
uci_get adguard_health_domain
}
health_dns() {
printf '%s\n' "100.100.100.100"
}
output_has_ip() {
output="$1"
expected="$2"
printf '%s\n' "$output" | awk -v ip="$expected" '
{
pos = index($0, ip)
if (pos > 0) {
before = pos > 1 ? substr($0, pos - 1, 1) : ""
after = substr($0, pos + length(ip), 1)
if (before !~ /[0-9A-Fa-f:.]/ && after !~ /[0-9A-Fa-f:.]/)
found = 1
}
}
END { exit found ? 0 : 1 }
'
}
check_health() {
domain="$(health_domain)"
dns="$(health_dns)"
[ -n "$(tailnet_upstreams)" ] || return 1
[ -n "$domain" ] || return 1
[ -n "$(expected_ips)" ] || return 1
output="$("$NSLOOKUP_CMD" "$domain" "$dns" 2>&1)" || return 1
for expected in $(expected_ips); do
output_has_ip "$output" "$expected" && return 0
done
return 1
}
api_url() {
url="$(uci_get adguard_api_url)"
normalize_api_url "$url"
}
curl_config_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
run_curl() (
user="$(uci_get adguard_username)"
pass="$(uci_get adguard_password)"
if [ -n "$user" ]; then
auth_file="$(state_temp_file curl-auth)" || exit 1
cleanup_curl_auth() {
rm -f "$auth_file"
}
trap cleanup_curl_auth 0 1 2 15
escaped_user="$(curl_config_escape "$user")" || exit 1
escaped_pass="$(curl_config_escape "$pass")" || exit 1
printf 'user = "%s:%s"\n' "$escaped_user" "$escaped_pass" >"$auth_file" || exit 1
chmod 0600 "$auth_file" || exit 1
"$CURL_CMD" --config "$auth_file" "$@"
else
"$CURL_CMD" "$@"
fi
)
curl_get() {
endpoint="$1"
base="$(api_url)" || return 1
run_curl -fsS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" "$base$endpoint"
}
curl_post_json() {
endpoint="$1"
body_file="$2"
base="$(api_url)" || return 1
run_curl -fsS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" -H "Content-Type: application/json" -X POST --data-binary "@$body_file" "$base$endpoint"
}
check_adguard_api() (
api_check_body=
api_check_response=
api_check_upstreams=
cleanup_preflight_files() {
for cleanup_file in "$api_check_body" "$api_check_response" "$api_check_upstreams"; do
[ -z "$cleanup_file" ] || rm -f "$cleanup_file"
done
}
trap cleanup_preflight_files 0 1 2 15
api_check_body="$(state_temp_file preflight-body)" || exit 1
api_check_response="$(state_temp_file preflight-response)" || exit 1
api_check_upstreams="$(state_temp_file preflight-upstreams)" || exit 1
curl_get "/control/status" >/dev/null || return 1
profile_lines down >"$api_check_upstreams" || return 1
if profile_is_empty "$api_check_upstreams"; then
log_rate_limited daemon.err api_default_upstreams_empty "AdGuard API preflight requires at least one valid default upstream"
return 1
fi
"$JQ_CMD" -nc --rawfile upstreams "$api_check_upstreams" '
{upstream_dns: ($upstreams | split("\n") | map(select(length > 0)))}
' >"$api_check_body" || {
log_rate_limited daemon.err api_preflight_payload "failed to build AdGuard API preflight payload"
return 1
}
curl_post_json "/control/test_upstream_dns" "$api_check_body" >"$api_check_response" || return 1
"$JQ_CMD" -e '
type == "object" and length > 0 and all(.[]; . == "OK")
' "$api_check_response" >/dev/null || return 1
)
json_replace_upstreams() {
dns_info="$1"
upstream_file="$2"
"$JQ_CMD" -c --rawfile upstreams "$upstream_file" '
.upstream_dns = ($upstreams | split("\n") | map(select(length > 0)))
' "$dns_info"
}
profile_is_empty() {
[ ! -s "$1" ] || ! awk 'NF { found=1 } END { exit found ? 0 : 1 }' "$1"
}
validate_profile_file() {
local profile="$1"
local upstream_file="$2"
if profile_is_empty "$upstream_file"; then
log_rate_limited daemon.err "profile_empty_$profile" "generated AdGuard DNS $profile profile is empty"
return 1
fi
if [ "$profile" = "up" ]; then
[ -n "$(tailnet_upstreams)" ] || {
log_rate_limited daemon.err profile_tailnet_upstreams_empty "AdGuard DNS up profile requires tailnet upstreams"
return 1
}
[ -n "$(health_domain)" ] && [ -n "$(expected_ips)" ] || {
log_rate_limited daemon.err profile_health_settings_empty "AdGuard DNS up profile requires health settings"
return 1
}
fi
}
apply_profile_locked() (
profile="$1"
dns_info=
body=
upstreams=
down_snapshot=
cleanup_profile_files() {
for cleanup_file in "$dns_info" "$body" "$upstreams" "$down_snapshot"; do
[ -z "$cleanup_file" ] || rm -f "$cleanup_file"
done
}
trap cleanup_profile_files 0 1 2 15
dns_info="$(state_temp_file dns-info)" || exit 1
body="$(state_temp_file dns-config)" || exit 1
upstreams="$(state_temp_file upstreams)" || exit 1
profile_lines "$profile" >"$upstreams" || {
log_rate_limited daemon.err "profile_generate_$profile" "failed to generate AdGuard DNS $profile profile"
return 1
}
if [ "$profile" = "down" ] && profile_is_empty "$upstreams" && \
[ "$(cat "$STATE_DIR/current_profile" 2>/dev/null || true)" = "up" ] && \
[ -s "$STATE_DIR/down_profile" ]; then
cat "$STATE_DIR/down_profile" >"$upstreams" || return 1
fi
validate_profile_file "$profile" "$upstreams" || return 1
if [ "$profile" = "up" ]; then
down_snapshot="$(state_temp_file down-profile)" || return 1
profile_lines down >"$down_snapshot" || return 1
validate_profile_file down "$down_snapshot" || return 1
cat "$down_snapshot" | write_state_atomic "$STATE_DIR/down_profile" || return 1
fi
curl_get "/control/dns_info" >"$dns_info" || {
log_rate_limited daemon.err profile_read "failed to read AdGuard DNS config"
return 1
}
json_replace_upstreams "$dns_info" "$upstreams" >"$body" || {
log_rate_limited daemon.err "profile_build_$profile" "failed to build AdGuard DNS config payload"
return 1
}
curl_post_json "/control/dns_config" "$body" >/dev/null || {
log_rate_limited daemon.err "profile_write_$profile" "failed to write AdGuard DNS $profile profile"
return 1
}
curl_post_json "/control/cache_clear" /dev/null >/dev/null 2>&1 || \
log_rate_limited daemon.warn "profile_cache_clear_$profile" "AdGuard cache clear failed after $profile profile switch"
printf '%s\n' "$profile" | write_state_atomic "$STATE_DIR/current_profile" || return 1
log_msg daemon.info "applied AdGuard DNS $profile profile"
)
apply_profile() {
profile="$1"
case "$profile" in
up|down) ;;
*)
echo "unknown profile: $profile" >&2
return 2
;;
esac
ensure_state_dir || return 1
exec 9>"$STATE_DIR/profile.lock" || return 1
"$FLOCK_CMD" -x 9 || return 1
apply_profile_locked "$profile"
}
lan_ip() {
ip="$("$UBUS_CMD" call network.interface.lan status 2>/dev/null | \
sed -n 's/.*"address":"\([0-9.][0-9.]*\)".*/\1/p' | head -n 1)"
if [ -n "$ip" ]; then
printf '%s\n' "$ip"
return 0
fi
uci_get_raw network.lan.ipaddr
}
dhcp_options() {
options="$(uci_get_raw dhcp.lan.dhcp_option)"
if [ -n "$options" ]; then
printf '%s\n' "$options"
else
"$UCI_CMD" show dhcp.lan.dhcp_option 2>/dev/null | \
sed -n "s/^dhcp\\.lan\\.dhcp_option='\\(.*\\)'$/\\1/p"
fi
}
check_dhcp_option() {
ip="$(lan_ip)"
[ -n "$ip" ] || return 1
for option in $(dhcp_options); do
[ "$option" = "6,$ip" ] && return 0
done
return 1
}
check_port_53_adguard() {
"$NETSTAT_CMD" -lnup 2>/dev/null | \
grep -E '[:.]53[[:space:]]' | \
grep -F 'AdGuardHome' >/dev/null
}
preflight() {
ready=pass
for item in adguard_process port_53_adguard dhcp_advertises_lan_dns adguard_api health_check; do
case "$item" in
adguard_process) "$PGREP_CMD" -f AdGuardHome >/dev/null 2>&1 ;;
port_53_adguard) check_port_53_adguard ;;
dhcp_advertises_lan_dns) check_dhcp_option ;;
adguard_api) check_adguard_api ;;
health_check) check_health ;;
esac
if [ "$?" -eq 0 ]; then
printf '%s=pass\n' "$item"
else
printf '%s=fail\n' "$item"
ready=fail
fi
done
printf 'ready=%s\n' "$ready"
[ "$ready" = "pass" ]
}
startup_preflight() {
failures=
for item in adguard_process port_53_adguard dhcp_advertises_lan_dns adguard_api; do
case "$item" in
adguard_process) "$PGREP_CMD" -f AdGuardHome >/dev/null 2>&1 ;;
port_53_adguard) check_port_53_adguard ;;
dhcp_advertises_lan_dns) check_dhcp_option ;;
adguard_api) check_adguard_api ;;
esac
if [ "$?" -ne 0 ]; then
failures="${failures:+$failures,}$item"
fi
done
[ -z "$failures" ] || {
printf '%s\n' "$failures"
return 1
}
}
valid_positive_number() {
case "$1" in
''|*[!0-9]*) return 1 ;;
0) return 1 ;;
*) return 0 ;;
esac
}
run_loop() {
enabled || {
log_msg daemon.info "AdGuard DNS auto switch disabled"
return 0
}
interval="$(uci_get adguard_check_interval)"
success_threshold="$(uci_get adguard_success_threshold)"
failure_threshold="$(uci_get adguard_failure_threshold)"
valid_positive_number "$interval" || interval=10
valid_positive_number "$success_threshold" || success_threshold=2
valid_positive_number "$failure_threshold" || failure_threshold=2
ensure_state_dir || return 1
success_count=0
failure_count=0
while :; do
if static_failures="$(startup_preflight)"; then
break
fi
log_rate_limited daemon.err "static_preflight_$static_failures" "AdGuard DNS static preflight failed: $static_failures"
"$SLEEP_CMD" "$interval"
done
while :; do
current="$(cat "$STATE_DIR/current_profile" 2>/dev/null || true)"
desired="$current"
if check_health; then
success_count=$((success_count + 1))
failure_count=0
if [ "$success_count" -ge "$success_threshold" ]; then
desired=up
fi
else
failure_count=$((failure_count + 1))
success_count=0
if [ "$failure_count" -ge "$failure_threshold" ]; then
desired=down
fi
fi
if [ -n "$desired" ] && [ "$desired" != "$current" ]; then
apply_profile "$desired" || true
fi
"$SLEEP_CMD" "$interval"
done
}
case "${1:-}" in
--profile)
profile_lines "${2:-}"
;;
--check-health)
check_health
;;
--preflight)
preflight
;;
--apply-profile)
apply_profile "${2:-}"
;;
--run)
run_loop
;;
*)
echo "usage: $0 --profile up|down | --check-health | --preflight | --apply-profile up|down | --run" >&2
exit 2
;;
esac
+523
View File
@@ -0,0 +1,523 @@
#!/bin/sh
# Copyright (C) 2025 asvow
# SPDX-License-Identifier: GPL-3.0-only
TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}"
LOGGER_CMD="${LOGGER_CMD:-logger}"
FLOCK_BIN="${FLOCK_BIN:-flock}"
IFCONFIG_BIN="${IFCONFIG_BIN:-ifconfig}"
FIREWALL_INIT="${FIREWALL_INIT:-/etc/init.d/firewall}"
DNSMASQ_INIT="${DNSMASQ_INIT:-/etc/init.d/dnsmasq}"
TAILSCALE_SECRETS_BIN="${TAILSCALE_SECRETS_BIN:-/usr/sbin/tailscale_secrets}"
TAILSCALE_HELPER_STATE_DIR="${TAILSCALE_HELPER_STATE_DIR:-/var/lib/tailscale}"
EXIT_NODE_FIREWALL_STATE_FILE="$TAILSCALE_HELPER_STATE_DIR/exit_node_firewall_state"
FIREWALL_PENDING_STATE_FILE="${FIREWALL_PENDING_STATE_FILE:-/var/run/tailscale-firewall-pending}"
LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale.lock}"
TAILSCALE_PORT="${TAILSCALE_PORT:-41641}"
TAILSCALE_STATE_WAS_PRESENT="${TAILSCALE_STATE_WAS_PRESENT:-0}"
ALLOW_WAN_DIRECT="${ALLOW_WAN_DIRECT:-0}"
WAN_DIRECT_ZONES="${WAN_DIRECT_ZONES:-wan}"
POLICY_ROUTING_HELPER="${POLICY_ROUTING_HELPER:-/usr/sbin/tailscale_policy_routing}"
log_error() {
"$LOGGER_CMD" -p daemon.err -t tailscale_helper "$(date '+%Y/%m/%d %H:%M:%S') $1"
}
# Error handling function
revert_exit() {
log_error "$1"
uci revert dhcp && uci revert network && uci revert firewall
exit 1
}
find_lan_wan_forwarding() {
uci show firewall | awk -F= '
$1 ~ /^firewall\.@forwarding\[[0-9]+\]$/ && $2 == "forwarding" {
section=$1
next
}
$1 ~ /^firewall\.@forwarding\[[0-9]+\]\.src$/ {
value=$2
gsub(/\047/, "", value)
if (section != "" && value == "lan") {
src[section]=1
}
next
}
$1 ~ /^firewall\.@forwarding\[[0-9]+\]\.dest$/ {
value=$2
gsub(/\047/, "", value)
if (section != "" && value == "wan") {
dest[section]=1
}
}
END {
for (section in src) {
if (dest[section]) {
print section
exit
}
}
}
'
}
valid_port() {
case "$1" in
''|*[!0-9]*) return 1 ;;
*) [ "$1" -ge 1 ] && [ "$1" -le 65535 ] ;;
esac
}
normalize_uci_id() {
printf '%s' "$1" | sed 's/[^A-Za-z0-9_]/_/g'
}
firewall_section_has_only_options() {
local section="$1"
local allowed="$2"
local output option
output="$(uci -q show "firewall.$section" 2>/dev/null)" || return 1
for option in $(printf '%s\n' "$output" | sed -n "s/^firewall\\.$section\\.\\([^.=]*\\)=.*/\\1/p"); do
case " $allowed " in
*" $option "*) ;;
*) return 1 ;;
esac
done
}
delete_named_firewall_sections() {
local output section
output="$(uci -q show firewall 2>/dev/null)" || return 1
for section in "$@"; do
if printf '%s\n' "$output" | grep -q "^firewall\\.$section="; then
uci -q delete "firewall.$section" || return 1
fi
done
}
delete_wan_direct_rules() {
local output section
output="$(uci -q show firewall 2>/dev/null)" || return 1
if printf '%s\n' "$output" | grep -q '^firewall\.ts_wan_direct='; then
uci -q delete firewall.ts_wan_direct || return 1
fi
for section in $(printf '%s\n' "$output" | sed -n 's/^firewall\.\(ts_wan_direct_[^.=]*\)=[^=]*$/\1/p'); do
uci -q delete "firewall.$section" || return 1
done
}
wan_direct_rule_matches() {
local section="$1"
local port="$2"
local zone="$3"
[ "$(uci -q get "firewall.$section" 2>/dev/null)" = "rule" ] &&
[ "$(uci -q get "firewall.$section.name" 2>/dev/null)" = "Allow-Tailscale-WAN-$port-$zone" ] &&
[ "$(uci -q get "firewall.$section.src" 2>/dev/null)" = "$zone" ] &&
[ "$(uci -q get "firewall.$section.proto" 2>/dev/null)" = "udp" ] &&
[ "$(uci -q get "firewall.$section.dest_port" 2>/dev/null)" = "$port" ] &&
[ "$(uci -q get "firewall.$section.target" 2>/dev/null)" = "ACCEPT" ] &&
firewall_section_has_only_options "$section" "name src proto dest_port target"
}
wan_direct_rules_match() {
local port="$1"
local output zones zone zone_id section index seen current_count
output="$(uci -q show firewall 2>/dev/null)" || return 1
printf '%s\n' "$output" | grep -q '^firewall\.ts_wan_direct=' && return 1
current_count=0
for section in $(printf '%s\n' "$output" | sed -n 's/^firewall\.\(ts_wan_direct_[^.=]*\)=[^=]*$/\1/p'); do
current_count=$((current_count + 1))
done
if [ "$ALLOW_WAN_DIRECT" != "1" ]; then
[ "$current_count" -eq 0 ]
return $?
fi
zones="${WAN_DIRECT_ZONES:-wan}"
index=0
seen=" "
for zone in $zones; do
[ -n "$zone" ] || continue
case "$seen" in
*" $zone "*) continue ;;
esac
seen="${seen}${zone} "
index=$((index + 1))
zone_id="$(normalize_uci_id "$zone")"
[ -n "$zone_id" ] || zone_id="zone"
section="ts_wan_direct_${index}_${zone_id}"
wan_direct_rule_matches "$section" "$port" "$zone" || return 1
done
[ "$index" -gt 0 ] && [ "$current_count" -eq "$index" ]
}
configure_wan_direct_rule() {
local port zones zone zone_id section index seen
port="$TAILSCALE_PORT"
valid_port "$port" || port=41641
wan_direct_rules_match "$port" && return 0
delete_wan_direct_rules || return 1
[ "$ALLOW_WAN_DIRECT" = "1" ] || return 0
zones="${WAN_DIRECT_ZONES:-wan}"
index=0
seen=" "
for zone in $zones; do
[ -n "$zone" ] || continue
case "$seen" in
*" $zone "*) continue ;;
esac
seen="${seen}${zone} "
index=$((index + 1))
zone_id="$(normalize_uci_id "$zone")"
[ -n "$zone_id" ] || zone_id="zone"
section="ts_wan_direct_${index}_${zone_id}"
uci set "firewall.$section=rule" || return 1
uci set "firewall.$section.name=Allow-Tailscale-WAN-$port-$zone" || return 1
uci set "firewall.$section.src=$zone" || return 1
uci set "firewall.$section.proto=udp" || return 1
uci set "firewall.$section.dest_port=$port" || return 1
uci set "firewall.$section.target=ACCEPT" || return 1
done
[ "$index" -gt 0 ] || return 1
}
access_has() {
case " $ACCESS " in
*" $1 "*) return 0 ;;
*) return 1 ;;
esac
}
tailscale_zone_matches() {
local masq="$1"
[ "$(uci -q get firewall.tszone 2>/dev/null)" = "zone" ] &&
[ "$(uci -q get firewall.tszone.input 2>/dev/null)" = "ACCEPT" ] &&
[ "$(uci -q get firewall.tszone.output 2>/dev/null)" = "ACCEPT" ] &&
[ "$(uci -q get firewall.tszone.forward 2>/dev/null)" = "ACCEPT" ] &&
[ "$(uci -q get firewall.tszone.masq 2>/dev/null)" = "$masq" ] &&
[ "$(uci -q get firewall.tszone.mtu_fix 2>/dev/null)" = "1" ] &&
[ "$(uci -q get firewall.tszone.name 2>/dev/null)" = "tailscale" ] &&
[ "$(uci -q get firewall.tszone.device 2>/dev/null)" = "tailscale0" ] &&
firewall_section_has_only_options tszone "input output forward masq mtu_fix name device"
}
forwarding_rule_matches() {
local section="$1"
local src="$2"
local dest="$3"
[ "$(uci -q get "firewall.$section" 2>/dev/null)" = "forwarding" ] &&
[ "$(uci -q get "firewall.$section.src" 2>/dev/null)" = "$src" ] &&
[ "$(uci -q get "firewall.$section.dest" 2>/dev/null)" = "$dest" ] &&
firewall_section_has_only_options "$section" "src dest"
}
tailscale_access_rules_match() {
local masq="$1"
local output section token src dest zone_required=0
output="$(uci -q show firewall 2>/dev/null)" || return 1
[ -n "$ACCESS" ] && zone_required=1
[ "$DISABLE_SNAT_SUBNET_ROUTES" = "1" ] && zone_required=1
if [ "$zone_required" = 0 ]; then
for section in tszone ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do
printf '%s\n' "$output" | grep -q "^firewall\\.$section=" && return 1
done
return 0
fi
tailscale_zone_matches "$masq" || return 1
for token in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do
case "$token" in
ts_ac_lan) src=tailscale; dest=lan ;;
ts_ac_wan) src=tailscale; dest=wan ;;
lan_ac_ts) src=lan; dest=tailscale ;;
wan_ac_ts) src=wan; dest=tailscale ;;
esac
if access_has "$token"; then
forwarding_rule_matches "$token" "$src" "$dest" || return 1
elif printf '%s\n' "$output" | grep -q "^firewall\\.$token="; then
return 1
fi
done
}
configure_tailscale_access_rules() {
local masq=1 token src dest
[ "$DISABLE_SNAT_SUBNET_ROUTES" = "1" ] && masq=0
tailscale_access_rules_match "$masq" && return 0
delete_named_firewall_sections tszone ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts || return 1
[ -n "$ACCESS" ] || [ "$DISABLE_SNAT_SUBNET_ROUTES" = "1" ] || return 0
uci set firewall.tszone='zone' || return 1
uci set firewall.tszone.input='ACCEPT' || return 1
uci set firewall.tszone.output='ACCEPT' || return 1
uci set firewall.tszone.forward='ACCEPT' || return 1
uci set "firewall.tszone.masq=$masq" || return 1
uci set firewall.tszone.mtu_fix='1' || return 1
uci set firewall.tszone.name='tailscale' || return 1
uci add_list firewall.tszone.device='tailscale0' || return 1
for token in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do
access_has "$token" || continue
case "$token" in
ts_ac_lan) src=tailscale; dest=lan ;;
ts_ac_wan) src=tailscale; dest=wan ;;
lan_ac_ts) src=lan; dest=tailscale ;;
wan_ac_ts) src=wan; dest=tailscale ;;
esac
uci set "firewall.$token=forwarding" || return 1
uci set "firewall.$token.src=$src" || return 1
uci set "firewall.$token.dest=$dest" || return 1
done
}
apply_firewall_changes() {
local changes pending_dir had_pending=0
changes="$(uci changes firewall 2>/dev/null)" || return 1
[ -e "$FIREWALL_PENDING_STATE_FILE" ] && had_pending=1
if [ -n "$changes" ]; then
pending_dir="${FIREWALL_PENDING_STATE_FILE%/*}"
[ "$pending_dir" = "$FIREWALL_PENDING_STATE_FILE" ] && pending_dir=.
mkdir -p "$pending_dir" || return 1
: >"$FIREWALL_PENDING_STATE_FILE" || return 1
if ! uci commit firewall; then
[ "$had_pending" -eq 1 ] || rm -f "$FIREWALL_PENDING_STATE_FILE"
return 1
fi
fi
[ -e "$FIREWALL_PENDING_STATE_FILE" ] || return 0
if "$FIREWALL_INIT" reload || "$FIREWALL_INIT" restart; then
rm -f "$FIREWALL_PENDING_STATE_FILE" || return 1
return 0
fi
return 1
}
save_exit_node_firewall_state() {
local default_forward lan_wan_section lan_wan_enabled state_tmp
[ -f "$EXIT_NODE_FIREWALL_STATE_FILE" ] && return 0
mkdir -p "$TAILSCALE_HELPER_STATE_DIR" || return 1
state_tmp="${EXIT_NODE_FIREWALL_STATE_FILE}.tmp"
: >"$state_tmp" || return 1
if default_forward="$(uci get firewall.@defaults[0].forward 2>/dev/null)"; then
printf 'default_forward_present=1\n' >>"$state_tmp"
printf 'default_forward_value=%s\n' "$default_forward" >>"$state_tmp"
else
printf 'default_forward_present=0\n' >>"$state_tmp"
fi
lan_wan_section="$(find_lan_wan_forwarding)"
if [ -n "$lan_wan_section" ]; then
printf 'lan_wan_forwarding_present=1\n' >>"$state_tmp"
if lan_wan_enabled="$(uci get "$lan_wan_section.enabled" 2>/dev/null)"; then
printf 'lan_wan_enabled_present=1\n' >>"$state_tmp"
printf 'lan_wan_enabled_value=%s\n' "$lan_wan_enabled" >>"$state_tmp"
else
printf 'lan_wan_enabled_present=0\n' >>"$state_tmp"
fi
else
printf 'lan_wan_forwarding_present=0\n' >>"$state_tmp"
fi
mv "$state_tmp" "$EXIT_NODE_FIREWALL_STATE_FILE"
}
restore_exit_node_firewall_state() {
local default_forward_value default_forward_present lan_wan_enabled_present lan_wan_enabled_value
local lan_wan_section current_value
[ -r "$EXIT_NODE_FIREWALL_STATE_FILE" ] || return 0
default_forward_value=
default_forward_present=0
lan_wan_enabled_present=0
lan_wan_enabled_value=
lan_wan_forwarding_present=0
. "$EXIT_NODE_FIREWALL_STATE_FILE"
if [ "${default_forward_present:-0}" = "1" ]; then
current_value="$(uci get firewall.@defaults[0].forward 2>/dev/null || true)"
if [ "$current_value" != "${default_forward_value:-}" ]; then
uci set "firewall.@defaults[0].forward=${default_forward_value}" || return 1
fi
elif uci get firewall.@defaults[0].forward >/dev/null 2>&1; then
uci -q delete firewall.@defaults[0].forward || return 1
fi
lan_wan_section="$(find_lan_wan_forwarding)"
if [ -n "$lan_wan_section" ] && [ "${lan_wan_forwarding_present:-0}" = "1" ]; then
if [ "${lan_wan_enabled_present:-0}" = "1" ]; then
current_value="$(uci get "$lan_wan_section.enabled" 2>/dev/null || true)"
if [ "$current_value" != "${lan_wan_enabled_value:-}" ]; then
uci set "$lan_wan_section.enabled=${lan_wan_enabled_value}" || return 1
fi
elif uci get "$lan_wan_section.enabled" >/dev/null 2>&1; then
uci -q delete "$lan_wan_section.enabled" || return 1
fi
fi
# Always run the apply path so a previous commit with a pending reload is
# retried even when the restored values already match UCI.
apply_firewall_changes || return 1
rm -f "$EXIT_NODE_FIREWALL_STATE_FILE" || return 1
}
cleanup_managed_firewall() {
restore_exit_node_firewall_state || return 1
ALLOW_WAN_DIRECT=0
configure_wan_direct_rule || return 1
ACCESS=
DISABLE_SNAT_SUBNET_ROUTES=0
configure_tailscale_access_rules || return 1
apply_firewall_changes
}
case "${1:-}" in
--run)
shift
;;
--cleanup-exit-node-firewall)
restore_exit_node_firewall_state || {
log_error "Failed to restore exit-node firewall state."
exit 1
}
exit 0
;;
--cleanup-wan-direct-firewall)
ALLOW_WAN_DIRECT=0
configure_wan_direct_rule || {
log_error "Failed to remove Tailscale WAN direct firewall rules."
exit 1
}
apply_firewall_changes || {
log_error "Failed to apply Tailscale WAN direct firewall cleanup."
exit 1
}
exit 0
;;
--cleanup-managed-firewall)
cleanup_managed_firewall || {
log_error "Failed to remove Tailscale managed firewall state."
exit 1
}
exit 0
;;
*)
log_error "Unsupported tailscale_helper mode: ${1:-<none>}"
exit 2
;;
esac
# Use an auth key only for first enrollment or an explicit replacement request.
# Legacy stored keys are ignored when an existing tailscaled identity is present.
authkey=
if [ "$TAILSCALE_STATE_WAS_PRESENT" != "1" ] || \
"$TAILSCALE_SECRETS_BIN" authkey-pending >/dev/null 2>&1; then
authkey="$("$TAILSCALE_SECRETS_BIN" get authkey 2>/dev/null || true)"
fi
if [ -n "$authkey" ]; then
"$TAILSCALE_BIN" up --reset --authkey="$authkey" "$@" || revert_exit "tailscale up failed."
else
"$TAILSCALE_BIN" up --reset "$@" || revert_exit "tailscale up failed."
fi
unset authkey
# Use flock to acquire an exclusive lock
exec 9> "$LOCK_FILE"
"$FLOCK_BIN" -xn 9 || { revert_exit "Failed to acquire lock on $LOCK_FILE"; }
trap 'rm -f "$LOCK_FILE"; exit' INT TERM EXIT
# Wait for Tailscale IPv4 address
count=0
while [ -z "$("$IFCONFIG_BIN" | grep 'tailscale' | awk '{print $1}')" ] || [ -z "$("$TAILSCALE_BIN" ip -4)" ]; do
sleep 2
count=$((count + 1))
[ "${count}" -ge 5 ] && revert_exit "Failed to get Tailscale IPv4 address after 5 attempts."
done
# Registration keys are one-time inputs. Purge every stored credential version
# only after the connection has a Tailscale address, so failed enrollment keeps
# the key available for correction and retry.
if "$TAILSCALE_SECRETS_BIN" has authkey >/dev/null 2>&1; then
"$TAILSCALE_SECRETS_BIN" clear-authkey || revert_exit "Failed to clear the used Tailscale auth key."
fi
# Configure Tailscale MagicDNS
if [ "$ACCEPT_DNS" = "1" ]; then
MagicDNSSuffix=$("$TAILSCALE_BIN" status --json | awk -F'"' '/"MagicDNSSuffix"/ {last=$(NF-1)} END {print last}')
if [ -n "$MagicDNSSuffix" ]; then
target_address="/$MagicDNSSuffix/100.100.100.100"
index=$(uci show dhcp | sed -n 's/^dhcp\.@dnsmasq\[\([0-9][0-9]*\)\]=dnsmasq$/\1/p')
for i in $index; do
if ! uci -q get "dhcp.@dnsmasq[$i].address" 2>/dev/null | tr ' ' '\n' | grep -qxF "$target_address"; then
uci add_list "dhcp.@dnsmasq[$i].address=$target_address" || revert_exit "Failed to add DNS address."
fi
done
fi
fi
# Remove legacy LuCI network wrappers for tailscale0. tailscaled owns the TUN
# device, policy rules, and table 52 routes; netifd must not manage it.
uci -q delete network.tailscale
uci -q delete network.ts_lan
for route in $(uci show network | grep 'network.ts_subnet[0-9]\+=route' | grep -o 'network.ts_subnet[0-9]\+'); do
uci -q delete "$route"
done
# Configure exit node firewall rules
if [ -n "$EXIT_NODE" ]; then
save_exit_node_firewall_state || revert_exit "Failed to save exit-node firewall state."
uci set firewall.@defaults[0].forward='REJECT' || revert_exit "Failed to set default forward policy to REJECT."
lan_wan_section="$(find_lan_wan_forwarding)"
[ -n "$lan_wan_section" ] && uci set "$lan_wan_section.enabled=0" || true
else
restore_exit_node_firewall_state || revert_exit "Failed to restore exit-node firewall state."
fi
configure_wan_direct_rule || revert_exit "Failed to configure Tailscale WAN direct firewall rule."
configure_tailscale_access_rules || revert_exit "Failed to configure Tailscale firewall zone and forwarding rules."
# Commit configuration changes and reload service
if [ -n "$(uci changes dhcp)" ]; then
uci commit dhcp || revert_exit "Failed to commit Tailscale DNS configuration."
"$DNSMASQ_INIT" reload || revert_exit "Failed to reload dnsmasq."
fi
if [ -n "$(uci changes network)" ]; then
uci commit network || revert_exit "Failed to commit Tailscale network cleanup."
fi
apply_firewall_changes || revert_exit "Failed to commit or apply Tailscale firewall configuration."
# The policy-routing helper owns only its dedicated network UCI rule and the
# matching runtime rule. Its failure must not disrupt an otherwise healthy node.
if [ -x "$POLICY_ROUTING_HELPER" ] && ! "$POLICY_ROUTING_HELPER" sync; then
log_error "Failed to synchronize Tailscale/mwan3 policy routing precedence."
fi
exit 0
+286
View File
@@ -0,0 +1,286 @@
#!/bin/sh
# Copyright (C) 2026 jearton
# SPDX-License-Identifier: GPL-3.0-only
TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}"
LOGGER_CMD="${LOGGER_CMD:-logger}"
JSHN_LIB="${JSHN_LIB:-/usr/share/libubox/jshn.sh}"
log_msg() {
local priority="$1"
local message="$2"
"$LOGGER_CMD" -p "$priority" -t tailscale_keepalive "$message"
}
state_key() {
printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g'
}
log_rate_limited_error() {
local key="$1"
local message="$2"
local interval="$3"
local now="$4"
local last_file="$TMP_STATE_DIR/$key.last_error"
local last_seen=0
[ -r "$last_file" ] && last_seen="$(cat "$last_file" 2>/dev/null || echo 0)"
if [ $((now - last_seen)) -ge "$interval" ]; then
log_msg daemon.err "$message"
printf '%s\n' "$now" >"$last_file"
fi
}
read_status() {
if [ -n "${TAILSCALE_STATUS_FILE:-}" ] && [ -r "$TAILSCALE_STATUS_FILE" ]; then
cat "$TAILSCALE_STATUS_FILE"
else
"$TAILSCALE_BIN" status --json
fi
}
normalize_dns() {
printf '%s' "$1" | sed 's/\.$//'
}
short_dns_name() {
normalize_dns "$1" | sed 's/\..*$//'
}
peer_matches() {
local peer="$1"
local host="$2"
local dns="$3"
local matched_ip="$4"
local normalized_dns short_dns
normalized_dns="$(normalize_dns "$dns")"
short_dns="$(short_dns_name "$dns")"
[ "$host" = "$peer" ] || \
[ "$dns" = "$peer" ] || \
[ "$normalized_dns" = "$peer" ] || \
[ "$short_dns" = "$peer" ] || \
[ "$matched_ip" = "$peer" ]
}
emit_resolved_peer() {
local peer="$1"
local host="$2"
local dns="$3"
local ip="$4"
local matched_ip="$5"
[ -n "$host" ] || [ -n "$dns" ] || [ -n "$matched_ip" ] || return 1
peer_matches "$peer" "$host" "$dns" "$matched_ip" || return 1
if [ -n "$matched_ip" ]; then
printf 'OK\t%s\n' "$matched_ip"
elif [ -n "$ip" ]; then
printf 'OK\t%s\n' "$ip"
else
printf 'NO_IP\t\n'
fi
return 0
}
resolve_peer_with_jshn() {
local peer="$1"
local status keys key host dns ips ip matched_ip value
[ -r "$JSHN_LIB" ] || return 1
status="$(read_status)" || return 1
# OpenWrt ships jshn.sh in the jshn package; it parses JSON independently of
# whitespace and field order.
. "$JSHN_LIB" || return 1
json_cleanup
json_load "$status" || {
json_cleanup
return 1
}
json_select Peer || {
json_cleanup
return 1
}
json_get_keys keys
for key in $keys; do
json_select "$key" || continue
host=
dns=
ips=
ip=
matched_ip=
json_get_var host HostName
json_get_var dns DNSName
if json_select TailscaleIPs; then
json_get_values ips
for value in $ips; do
[ -n "$ip" ] || ip="$value"
[ "$value" = "$peer" ] && matched_ip="$value"
done
json_select ..
fi
if emit_resolved_peer "$peer" "$host" "$dns" "$ip" "$matched_ip"; then
json_cleanup
return 0
fi
json_select ..
done
json_cleanup
printf 'NOT_FOUND\t\n'
return 0
}
resolve_peer_with_python() {
local peer="$1"
local status
command -v python3 >/dev/null 2>&1 || return 1
status="$(read_status)" || return 1
STATUS_JSON="$status" python3 - "$peer" <<'PY'
import json
import os
import sys
peer = sys.argv[1]
try:
status = json.loads(os.environ["STATUS_JSON"])
except Exception:
sys.exit(1)
def norm_dns(value):
return value[:-1] if value.endswith(".") else value
def short_dns(value):
return norm_dns(value).split(".", 1)[0]
for item in status.get("Peer", {}).values():
host = item.get("HostName", "")
dns = item.get("DNSName", "")
ips = item.get("TailscaleIPs") or []
matched_ip = next((value for value in ips if value == peer), "")
if peer in (host, dns, norm_dns(dns), short_dns(dns)) or matched_ip:
if matched_ip:
print("OK\t%s" % matched_ip)
elif ips:
print("OK\t%s" % ips[0])
else:
print("NO_IP\t")
sys.exit(0)
print("NOT_FOUND\t")
PY
}
resolve_peer_from_status() {
local peer="$1"
resolve_peer_with_jshn "$peer" && return 0
resolve_peer_with_python "$peer" && return 0
printf 'ERROR\tno JSON parser available\n'
}
resolve_peers() {
local peer result
for peer in "$@"; do
result="$(resolve_peer_from_status "$peer")"
printf '%s\t%s\n' "$peer" "$result"
done
}
run_once() {
local failure_log_interval="$1"
shift
local now peer result status ip peer_key last_success
now="$(date +%s)"
for peer in "$@"; do
peer_key="$(state_key "$peer")"
result="$(resolve_peer_from_status "$peer")"
status="${result%% *}"
ip="${result#* }"
case "$status" in
OK)
if "$TAILSCALE_BIN" ping --timeout=5s --c=1 "$ip" >/dev/null 2>&1; then
last_success="$TMP_STATE_DIR/$peer_key.success"
if [ ! -e "$last_success" ]; then
log_msg daemon.info "peer $peer resolved to $ip and is reachable"
fi
: >"$last_success"
rm -f "$TMP_STATE_DIR/$peer_key.last_error"
else
log_rate_limited_error "$peer_key" "peer $peer resolved to $ip but tailscale ping failed" "$failure_log_interval" "$now"
rm -f "$TMP_STATE_DIR/$peer_key.success"
fi
;;
NO_IP)
log_rate_limited_error "$peer_key" "peer $peer has no Tailscale IP" "$failure_log_interval" "$now"
rm -f "$TMP_STATE_DIR/$peer_key.success"
;;
ERROR)
log_rate_limited_error "$peer_key" "peer $peer resolution failed: $ip" "$failure_log_interval" "$now"
rm -f "$TMP_STATE_DIR/$peer_key.success"
;;
*)
log_rate_limited_error "$peer_key" "peer $peer not found in tailscale status" "$failure_log_interval" "$now"
rm -f "$TMP_STATE_DIR/$peer_key.success"
;;
esac
done
}
run_loop() {
local interval="$1"
local failure_log_interval="$2"
shift 2
[ "$interval" -gt 0 ] 2>/dev/null || interval=20
[ "$failure_log_interval" -gt 0 ] 2>/dev/null || failure_log_interval=300
[ "$#" -gt 0 ] || {
log_msg daemon.err "keepalive enabled but no peers configured"
exit 1
}
TMP_STATE_DIR="/var/run/tailscale_keepalive"
mkdir -p "$TMP_STATE_DIR"
while true; do
run_once "$failure_log_interval" "$@"
sleep "$interval"
done
}
case "$1" in
--resolve-peers)
shift
resolve_peers "$@"
;;
--run)
shift
run_loop "$@"
;;
*)
echo "Usage: $0 --resolve-peers <peer>... | --run <interval> <failure-log-interval> <peer>..." >&2
exit 2
;;
esac
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
# Peer probe for LuCI Tailscale diagnostics.
TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}"
JQ_BIN="${JQ_BIN:-/usr/bin/jq}"
emit_json() {
peer="$1"
ok="$2"
path="$3"
latency_ms="$4"
relay="$5"
summary="$6"
raw="$7"
latency_json="null"
[ -n "$latency_ms" ] && latency_json="$latency_ms"
"$JQ_BIN" -nc \
--arg peer "$peer" \
--arg path "$path" \
--arg relay "$relay" \
--arg summary "$summary" \
--arg raw "$raw" \
--argjson ok "$ok" \
--argjson latency_ms "$latency_json" \
'{peer:$peer,ok:$ok,path:$path,latency_ms:$latency_ms,relay:$relay,summary:$summary,raw:$raw}'
}
usage() {
emit_json "" false failed "" "" "usage: tailscale_peer_probe <peer>" ""
exit 2
}
[ "$#" -eq 1 ] || usage
peer="$1"
case "$peer" in
""|*[!A-Za-z0-9_.:-]*)
emit_json "" false failed "" "" "invalid peer argument" ""
exit 2
;;
esac
peer_len=${#peer}
if [ "$peer_len" -lt 1 ] || [ "$peer_len" -gt 253 ]; then
emit_json "" false failed "" "" "invalid peer argument" ""
exit 2
fi
raw="$("$TAILSCALE_BIN" ping --c=1 --timeout=2s "$peer" 2>&1)"
rc="$?"
latency_ms="$(printf '%s\n' "$raw" | sed -nE 's/.* in ([0-9]+(\.[0-9]+)?)ms.*/\1/p' | head -n 1)"
relay="$(printf '%s\n' "$raw" | sed -nE 's/.*DERP\(([^)]*)\).*/\1/p' | head -n 1)"
direct_note="$(printf '%s\n' "$raw" | sed -n '/direct connection not established/{p;q;}')"
if [ -n "$relay" ]; then
if [ -n "$latency_ms" ]; then
summary="DERP $relay $latency_ms ms"
else
summary="DERP $relay"
fi
[ -n "$direct_note" ] && summary="$summary - direct connection not established"
emit_json "$peer" true derp "$latency_ms" "$relay" "$summary" "$raw"
exit 0
fi
if [ "$rc" -ne 0 ]; then
emit_json "$peer" false failed "" "" "tailscale ping failed" "$raw"
exit 0
fi
case "$raw" in
*pong\ from*)
if [ -n "$latency_ms" ]; then
summary="direct $latency_ms ms"
emit_json "$peer" true direct "$latency_ms" "" "$summary" "$raw"
else
summary="unknown path"
emit_json "$peer" true unknown "" "" "$summary" "$raw"
fi
;;
*)
emit_json "$peer" true unknown "" "" "unknown path" "$raw"
;;
esac
+429
View File
@@ -0,0 +1,429 @@
#!/bin/sh
# Copyright (C) 2026 jearton
# SPDX-License-Identifier: GPL-3.0-only
UCI_BIN="${UCI_BIN:-uci}"
IP_BIN="${IP_BIN:-ip}"
JQ_BIN="${JQ_BIN:-jq}"
LOGGER_CMD="${LOGGER_CMD:-logger}"
FLOCK_BIN="${FLOCK_BIN:-flock}"
MWAN3_INIT="${MWAN3_INIT:-/etc/init.d/mwan3}"
LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale-policy-routing.lock}"
SLEEP_BIN="${SLEEP_BIN:-sleep}"
MONITOR_INTERVAL="${MONITOR_INTERVAL:-15}"
SETTINGS_PACKAGE='tailscale_policy_routing'
SETTINGS_SECTION='settings'
NETWORK_SECTION='ts_mwan3_table52'
PRIORITY='1000'
TABLE='52'
RULE_NAME='luci-app-tailscale: Tailscale table 52 before mwan3'
log_error() {
"$LOGGER_CMD" -p daemon.err -t tailscale_policy_routing "$1"
}
setting_enabled() {
case "$("$UCI_BIN" -q get "$SETTINGS_PACKAGE.$SETTINGS_SECTION.enabled" 2>/dev/null || true)" in
1) return 0 ;;
0|'') return 1 ;;
*) return 2 ;;
esac
}
network_section_exists() {
"$UCI_BIN" -q get "network.$NETWORK_SECTION" >/dev/null 2>&1
}
network_section_owned() {
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION" 2>/dev/null || true)" = 'rule' ] && \
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.name" 2>/dev/null || true)" = "$RULE_NAME" ] && \
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.family" 2>/dev/null || true)" = 'ipv4' ] && \
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.priority" 2>/dev/null || true)" = "$PRIORITY" ] && \
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.lookup" 2>/dev/null || true)" = "$TABLE" ]
}
rule_dump() {
"$IP_BIN" -4 rule show 2>/dev/null
}
route_dump() {
"$IP_BIN" -4 route show table "$TABLE" 2>/dev/null
}
priority_lines() {
printf '%s\n' "$1" | sed -n "/^$PRIORITY:[[:space:]]/p"
}
priority_line_count() {
priority_lines "$1" | sed '/^$/d' | wc -l | tr -d ' '
}
is_exact_rule_line() {
# ip-rule has no comments or other ownership metadata. Only the exact rule we
# create can be treated as app-owned; constrained table-52 rules are foreign.
printf '%s\n' "$1" | grep -Fqx "$PRIORITY: from all lookup $TABLE"
}
runtime_rule_state() {
rules="$1"
lines="$(priority_lines "$rules")"
count="$(priority_line_count "$rules")"
case "$count" in
0) printf '%s\n' 'absent' ;;
1)
if is_exact_rule_line "$lines"; then
printf '%s\n' 'exact'
else
printf '%s\n' 'conflict'
fi
;;
*) printf '%s\n' 'conflict' ;;
esac
}
table_has_default_route() {
route_dump | grep -Eq '^default[[:space:]]'
}
tailscale_rule_priority() {
rule_dump | awk -v preferred="$PRIORITY" -v table="$TABLE" '
$0 ~ ("(lookup|table)[[:space:]]+" table "([[:space:]]|$)") {
priority = $1
sub(/:$/, "", priority)
if (priority != preferred && (best == "" || priority + 0 < best + 0))
best = priority
}
END { print best }
'
}
mwan3_present() {
[ -x "$MWAN3_INIT" ] && \
[ "$("$UCI_BIN" -q get mwan3.globals 2>/dev/null || true)" = 'globals' ]
}
mwan3_mark_mask() {
mask="$("$UCI_BIN" -q get mwan3.globals.mmx_mask 2>/dev/null || true)"
case "$mask" in
0[xX][0-9A-Fa-f]*) printf '%s\n' "$mask" | tr '[:upper:]' '[:lower:]' ;;
*) printf '%s\n' '0x3f00' ;;
esac
}
mwan3_earlier_mark_rule() {
rules="$1"
threshold="$2"
mask="$(mwan3_mark_mask)"
mwan3_present || return 1
printf '%s\n' "$rules" | awk -v threshold="$threshold" -v mask="$mask" '
/fwmark/ && index(tolower($0), "/" mask) && /(lookup|table)/ {
priority = $1
sub(/:$/, "", priority)
if (priority + 0 < threshold + 0) {
found = 1
exit
}
}
END { exit !found }
'
}
write_owned_network_rule() {
"$UCI_BIN" set "network.$NETWORK_SECTION=rule" || return 1
"$UCI_BIN" set "network.$NETWORK_SECTION.name=$RULE_NAME" || return 1
"$UCI_BIN" set "network.$NETWORK_SECTION.family=ipv4" || return 1
"$UCI_BIN" set "network.$NETWORK_SECTION.priority=$PRIORITY" || return 1
"$UCI_BIN" set "network.$NETWORK_SECTION.lookup=$TABLE" || return 1
"$UCI_BIN" commit network
}
delete_owned_network_rule() {
network_section_exists || return 3
network_section_owned || return 2
"$UCI_BIN" delete "network.$NETWORK_SECTION" || return 1
"$UCI_BIN" commit network
}
add_runtime_rule() {
rules="$(rule_dump)" || return 1
case "$(runtime_rule_state "$rules")" in
absent) "$IP_BIN" -4 rule add priority "$PRIORITY" lookup "$TABLE" ;;
exact) return 0 ;;
conflict) return 2 ;;
esac
}
delete_runtime_rule() {
rules="$(rule_dump)" || return 1
case "$(runtime_rule_state "$rules")" in
absent) return 0 ;;
exact) "$IP_BIN" -4 rule del priority "$PRIORITY" lookup "$TABLE" ;;
conflict) return 2 ;;
esac
}
cleanup_owned_state() {
strict_cleanup="${1:-0}"
rules="$(rule_dump)" || return 1
if ! network_section_exists; then
# A normal disable does not adopt or remove manually-created exact rules.
# Package removal is stricter: it must not leave an indistinguishable
# priority-1000 rule behind after the helper is gone.
if [ "$strict_cleanup" = 1 ] && [ "$(runtime_rule_state "$rules")" = exact ]; then
log_error 'refusing package cleanup because an unowned priority 1000 table 52 rule remains'
return 2
fi
return 0
fi
if ! network_section_owned; then
log_error 'refusing to delete a non-owned network rule section'
return 2
fi
delete_runtime_rule
delete_status=$?
case "$delete_status" in
0) ;;
2)
log_error 'refusing to delete conflicting priority 1000 policy rules'
return 2
;;
*) return "$delete_status" ;;
esac
# Keep the ownership declaration until the runtime rule is gone. If deletion
# fails, a later sync can retry safely instead of treating the rule as foreign.
delete_owned_network_rule
}
sync_enabled() {
rules="$(rule_dump)" || return 1
if table_has_default_route; then
cleanup_owned_state
cleanup_status=$?
case "$cleanup_status" in
0) return 0 ;;
2)
log_error 'table 52 has a default route and an unowned rule prevents safe cleanup'
return 1
;;
*) return "$cleanup_status" ;;
esac
fi
if mwan3_earlier_mark_rule "$rules" "$PRIORITY"; then
if network_section_owned; then
delete_runtime_rule
cleanup_status=$?
case "$cleanup_status" in
0) ;;
2)
log_error 'refusing to remove the managed rule because priority 1000 now conflicts'
return 1
;;
*) return "$cleanup_status" ;;
esac
fi
log_error 'mwan3 has a fwmark policy rule before priority 1000; table 52 precedence is blocked'
return 0
fi
if network_section_exists; then
if ! network_section_owned; then
log_error 'network.ts_mwan3_table52 exists but is not owned by luci-app-tailscale'
return 1
fi
else
case "$(runtime_rule_state "$rules")" in
absent) write_owned_network_rule || return 1 ;;
exact)
log_error 'priority 1000 table 52 rule already exists without app ownership'
return 1
;;
conflict)
log_error 'priority 1000 is occupied by another policy rule'
return 1
;;
esac
fi
add_runtime_rule
add_status=$?
case "$add_status" in
0) return 0 ;;
2)
# Keep the declaration so a later sync can safely recover if the foreign
# rule disappears. It also records that the app must not adopt that rule.
log_error 'priority 1000 is occupied by another policy rule'
return 1
;;
*) return "$add_status" ;;
esac
}
sync() {
setting_enabled
enabled_status=$?
case "$enabled_status" in
0) sync_enabled ;;
1) cleanup_owned_state ;;
2)
log_error 'tailscale_policy_routing.settings.enabled is invalid'
return 1
;;
esac
}
ensure() {
setting_enabled
enabled_status=$?
case "$enabled_status" in
0) ;;
1) return 0 ;;
2)
log_error 'tailscale_policy_routing.settings.enabled is invalid'
return 1
;;
esac
rules="$(rule_dump)" || return 1
if table_has_default_route; then
# Keep the persistent declaration for recovery after the exit node is
# removed, but never leave our high-priority runtime rule ahead of a
# default route in table 52.
network_section_owned || return 0
delete_runtime_rule
return $?
fi
if mwan3_earlier_mark_rule "$rules" "$PRIORITY"; then
network_section_owned || return 0
delete_runtime_rule
cleanup_status=$?
case "$cleanup_status" in
0)
log_error 'mwan3 has a fwmark policy rule before priority 1000; table 52 precedence is blocked'
return 0
;;
2)
log_error 'refusing to remove the managed rule because priority 1000 now conflicts'
return 1
;;
*) return "$cleanup_status" ;;
esac
fi
network_section_owned || return 0
add_runtime_rule
}
bool_json() {
if "$1"; then
printf '%s\n' true
else
printf '%s\n' false
fi
}
print_status() {
rules="$(rule_dump)" || exit 1
enabled=false
settings_invalid=false
setting_enabled
enabled_status=$?
case "$enabled_status" in
0) enabled=true ;;
2) settings_invalid=true ;;
esac
runtime_state="$(runtime_rule_state "$rules")"
owned=false
network_section_owned && owned=true
section_exists=false
network_section_exists && section_exists=true
default_route=false
table_has_default_route && default_route=true
tailscale_priority="$(tailscale_rule_priority)"
mwan3=false
mwan3_present && mwan3=true
early_mark=false
if mwan3_earlier_mark_rule "$rules" "$PRIORITY"; then
early_mark=true
fi
if [ "$settings_invalid" = true ]; then
state=error
message='The policy-routing enable setting is invalid.'
elif [ "$enabled" = false ]; then
state=disabled
message='Policy routing precedence is disabled.'
elif [ "$default_route" = true ]; then
state=blocked_default_route
message='Table 52 contains a default route, so precedence is blocked to preserve mwan3 routing.'
elif [ "$early_mark" = true ]; then
state=blocked_mwan3_priority
message='A mwan3 fwmark policy rule precedes priority 1000, so table 52 precedence is blocked.'
elif [ "$section_exists" = true ] && [ "$owned" = false ]; then
state=blocked_ownership
message='The named network rule is not owned by luci-app-tailscale.'
elif [ "$runtime_state" = conflict ]; then
state=blocked_priority_conflict
message='Priority 1000 is occupied by another policy rule.'
elif [ "$runtime_state" = exact ] && [ "$owned" = false ]; then
state=blocked_ownership
message='Priority 1000 lookup 52 exists but is not owned by luci-app-tailscale.'
elif [ "$runtime_state" = exact ] && [ "$owned" = true ]; then
state=active
message='Tailscale table 52 is evaluated before mwan3 policy rules.'
else
state=waiting
message='Waiting to install the managed table 52 policy rule.'
fi
"$JQ_BIN" -nc \
--arg state "$state" \
--arg message "$message" \
--arg tailscale_priority "$tailscale_priority" \
--argjson enabled "$enabled" \
--argjson mwan3_present "$mwan3" \
--argjson mwan3_earlier_mark_rule "$early_mark" \
--argjson managed_network_rule "$owned" \
--argjson runtime_rule_present "$( [ "$runtime_state" = exact ] && printf true || printf false )" \
--argjson default_route "$default_route" \
'{state:$state, message:$message, enabled:$enabled, mwan3_present:$mwan3_present, mwan3_earlier_mark_rule:$mwan3_earlier_mark_rule, managed_network_rule:$managed_network_rule, runtime_rule_present:$runtime_rule_present, default_route:$default_route, tailscale_rule_priority:($tailscale_priority | if . == "" then null else tonumber end)}'
}
monitor() {
while :; do
if setting_enabled; then
# The normal sync and hotplug paths report actionable errors. The monitor
# retries quietly so a persistent external conflict cannot flood syslog.
saved_logger="$LOGGER_CMD"
LOGGER_CMD=:
run_locked ensure || :
LOGGER_CMD="$saved_logger"
fi
"$SLEEP_BIN" "$MONITOR_INTERVAL" || return 0
done
}
run_locked() {
lock_dir="${LOCK_FILE%/*}"
[ "$lock_dir" = "$LOCK_FILE" ] && lock_dir=.
mkdir -p "$lock_dir" || return 1
exec 9>"$LOCK_FILE"
"$FLOCK_BIN" -xn 9 || return 1
"$@"
}
case "${1:-}" in
sync) run_locked sync ;;
ensure) run_locked ensure ;;
cleanup) run_locked cleanup_owned_state 1 ;;
enabled) setting_enabled ;;
monitor) monitor ;;
status) print_status ;;
*)
echo "usage: $0 {sync|ensure|cleanup|enabled|monitor|status}" >&2
exit 2
;;
esac
+555
View File
@@ -0,0 +1,555 @@
#!/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
@@ -0,0 +1,45 @@
{
"admin/vpn/tailscale": {
"title": "Tailscale",
"order": 90,
"action": {
"type": "firstchild"
},
"depends": {
"acl": [ "luci-app-tailscale" ],
"uci": { "tailscale": true }
}
},
"admin/vpn/tailscale/setting": {
"title": "Global Settings",
"order": 10,
"action": {
"type": "view",
"path": "tailscale/setting"
}
},
"admin/vpn/tailscale/interface": {
"title": "Interface Info",
"order": 20,
"action": {
"type": "view",
"path": "tailscale/interface"
}
},
"admin/vpn/tailscale/peers": {
"title": "Peers",
"order": 25,
"action": {
"type": "view",
"path": "tailscale/peers"
}
},
"admin/vpn/tailscale/log": {
"title": "Logs",
"order": 30,
"action": {
"type": "view",
"path": "tailscale/log"
}
}
}
@@ -0,0 +1,26 @@
{
"luci-app-tailscale": {
"description": "Grant access to Tailscale configuration",
"read": {
"file": {
"/sbin/ip -s -j ad": [ "exec" ],
"/sbin/logread -e tailscale": [ "exec" ],
"/usr/sbin/tailscale status --json": [ "exec" ],
"/usr/sbin/tailscale_peer_probe": [ "exec" ],
"/usr/sbin/tailscale login": [ "exec" ],
"/usr/sbin/tailscale logout": [ "exec" ]
},
"ubus": {
"luci.tailscale": [ "secret_status", "openclash_bypass_status", "policy_routing_status" ],
"service": [ "list" ]
},
"uci": [ "tailscale", "tailscale_openclash", "tailscale_policy_routing" ]
},
"write": {
"ubus": {
"luci.tailscale": [ "adguard_preflight", "set_secrets" ]
},
"uci": [ "tailscale", "tailscale_openclash", "tailscale_policy_routing" ]
}
}
}