#!/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 }