Files

471 lines
14 KiB
Bash
Executable File

#!/bin/sh /etc/rc.common
USE_PROCD=1
START=99
STOP=10
NAME=tailscale-settings
# State files live on tmpfs so they are rebuilt after every boot.
STATE_DIR=/var/run/tailscale-settings
DAEMON_SIG_FILE="$STATE_DIR/daemon"
ENABLED_FILE="$STATE_DIR/enabled"
DISABLE_REQUEST_FILE="$STATE_DIR/disable-requested"
DNS_MODE_FILE="$STATE_DIR/dns_mode"
TAILSCALE_INIT=/etc/init.d/tailscale
# Compare version strings: return 0 (true) if $1 >= $2
version_gte() {
awk -v a="${1%%-*}" -v b="${2%%-*}" 'BEGIN {
split(a, av, ".")
split(b, bv, ".")
for (i=1; i<=3; i++) {
ai = int(av[i]+0); bi = int(bv[i]+0)
if (ai > bi) { exit 0 }
if (ai < bi) { exit 1 }
}
exit 0
}'
return $?
}
# Path of the tailscale daemon state file, as configured for the daemon init.
tailscale_state_file() {
local state_file
config_get state_file settings state_file '/etc/tailscale/tailscaled.state'
echo "$state_file"
}
# Fingerprint of the UCI options that are only read by the daemon init script
# (they end up as procd env/command arguments, so changing them needs a restart).
daemon_signature() {
local fw_mode port state_file log_stdout log_stderr
config_get fw_mode settings fw_mode 'nftables'
config_get port settings port '41641'
config_get state_file settings state_file '/etc/tailscale/tailscaled.state'
config_get_bool log_stdout settings log_stdout 1
config_get_bool log_stderr settings log_stderr 1
echo "$fw_mode|$port|$state_file|$log_stdout|$log_stderr"
}
# Returns 0 when the tailscale daemon is managed and currently running.
tailscale_running() {
[ -x "$TAILSCALE_INIT" ] || return 1
"$TAILSCALE_INIT" running >/dev/null 2>&1
}
# Add/remove DNS forward entry in dnsmasq for MagicDNS via OpenWrt
setup_tailscale_dns_forward() {
if [ ! -f /etc/config/dhcp ] || [ ! -x /etc/init.d/dnsmasq ]; then
return 0
fi
if [ ! -x /usr/sbin/tailscale ] && [ ! -x /usr/bin/tailscale ]; then
return 0
fi
local entry="/.ts.net/100.100.100.100"
local s
for s in $(uci -q get dhcp.@dnsmasq[0].server); do
[ "$s" = "$entry" ] && return 0
done
uci add_list dhcp.@dnsmasq[0].server="$entry"
uci commit dhcp
/etc/init.d/dnsmasq reload 2>/dev/null || /etc/init.d/dnsmasq restart 2>/dev/null
logger -t "$NAME" "Added DNS forward: $entry"
}
remove_tailscale_dns_forward() {
if [ ! -f /etc/config/dhcp ] || [ ! -x /etc/init.d/dnsmasq ]; then
return 0
fi
local entry="/.ts.net/100.100.100.100"
local found=0 s
for s in $(uci -q get dhcp.@dnsmasq[0].server); do
[ "$s" = "$entry" ] && found=1
done
if [ "$found" = "1" ]; then
uci -q del_list dhcp.@dnsmasq[0].server="$entry"
uci commit dhcp
/etc/init.d/dnsmasq reload 2>/dev/null || /etc/init.d/dnsmasq restart 2>/dev/null
logger -t "$NAME" "Removed Tailscale DNS forward entry: $entry"
fi
}
# Keep the dnsmasq forward record in sync with dns_mode.
#
# Only magicdns takes resolution away from dnsmasq; both openwrt_forward and
# disabled leave dnsmasq authoritative, so the forward entry stays in place
# unless the user actually switches to magicdns. This mirrors what the old
# code did for openwrt_forward but stops deleting the entry when the mode goes
# back to disabled, which used to silently break *.ts.net resolution.
apply_dns_forward() {
local dns_mode prev=''
config_get dns_mode settings dns_mode 'disabled'
[ -f "$DNS_MODE_FILE" ] && prev=$(cat "$DNS_MODE_FILE")
if [ "$dns_mode" = "$prev" ]; then
return 0
fi
case "$dns_mode" in
magicdns)
remove_tailscale_dns_forward
;;
openwrt_forward)
setup_tailscale_dns_forward
;;
*)
# disabled: leave whatever the previous mode put in place,
# dnsmasq is authoritative either way
;;
esac
echo "$dns_mode" > "$DNS_MODE_FILE"
}
# Run 'tailscale set' and recover from one known failure: a daemon built
# without auto-update support (ts_omit_clientupdate) rejects every edit whose
# result still has AutoUpdate.Apply=true, so while that flag is saved none of
# the settings below can be applied. Reset it once and retry. Any other
# failure, and the result of the retry, is reported unchanged.
run_tailscale_set() {
local ts_bin="$1" err ret
shift
err=$("$ts_bin" "$@" 2>&1)
ret=$?
if [ $ret -ne 0 ]; then
case "$err" in
*'Auto-update support is disabled in this build'*)
logger -t "$NAME" "AutoUpdate.Apply=true is unsupported in this build, resetting it"
if "$ts_bin" set --auto-update=false; then
err=$("$ts_bin" "$@" 2>&1)
ret=$?
else
logger -t "$NAME" "Failed to reset auto-update"
fi
;;
esac
fi
[ -z "$err" ] || printf '%s\n' "$err" >&2
return $ret
}
apply_settings() {
local ts_bin
if [ -x /usr/sbin/tailscale ]; then
ts_bin=/usr/sbin/tailscale
elif [ -x /usr/bin/tailscale ]; then
ts_bin=/usr/bin/tailscale
else
logger -t "$NAME" "tailscale binary not found, skipping settings apply"
return 0
fi
config_load tailscale
if [ -x "$TAILSCALE_INIT" ] && ! tailscale_running; then
logger -t "$NAME" "tailscale is not running, skip applying settings"
return 0
fi
local accept_routes advertise_exit_node exit_node exit_node_allow_lan_access
local ssh shields_up runwebclient nosnat hostname
local enable_relay relay_server_port disable_fw_config
config_get accept_routes settings accept_routes '0'
config_get advertise_exit_node settings advertise_exit_node '0'
config_get exit_node settings exit_node ''
config_get exit_node_allow_lan_access settings exit_node_allow_lan_access '0'
config_get ssh settings ssh '0'
config_get dns_mode settings dns_mode 'disabled'
config_get shields_up settings shields_up '0'
config_get runwebclient settings runwebclient '0'
config_get nosnat settings nosnat '0'
config_get hostname settings hostname ''
config_get enable_relay settings enable_relay '0'
config_get relay_server_port settings relay_server_port '40000'
config_get disable_fw_config settings disable_fw_config '0'
# Collect advertise_routes UCI list into comma-separated string
local routes=""
append_route() {
if [ -z "$routes" ]; then
routes="$1"
else
routes="$routes,$1"
fi
}
config_list_foreach settings advertise_routes append_route
# Build argument list for 'tailscale set'
set -- set
# --accept-routes
[ "$accept_routes" = "1" ] \
&& set -- "$@" --accept-routes=true \
|| set -- "$@" --accept-routes=false
# --advertise-exit-node (only when no exit node is selected)
if [ "$advertise_exit_node" = "1" ] && [ -z "$exit_node" ]; then
set -- "$@" --advertise-exit-node=true
else
set -- "$@" --advertise-exit-node=false
fi
# --exit-node-allow-lan-access (only when no exit node is selected)
if [ -z "$exit_node" ]; then
[ "$exit_node_allow_lan_access" = "1" ] \
&& set -- "$@" --exit-node-allow-lan-access=true \
|| set -- "$@" --exit-node-allow-lan-access=false
fi
# --ssh
[ "$ssh" = "1" ] \
&& set -- "$@" --ssh=true \
|| set -- "$@" --ssh=false
# --accept-dns based on dns_mode
case "$dns_mode" in
magicdns)
set -- "$@" --accept-dns=true
;;
*)
# openwrt_forward and disabled both let dnsmasq own resolution
set -- "$@" --accept-dns=false
;;
esac
# --shields-up
[ "$shields_up" = "1" ] \
&& set -- "$@" --shields-up=true \
|| set -- "$@" --shields-up=false
# --webclient
[ "$runwebclient" = "1" ] \
&& set -- "$@" --webclient=true \
|| set -- "$@" --webclient=false
# Everything below is handed to 'tailscale set' in one call, and 'set'
# aborts on an unknown flag. Gate the newer options on the first release
# that accepts them, so an older CLI still applies the rest.
local ts_ver
ts_ver=$("$ts_bin" version 2>/dev/null | head -n1)
# --snat-subnet-routes (requires tailscale >= 1.66.1)
if [ -n "$ts_ver" ] && version_gte "$ts_ver" "1.66.1"; then
[ "$nosnat" = "1" ] \
&& set -- "$@" --snat-subnet-routes=false \
|| set -- "$@" --snat-subnet-routes=true
fi
# --advertise-routes (comma-separated, empty clears routes)
set -- "$@" "--advertise-routes=$routes"
# --exit-node (empty clears the exit node)
set -- "$@" "--exit-node=$exit_node"
# when an exit node is configured, always allow LAN access
[ -n "$exit_node" ] && set -- "$@" --exit-node-allow-lan-access=true
# --hostname (empty resets it to the OS hostname)
set -- "$@" "--hostname=$hostname"
# --relay-server-port (requires tailscale >= 1.84.0)
if [ -n "$ts_ver" ] && version_gte "$ts_ver" "1.84.0"; then
if [ "$enable_relay" = "1" ]; then
set -- "$@" "--relay-server-port=${relay_server_port:-40000}"
else
set -- "$@" "--relay-server-port="
fi
fi
# --netfilter-mode (off when disable_fw_config is enabled, back to on
# otherwise; requires tailscale >= 1.66.1)
if [ -n "$ts_ver" ] && version_gte "$ts_ver" "1.66.1"; then
if [ "$disable_fw_config" = "1" ]; then
set -- "$@" --netfilter-mode=off
else
set -- "$@" --netfilter-mode=on
fi
elif [ "$disable_fw_config" = "1" ]; then
logger -t "$NAME" "tailscale ${ts_ver:-before 1.66.1} has no --netfilter-mode in 'set', ignoring disable_fw_config"
fi
logger -t "$NAME" "Applying settings"
run_tailscale_set "$ts_bin" "$@"
local ret=$?
[ $ret -eq 0 ] \
&& logger -t "$NAME" "Settings applied successfully" \
|| logger -t "$NAME" "Failed to apply settings (exit code: $ret)"
return $ret
}
# Enable or disable autostart and the running daemon.
#
# The enable direction is applied here because starting a stopped daemon is
# harmless. The disable direction is only *armed* from the RPC (before the
# commit), because stopping is not reversible and the UCI rollback timer can
# still fire afterwards: rpcd commits first and arms the timer last, and a
# rollback only restores files. Keeping the actual stop in the reload handler
# means a rolled back config lands on service_enabled=1 while the daemon is
# still up, and the next apply simply drops the request.
sync_service_enabled() {
local service_enabled=1 prev=''
local requested=0
config_get service_enabled settings service_enabled '1'
prev=$(cat "$ENABLED_FILE" 2>/dev/null)
case "$prev" in
0|1) ;;
*)
# No usable record of the last applied value (first apply after
# an upgrade, or a truncated file). Adopt the configuration as
# the baseline instead of treating it as a transition: starting
# a daemon here would run start_service, which arms another UCI
# rollback window right after the one this apply is inside.
#
# An armed disable request is the exception: the user just asked
# for the stop, so the baseline has to be "was running", not "was
# stopped", or the request would be dropped without stopping.
if [ -f "$DISABLE_REQUEST_FILE" ]; then
prev=1
else
echo "$service_enabled" > "$ENABLED_FILE"
prev="$service_enabled"
fi
;;
esac
if [ -f "$DISABLE_REQUEST_FILE" ]; then
requested=1
fi
if [ "$service_enabled" = "0" ]; then
if [ "$prev" = "0" ]; then
return 0
fi
# Only stop when the UI asked for it, a hand edited config is
# respected for autostart but never kills a running tunnel.
if [ "$requested" -eq 0 ]; then
logger -t "$NAME" "service_enabled=0 for the first time, disabling autostart only"
"$TAILSCALE_INIT" disable 2>/dev/null
echo "0" > "$ENABLED_FILE"
return 0
fi
logger -t "$NAME" "Service disabled, stopping tailscale"
"$TAILSCALE_INIT" stop 2>/dev/null
"$TAILSCALE_INIT" disable 2>/dev/null
rm -f "$DISABLE_REQUEST_FILE"
echo "0" > "$ENABLED_FILE"
return 0
fi
rm -f "$DISABLE_REQUEST_FILE"
# The service should run. A daemon that is already up must not be touched:
# that is the whole point of this handler. A daemon that is down is only
# brought back when the user explicitly turned the service on in this
# apply, not because some unrelated option happened to be saved, and not
# because it crashed or was stopped by hand.
if tailscale_running; then
[ "$prev" = "1" ] || echo "1" > "$ENABLED_FILE"
return 0
fi
if [ "$prev" = "1" ]; then
return 0
fi
logger -t "$NAME" "Service enabled, starting tailscale"
"$TAILSCALE_INIT" enable 2>/dev/null
tailscale_running || "$TAILSCALE_INIT" start 2>/dev/null
echo "1" > "$ENABLED_FILE"
}
# Wait briefly for procd to bring the daemon back after a restart. Called from
# reload_service, which rc.common runs outside of any function, so the loop
# counter has to live in a function of its own to stay out of the global scope.
wait_for_daemon() {
local waited=0
while [ "$waited" -lt 2 ]; do
tailscale_running && return 0
sleep 1
waited=$((waited + 1))
done
return 1
}
handle_service_state() {
if [ ! -x "$TAILSCALE_INIT" ]; then
logger -t "$NAME" "tailscale init script not found, keeping settings only"
apply_settings
apply_dns_forward
return 0
fi
sync_service_enabled
# Nothing may touch a running daemon here. The upstream init runs
# 'tailscaled --cleanup' before handing the instance to procd, which tears
# down tailscale0 addresses, table 52 routes and the netfilter rules while
# the old process is still alive. Running 'start' on a live service (or
# 'restart' without reason) therefore leaves a node that still answers
# 'tailscale status' and 'tailscale ping' but has no working kernel state.
if ! tailscale_running; then
return 0
fi
local desired
desired=$(daemon_signature)
if [ -f "$DAEMON_SIG_FILE" ] && [ "$(cat "$DAEMON_SIG_FILE")" != "$desired" ]; then
logger -t "$NAME" "Daemon options changed, restarting tailscale"
"$TAILSCALE_INIT" restart 2>/dev/null
wait_for_daemon || logger -t "$NAME" "tailscale did not come back within 2s"
fi
echo "$desired" > "$DAEMON_SIG_FILE"
apply_settings
apply_dns_forward
}
start_service() {
mkdir -p "$STATE_DIR"
config_load tailscale
daemon_signature > "$DAEMON_SIG_FILE"
# Record the desired flag without acting on it: at boot the service state
# is already correct and acting here would start or stop a daemon we do
# not own. Only seed the file when there is nothing to inherit, so a
# manual `disable` in the shell survives until the reload handler sees a
# real transition.
if [ ! -f "$ENABLED_FILE" ]; then
local service_enabled=1
config_get service_enabled settings service_enabled '1'
echo "$service_enabled" > "$ENABLED_FILE"
fi
apply_settings
apply_dns_forward
}
service_triggers() {
procd_add_reload_trigger "tailscale"
}
reload_service() {
mkdir -p "$STATE_DIR"
config_load tailscale
handle_service_state
}