mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 23:45:13 +08:00
🗽 Sync 2026-09-29 04:49:38
This commit is contained in:
1 parent
1ba92ebff1
commit
cec9f6bff2
87 files changed
+25281
No files matched your search
@@ -0,0 +1,68 @@
|
||||
config tailscale 'settings'
|
||||
# Set whether Tailcale is enabled or not
|
||||
option enabled '0'
|
||||
# Set the port to listen on for incoming Tailscale packets (default 41641)
|
||||
option port '41641'
|
||||
# Allow WAN inbound UDP traffic to the local Tailscale listen port for first-packet direct connections
|
||||
#option allow_wan_direct '0'
|
||||
# Firewall source zones used when WAN direct is enabled
|
||||
list wan_direct_zones 'wan'
|
||||
# Path to config file
|
||||
option config_path '/etc/tailscale'
|
||||
# Default to using nftables - change below to 'iptables' if still using iptables
|
||||
option fw_mode 'nftables'
|
||||
# Enable stdout log
|
||||
option log_stdout '1'
|
||||
# Enable stderr log
|
||||
option log_stderr '1'
|
||||
# Accept subnet routes that other nodes advertise
|
||||
#option accept_routes '1'
|
||||
# Provide a hostname to use for the device instead of the one provided by the OS
|
||||
#option hostname ''
|
||||
# Accept DNS configuration from the admin console. (default accept)
|
||||
option accept_dns '1'
|
||||
# Offer to be an exit node for outbound internet traffic from the Tailscale network
|
||||
#option advertise_exit_node '1'
|
||||
# Provide a Tailscale IP or machine name to use as an exit node
|
||||
#option exit_node ''
|
||||
# Expose physical subnet routes to your entire Tailscale network (e.g. 10.0.0.0/24)
|
||||
#list advertise_routes ''
|
||||
# Set to '1' to disable subnet route masquerading
|
||||
#option disable_snat_subnet_routes '1'
|
||||
# Set firewall zone (ts_ac_lan: Tailscale access LAN, ts_ac_wan: Tailscale access WAN:, lan_ac_ts: LAN access Tailscale, wan_ac_ts: WAN access Tailscale)
|
||||
# Documentation https://openwrt.org/docs/guide-user/services/vpn/tailscale/start
|
||||
#list access 'ts_ac_lan'
|
||||
#list access 'ts_ac_wan'
|
||||
#list access 'lan_ac_ts'
|
||||
#list access 'wan_ac_ts'
|
||||
# Enable periodic Tailscale pings to keep selected peer paths warm
|
||||
#option keepalive_enabled '0'
|
||||
# Select peer hostnames from tailscale status --json
|
||||
#list keepalive_peers ''
|
||||
# Seconds between keepalive probes
|
||||
option keepalive_interval '20'
|
||||
# Seconds between repeated failure log messages for the same peer
|
||||
option keepalive_failure_log_interval '300'
|
||||
# Enable automatic AdGuard Home upstream switching based on tailnet DNS health
|
||||
#option adguard_dns_switch_enabled '0'
|
||||
# Configure at least one default upstream in LuCI before enabling the switch
|
||||
#list adguard_default_upstreams ''
|
||||
# Configure at least one tailnet upstream in LuCI before enabling the switch
|
||||
#list adguard_tailnet_upstreams ''
|
||||
# Domain and expected IPs for tailnet DNS health checks through 100.100.100.100; fill both before enabling
|
||||
#option adguard_health_domain ''
|
||||
# Add at least one expected IP before enabling the AdGuard DNS switch
|
||||
#list adguard_health_expected_ips ''
|
||||
# Seconds between AdGuard DNS health checks
|
||||
option adguard_check_interval '10'
|
||||
# Consecutive health check results required before switching profiles
|
||||
option adguard_success_threshold '2'
|
||||
option adguard_failure_threshold '2'
|
||||
# AdGuard Home API endpoint and optional credentials
|
||||
option adguard_api_url 'http://127.0.0.1:3000'
|
||||
#option adguard_username ''
|
||||
# Set extra flags for enabling settings upon the initiation of Tailscale
|
||||
# Documentation https://tailscale.com/kb/1241/tailscale-up
|
||||
#list flags ''
|
||||
# Provide the base URL of a custom server instead of https://controlplane.tailscale.com
|
||||
#option login_server ''
|
||||
@@ -0,0 +1,2 @@
|
||||
config openclash 'settings'
|
||||
option enabled '1'
|
||||
@@ -0,0 +1,3 @@
|
||||
config settings 'settings'
|
||||
# Prefer Tailscale table 52 before mwan3 fwmark policy rules.
|
||||
option enabled '0'
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
|
||||
POLICY_HELPER="${POLICY_HELPER:-/usr/sbin/tailscale_policy_routing}"
|
||||
LOGGER_CMD="${LOGGER_CMD:-logger}"
|
||||
|
||||
case "${ACTION:-}" in
|
||||
ifup|ifupdate) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
[ -x "$POLICY_HELPER" ] || exit 0
|
||||
|
||||
"$POLICY_HELPER" ensure >/dev/null 2>&1 || \
|
||||
"$LOGGER_CMD" -p daemon.err -t tailscale_policy_routing 'failed to restore Tailscale table 52 policy priority'
|
||||
Executable
+313
@@ -0,0 +1,313 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
START=90
|
||||
|
||||
USE_PROCD=1
|
||||
|
||||
PROG=/usr/sbin/tailscale_helper
|
||||
PROGK=/usr/sbin/tailscale_keepalive
|
||||
PROGA=/usr/sbin/tailscale_adguard_dns_switch
|
||||
PROGS=/usr/sbin/tailscale_secrets
|
||||
PROGD=/usr/sbin/tailscaled
|
||||
CONFIG_PATH=/var/lib/tailscale
|
||||
ADGUARD_DNS_STATE_DIR="${ADGUARD_DNS_STATE_DIR:-/var/run/tailscale_adguard_dns_switch}"
|
||||
FIREWALL_PENDING_STATE_FILE="${FIREWALL_PENDING_STATE_FILE:-/var/run/tailscale-firewall-pending}"
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "tailscale"
|
||||
}
|
||||
|
||||
reload_in_progress() {
|
||||
[ "${TAILSCALE_INTERNAL_RELOAD:-0}" = "1" ]
|
||||
}
|
||||
|
||||
section_enabled() {
|
||||
config_get_bool enabled "$1" 'enabled' 0
|
||||
[ $enabled -gt 0 ]
|
||||
}
|
||||
|
||||
append_wan_direct_zone() {
|
||||
wan_direct_zones="${wan_direct_zones:+$wan_direct_zones }$1"
|
||||
}
|
||||
|
||||
normalize_tailscale_port() {
|
||||
case "$1" in
|
||||
''|*[!0-9]*) printf '%s\n' 41641 ;;
|
||||
*)
|
||||
if [ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null; then
|
||||
printf '%s\n' "$1"
|
||||
else
|
||||
printf '%s\n' 41641
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
tailscale_helper() {
|
||||
local cfg="$1"
|
||||
local state_was_present="${2:-0}"
|
||||
local accept_routes hostname accept_dns advertise_exit_node exit_node advertise_routes disable_snat_subnet_routes allow_wan_direct wan_direct_zones access flags login_server std_out std_err port
|
||||
|
||||
config_get port $cfg 'port'
|
||||
port="$(normalize_tailscale_port "$port")"
|
||||
config_get_bool accept_routes $cfg 'accept_routes' 0
|
||||
config_get hostname $cfg 'hostname'
|
||||
config_get_bool accept_dns $cfg 'accept_dns' 1
|
||||
config_get_bool advertise_exit_node $cfg 'advertise_exit_node' 0
|
||||
config_get exit_node $cfg 'exit_node'
|
||||
config_get advertise_routes $cfg 'advertise_routes'
|
||||
config_get_bool disable_snat_subnet_routes $cfg 'disable_snat_subnet_routes' 0
|
||||
config_get_bool allow_wan_direct $cfg 'allow_wan_direct' 0
|
||||
wan_direct_zones=""
|
||||
config_list_foreach "$cfg" 'wan_direct_zones' append_wan_direct_zone
|
||||
wan_direct_zones="${wan_direct_zones:-wan}"
|
||||
config_get access $cfg 'access'
|
||||
config_get flags $cfg 'flags'
|
||||
config_get login_server $cfg 'login_server'
|
||||
config_get_bool std_out $cfg 'log_stdout'
|
||||
config_get_bool std_err $cfg 'log_stderr'
|
||||
|
||||
procd_open_instance
|
||||
procd_set_param command $PROG --run
|
||||
|
||||
[ "$accept_routes" = "1" ] && procd_append_param command --accept-routes=true
|
||||
[ -n "$hostname" ] && procd_append_param command --hostname="$hostname"
|
||||
[ "$accept_dns" = "0" ] && procd_append_param command --accept-dns=false
|
||||
[ "$advertise_exit_node" = "1" ] && procd_append_param command --advertise-exit-node
|
||||
[ -n "$exit_node" ] && {
|
||||
procd_append_param command --exit-node="$exit_node"
|
||||
procd_append_param command --exit-node-allow-lan-access=true
|
||||
}
|
||||
[ -n "$advertise_routes" ] && procd_append_param command --advertise-routes="$(echo $advertise_routes | tr ' ' ',')"
|
||||
if [ "$disable_snat_subnet_routes" = "1" ]; then
|
||||
procd_append_param command --snat-subnet-routes=false
|
||||
else
|
||||
procd_append_param command --snat-subnet-routes=true
|
||||
fi
|
||||
[ -n "$flags" ] && procd_append_param command $flags
|
||||
[ -n "$login_server" ] && procd_append_param command --login-server="$login_server"
|
||||
|
||||
procd_set_param env \
|
||||
ACCEPT_DNS="$accept_dns" \
|
||||
EXIT_NODE="$exit_node" \
|
||||
ACCESS="$access" \
|
||||
DISABLE_SNAT_SUBNET_ROUTES="$disable_snat_subnet_routes" \
|
||||
ALLOW_WAN_DIRECT="$allow_wan_direct" \
|
||||
WAN_DIRECT_ZONES="$wan_direct_zones" \
|
||||
TAILSCALE_PORT="$port" \
|
||||
TAILSCALE_STATE_WAS_PRESENT="$state_was_present" \
|
||||
TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \
|
||||
FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE"
|
||||
|
||||
procd_set_param stdout "$std_out"
|
||||
procd_set_param stderr "$std_err"
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
tailscale_keepalive() {
|
||||
local cfg="$1"
|
||||
local keepalive_enabled keepalive_peers keepalive_interval keepalive_failure_log_interval std_out std_err
|
||||
|
||||
config_get_bool keepalive_enabled $cfg 'keepalive_enabled' 0
|
||||
[ "$keepalive_enabled" = "1" ] || return 0
|
||||
|
||||
config_get keepalive_peers $cfg 'keepalive_peers'
|
||||
[ -n "$keepalive_peers" ] || {
|
||||
logger -p daemon.err -t tailscale "keepalive enabled but no peers configured"
|
||||
return 0
|
||||
}
|
||||
|
||||
config_get keepalive_interval $cfg 'keepalive_interval' 20
|
||||
config_get keepalive_failure_log_interval $cfg 'keepalive_failure_log_interval' 300
|
||||
config_get_bool std_out $cfg 'log_stdout'
|
||||
config_get_bool std_err $cfg 'log_stderr'
|
||||
|
||||
procd_open_instance keepalive
|
||||
procd_set_param command $PROGK --run "$keepalive_interval" "$keepalive_failure_log_interval"
|
||||
procd_append_param command $keepalive_peers
|
||||
procd_set_param respawn
|
||||
procd_set_param stdout "$std_out"
|
||||
procd_set_param stderr "$std_err"
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
tailscale_adguard_dns() {
|
||||
local cfg="$1"
|
||||
local adguard_dns_switch_enabled std_out std_err
|
||||
local adguard_health_domain adguard_health_expected_ips
|
||||
local adguard_default_upstreams adguard_tailnet_upstreams
|
||||
|
||||
config_get_bool adguard_dns_switch_enabled "$cfg" 'adguard_dns_switch_enabled' 0
|
||||
[ "$adguard_dns_switch_enabled" = "1" ] || return 0
|
||||
|
||||
config_get adguard_health_domain "$cfg" 'adguard_health_domain'
|
||||
config_get adguard_health_expected_ips "$cfg" 'adguard_health_expected_ips'
|
||||
config_get adguard_default_upstreams "$cfg" 'adguard_default_upstreams'
|
||||
config_get adguard_tailnet_upstreams "$cfg" 'adguard_tailnet_upstreams'
|
||||
|
||||
[ -n "$adguard_health_domain" ] || {
|
||||
logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_health_domain is not configured"
|
||||
return 0
|
||||
}
|
||||
[ -n "$adguard_health_expected_ips" ] || {
|
||||
logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_health_expected_ips is not configured"
|
||||
return 0
|
||||
}
|
||||
[ -n "$adguard_default_upstreams" ] || {
|
||||
logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_default_upstreams is not configured"
|
||||
return 0
|
||||
}
|
||||
[ -n "$adguard_tailnet_upstreams" ] || {
|
||||
logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_tailnet_upstreams is not configured"
|
||||
return 0
|
||||
}
|
||||
|
||||
config_get_bool std_out "$cfg" 'log_stdout' 1
|
||||
config_get_bool std_err "$cfg" 'log_stderr' 1
|
||||
|
||||
procd_open_instance adguard_dns
|
||||
procd_set_param command "$PROGA" --run
|
||||
procd_set_param respawn
|
||||
procd_set_param stdout "$std_out"
|
||||
procd_set_param stderr "$std_err"
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
tailscale_adguard_dns_apply_down() {
|
||||
local cfg="$1"
|
||||
local adguard_dns_switch_enabled adguard_current_profile
|
||||
|
||||
config_get_bool adguard_dns_switch_enabled "$cfg" 'adguard_dns_switch_enabled' 0
|
||||
adguard_current_profile="$(cat "$ADGUARD_DNS_STATE_DIR/current_profile" 2>/dev/null || true)"
|
||||
[ "$adguard_current_profile" != "down" ] || return 0
|
||||
[ "$adguard_dns_switch_enabled" = "1" ] || [ "$adguard_current_profile" = "up" ] || return 0
|
||||
|
||||
"$PROGA" --apply-profile down >/dev/null 2>&1 || {
|
||||
logger -p daemon.err -t tailscale_adguard_dns "failed to restore AdGuard DNS down profile"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
start_instance() {
|
||||
local cfg="$1"
|
||||
local port config_path fw_mode std_out std_err state_file state_was_present allow_wan_direct secrets_ref
|
||||
|
||||
config_get secrets_ref "$cfg" 'secrets_ref'
|
||||
if [ -n "$secrets_ref" ] && ! "$PROGS" activate "$secrets_ref"; then
|
||||
logger -p daemon.err -t tailscale "failed to activate protected credential version"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! section_enabled "$cfg"; then
|
||||
TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \
|
||||
FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" \
|
||||
"$PROG" --cleanup-managed-firewall || logger -p daemon.err -t tailscale "failed to remove managed firewall state for disabled service"
|
||||
echo "disabled in /etc/config/tailscale"
|
||||
return 1
|
||||
fi
|
||||
|
||||
config_get port $cfg 'port'
|
||||
port="$(normalize_tailscale_port "$port")"
|
||||
config_get config_path $cfg 'config_path'
|
||||
config_get fw_mode $cfg 'fw_mode'
|
||||
config_get_bool std_out $cfg 'log_stdout'
|
||||
config_get_bool std_err $cfg 'log_stderr'
|
||||
config_get_bool allow_wan_direct $cfg 'allow_wan_direct' 0
|
||||
if [ "$allow_wan_direct" != "1" ]; then
|
||||
TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \
|
||||
FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" \
|
||||
"$PROG" --cleanup-wan-direct-firewall || return 1
|
||||
fi
|
||||
|
||||
[ -d $config_path ] || mkdir -p $config_path
|
||||
[ -d $CONFIG_PATH ] || mkdir -p $CONFIG_PATH
|
||||
state_file="$config_path/tailscaled.state"
|
||||
state_was_present=0
|
||||
[ ! -s "$state_file" ] || state_was_present=1
|
||||
|
||||
"$PROGD" --cleanup
|
||||
|
||||
procd_open_instance
|
||||
procd_set_param command $PROGD
|
||||
|
||||
procd_append_param command --port "$port"
|
||||
[ -n "$state_file" ] && procd_append_param command --state "$state_file"
|
||||
|
||||
procd_set_param env TS_DEBUG_FIREWALL_MODE="$fw_mode"
|
||||
|
||||
procd_set_param respawn
|
||||
procd_set_param stdout "$std_out"
|
||||
procd_set_param stderr "$std_err"
|
||||
procd_close_instance
|
||||
|
||||
tailscale_helper "$cfg" "$state_was_present"
|
||||
tailscale_keepalive "$cfg"
|
||||
tailscale_adguard_dns "$cfg"
|
||||
}
|
||||
|
||||
start_service() {
|
||||
"$PROGS" migrate || {
|
||||
logger -p daemon.err -t tailscale "failed to migrate protected credentials"
|
||||
return 1
|
||||
}
|
||||
config_load 'tailscale'
|
||||
config_foreach start_instance 'tailscale'
|
||||
}
|
||||
|
||||
stop_instance() {
|
||||
local cfg="$1"
|
||||
local helper_cleanup_failed=0 cleanup_action
|
||||
|
||||
if reload_in_progress; then
|
||||
cleanup_action=--cleanup-exit-node-firewall
|
||||
else
|
||||
cleanup_action=--cleanup-managed-firewall
|
||||
fi
|
||||
TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \
|
||||
FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" \
|
||||
"$PROG" "$cleanup_action" || helper_cleanup_failed=1
|
||||
|
||||
# Remove dnsmasq settings
|
||||
MagicDNSSuffix=$(tailscale status --json | awk -F'"' '/"MagicDNSSuffix"/ {last=$(NF-1)} END {print last}')
|
||||
if [ -n "$MagicDNSSuffix" ]; then
|
||||
target_address="/$MagicDNSSuffix/100.100.100.100"
|
||||
indexes=$(uci show dhcp | sed -n 's/^dhcp\.@dnsmasq\[\([0-9][0-9]*\)\]=dnsmasq$/\1/p')
|
||||
for index in $indexes; do
|
||||
option="dhcp.@dnsmasq[$index].address"
|
||||
uci -q del_list "$option=$target_address"
|
||||
done
|
||||
fi
|
||||
|
||||
# Tailscale owns tailscale0, policy rules, and table 52 routes. Do not reload
|
||||
# network here; netifd must not manage tailscale0.
|
||||
[ -n "$(uci changes dhcp)" ] && uci commit dhcp && /etc/init.d/dnsmasq reload
|
||||
|
||||
"$PROGD" --cleanup
|
||||
|
||||
# Keep helper recovery state available when firewall cleanup needs a retry.
|
||||
[ "$helper_cleanup_failed" -ne 0 ] || rm -rf "$CONFIG_PATH"
|
||||
|
||||
[ "$helper_cleanup_failed" -eq 0 ]
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
config_load 'tailscale'
|
||||
config_foreach stop_instance 'tailscale'
|
||||
}
|
||||
|
||||
service_stopped() {
|
||||
config_load 'tailscale'
|
||||
config_foreach tailscale_adguard_dns_apply_down 'tailscale'
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
local reload_status=0
|
||||
TAILSCALE_INTERNAL_RELOAD=1
|
||||
stop || reload_status=1
|
||||
TAILSCALE_INTERNAL_RELOAD=0
|
||||
if ! start; then
|
||||
reload_status=1
|
||||
stop || reload_status=1
|
||||
fi
|
||||
return "$reload_status"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
START=99
|
||||
USE_PROCD=1
|
||||
PROG="${PROG:-/usr/sbin/tailscale_openclash_bypass}"
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "tailscale_openclash"
|
||||
}
|
||||
|
||||
sync_instance() {
|
||||
"$PROG" sync
|
||||
}
|
||||
|
||||
start_service() {
|
||||
sync_instance
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
start_service
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
"$PROG" cleanup
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
START=91
|
||||
USE_PROCD=1
|
||||
|
||||
PROG="${PROG:-/usr/sbin/tailscale_policy_routing}"
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "tailscale_policy_routing"
|
||||
}
|
||||
|
||||
start_service() {
|
||||
"$PROG" sync || true
|
||||
"$PROG" enabled || return 0
|
||||
|
||||
procd_open_instance monitor
|
||||
procd_set_param command "$PROG" monitor
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
start_service
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
# Persistent cleanup is reserved for an explicit disable or package removal.
|
||||
# A normal service stop must not erase the enabled UCI declaration.
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/bin/sh
|
||||
|
||||
UPGRADE_STATE_DIR="${UPGRADE_STATE_DIR:-/etc/.luci-app-tailscale-upgrade}"
|
||||
TAILSCALE_SECRETS_BIN="${TAILSCALE_SECRETS_BIN:-/usr/sbin/tailscale_secrets}"
|
||||
UCI_BIN="${UCI_BIN:-uci}"
|
||||
OPENCLASH_BYPASS_INIT="${OPENCLASH_BYPASS_INIT:-/etc/init.d/tailscale-openclash-bypass}"
|
||||
POLICY_ROUTING_INIT="${POLICY_ROUTING_INIT:-/etc/init.d/tailscale-policy-routing}"
|
||||
LEGACY_ADGUARD_DNS_INIT="${LEGACY_ADGUARD_DNS_INIT:-/etc/init.d/tailscale-adguard-dns}"
|
||||
LEGACY_ADGUARD_DNS_RC_DIR="${LEGACY_ADGUARD_DNS_RC_DIR:-/etc/rc.d}"
|
||||
LEGACY_ADGUARD_DNS_BACKUP_DIR="${LEGACY_ADGUARD_DNS_BACKUP_DIR:-/etc/tailscale/luci-app-tailscale-legacy}"
|
||||
LUCI_INDEX_CACHE="${LUCI_INDEX_CACHE:-/tmp/luci-indexcache}"
|
||||
|
||||
cleanup_upgrade_state() {
|
||||
rm -f "$UPGRADE_STATE_DIR/tailscale" "$UPGRADE_STATE_DIR/tailscale_openclash" \
|
||||
"$UPGRADE_STATE_DIR/tailscale_openclash.absent" "$UPGRADE_STATE_DIR/tailscale_policy_routing" \
|
||||
"$UPGRADE_STATE_DIR/.complete"
|
||||
rmdir "$UPGRADE_STATE_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
restore_upgrade_config() {
|
||||
local source_file="$1" target_file="$2" temporary_file
|
||||
|
||||
[ -f "$source_file" ] || return 0
|
||||
temporary_file="$target_file.luci-app-tailscale-upgrade.$$"
|
||||
cp "$source_file" "$temporary_file" || return 1
|
||||
mv "$temporary_file" "$target_file" || {
|
||||
rm -f "$temporary_file"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_legacy_adguard_dns_service() {
|
||||
local backup_dir link
|
||||
|
||||
[ -e "$LEGACY_ADGUARD_DNS_INIT" ] || return 0
|
||||
backup_dir="$LEGACY_ADGUARD_DNS_BACKUP_DIR/tailscale-adguard-dns.$(date +%Y%m%d%H%M%S).$$"
|
||||
mkdir -p "$backup_dir" || return 1
|
||||
|
||||
for link in "$LEGACY_ADGUARD_DNS_RC_DIR"/*tailscale-adguard-dns; do
|
||||
[ -e "$link" ] || [ -L "$link" ] || continue
|
||||
printf '%s\t%s\n' "${link##*/}" "$(readlink "$link" 2>/dev/null || true)" >>"$backup_dir/rc-links"
|
||||
done
|
||||
|
||||
if [ -x "$LEGACY_ADGUARD_DNS_INIT" ]; then
|
||||
"$LEGACY_ADGUARD_DNS_INIT" stop || return 1
|
||||
"$LEGACY_ADGUARD_DNS_INIT" disable || true
|
||||
fi
|
||||
|
||||
cp -p "$LEGACY_ADGUARD_DNS_INIT" "$backup_dir/init" || return 1
|
||||
for link in "$LEGACY_ADGUARD_DNS_RC_DIR"/*tailscale-adguard-dns; do
|
||||
[ -e "$link" ] || [ -L "$link" ] || continue
|
||||
rm -f "$link" || return 1
|
||||
done
|
||||
mv "$LEGACY_ADGUARD_DNS_INIT" "$backup_dir/tailscale-adguard-dns.disabled" || return 1
|
||||
}
|
||||
|
||||
if [ -f "$UPGRADE_STATE_DIR/.complete" ]; then
|
||||
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale" /etc/config/tailscale || exit 1
|
||||
if [ -f "$UPGRADE_STATE_DIR/tailscale_openclash.absent" ]; then
|
||||
rm -f /etc/config/tailscale_openclash
|
||||
else
|
||||
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale_openclash" /etc/config/tailscale_openclash || exit 1
|
||||
fi
|
||||
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale_policy_routing" /etc/config/tailscale_policy_routing || exit 1
|
||||
cleanup_upgrade_state
|
||||
elif [ -d "$UPGRADE_STATE_DIR" ]; then
|
||||
cleanup_upgrade_state
|
||||
fi
|
||||
|
||||
cleanup_legacy_adguard_dns_service || exit 1
|
||||
|
||||
"$TAILSCALE_SECRETS_BIN" migrate || exit 1
|
||||
|
||||
openclash_bypass_enabled="$("$UCI_BIN" -q get tailscale_openclash.settings.enabled 2>/dev/null || true)"
|
||||
if [ "$openclash_bypass_enabled" = '1' ] && [ -x "$OPENCLASH_BYPASS_INIT" ]; then
|
||||
"$OPENCLASH_BYPASS_INIT" enable
|
||||
"$OPENCLASH_BYPASS_INIT" start
|
||||
fi
|
||||
|
||||
if [ -x "$POLICY_ROUTING_INIT" ]; then
|
||||
"$POLICY_ROUTING_INIT" enable
|
||||
"$POLICY_ROUTING_INIT" start
|
||||
fi
|
||||
|
||||
rm -f "$LUCI_INDEX_CACHE"
|
||||
exit 0
|
||||
@@ -0,0 +1 @@
|
||||
/etc/tailscale/luci-secrets.json
|
||||
+230
@@ -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
@@ -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
@@ -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
|
||||
+1077
File diff suppressed because it is too large.
Load diff
+89
@@ -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
@@ -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
@@ -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" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user