diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e06625f..242decc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,9 +126,9 @@ Before opening a Pull Request (PR), please consider the following guidelines: And finally when you are satisfied with your changes, open a new PR. -[issues]: https://github.com/AxisCommunications/tailscale-acap/issues -[issues_new]: https://github.com/AxisCommunications/tailscale-acap/issues/new -[issues_bugs]: https://github.com/AxisCommunications/tailscale-acap/issues?q=label%3Abug -[discussions]: https://github.com/AxisCommunications/tailscale-acap/discussions -[discussions_new]: https://github.com/AxisCommunications/tailscale-acap/discussions/new +[issues]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues +[issues_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues/new +[issues_bugs]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues?q=label%3Abug +[discussions]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions +[discussions_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions/new diff --git a/LICENSE b/LICENSE index 9241b06..c392ede 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ BSD 3-Clause License Copyright (c) 2020 Tailscale & AUTHORS. +Copyright (c) 2022 Weston Blieden (ACAP packaging and wrapper code) All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/aarch64/app/Tailscale_VPN_run b/aarch64/app/Tailscale_VPN_run index 5dc76a1..0f1592a 100644 --- a/aarch64/app/Tailscale_VPN_run +++ b/aarch64/app/Tailscale_VPN_run @@ -76,11 +76,66 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true" fi -eval $TAILSCALE_CMD -UP_EXIT=$? +# Run `tailscale up` in the background and act on its outcome. If the node needs +# (re-)authentication, `up` blocks until the user logs in; backgrounding it +# ensures the status publisher below keeps running so the UI can surface the +# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting). +# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit +# code is captured directly. We must NOT background `up` separately and `wait` +# for it from here, because in POSIX sh `wait` only works on children of the +# current shell — a subshell waiting on the parent's child returns 127. +{ + eval "$TAILSCALE_CMD" + up_exit=$? + if [ "$up_exit" -eq 0 ]; then + logger -t "Tailscale_VPN" "Tailscale VPN is running" + # Auth succeeded with a one-time auth key — signal param_bridge to clear it + if [ -n "$AUTH_KEY" ]; then + : > "$STATE_DIR/authkey_clear" + fi + else + logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)" + fi +} & +TAILSCALE_UP_PID=$! -logger -t "Tailscale_VPN" "Tailscale VPN is running" logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP" logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS" +# Publish tailscale's real backend state as JSON for the web UI to consume. +# This is the authoritative connection signal (BackendState / Self.Online / +# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at +# /local/Tailscale_VPN/status.json. +STATUS_FILE="$APP_DIR/html/status.json" + +publish_status() { + if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then + mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null + chmod 644 "$STATUS_FILE" 2>/dev/null + else + rm -f "$STATUS_FILE.tmp" 2>/dev/null + fi +} + +status_loop() { + while true; do + publish_status + sleep 5 + done +} +status_loop & +STATUS_LOOP_PID=$! + +# Clean up the status writer, up watcher, daemon and published status on +# stop/restart so param_bridge (which signals this script) leaves no orphans or +# stale state. +cleanup() { + [ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null + [ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null + [ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null + rm -f "$STATUS_FILE" 2>/dev/null + exit 0 +} +trap cleanup TERM INT + wait $TAILSCALED_PID diff --git a/aarch64/app/html/index.html b/aarch64/app/html/index.html index e6b11e7..419b235 100644 --- a/aarch64/app/html/index.html +++ b/aarch64/app/html/index.html @@ -490,6 +490,7 @@ (function() { var APP = 'Tailscale_VPN'; var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP; + var STATUS_URL = 'status.json'; var logBox = document.getElementById('log-box'); var autoScroll = true; @@ -737,29 +738,82 @@ .catch(function() { return false; }); } + // Ground truth published by the run script from `tailscale status --json`. + function fetchStatus() { + return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' }) + .then(function(r) { return r.ok ? r.json() : null; }) + .catch(function() { return null; }); + } + + // Apply Tailscale's authoritative backend state onto the result object. + function applyStatus(result, st) { + var self = st.Self || {}; + var ips = self.TailscaleIPs || st.TailscaleIPs || []; + var ip4 = null; + for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } } + var bs = st.BackendState; + + if (st.Version) result.version = String(st.Version).split('-')[0]; + + if (bs === 'Running' && self.Online === true) { + // Genuinely connected and reachable on the tailnet + result.state = 'connected'; + result.url = null; + result.ip = ip4 || result.ip; + result.node = self.HostName || result.node; + result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet; + cacheSet('ip', result.ip); cacheSet('node', result.node); + cacheSet('tailnet', result.tailnet); cacheSet('version', result.version); + } else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') { + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Running') { + // Backend running but node not online: either a transient network + // drop (no action needed) or the node was removed/expired and needs + // re-auth. Not connected. Keep any login URL the log parser found + // (status.json's AuthURL lags during the `tailscale up` re-auth + // window) so the login button still appears when re-auth is needed. + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Stopped') { + result.state = 'disconnected'; + result.url = null; + } else { + // NoState / Starting / unknown + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } + } + function refresh() { - fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) - .then(function(r) { return r.text(); }) - .then(function(txt) { - var result = parse(txt); - renderLogs(txt); - // Always verify with the app status API - syslog can have stale entries - checkAppRunning().then(function(running) { - if (!running) { - result.state = 'disconnected'; - } else if (!result.url && result.state !== 'connected') { - result.state = 'connected'; - result.ip = result.ip || cacheGet('ip'); - result.node = result.node || cacheGet('node'); - result.tailnet = result.tailnet || cacheGet('tailnet'); - result.version = result.version || cacheGet('version'); - } - render(result); - }); - }) - .catch(function() { - document.getElementById('status-text').textContent = 'Unable to fetch logs'; + Promise.all([ + fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) + .then(function(r) { return r.text(); }) + .catch(function() { return ''; }), + fetchStatus() + ]).then(function(arr) { + var txt = arr[0]; + var st = arr[1]; + var result = parse(txt || ''); + if (txt) renderLogs(txt); + // Verify the app is actually running - status.json can be stale if stopped + checkAppRunning().then(function(running) { + if (!running) { + result.state = 'disconnected'; + } else if (st && st.BackendState) { + // Authoritative: Tailscale's own backend state + applyStatus(result, st); + } else if (!result.url && result.state !== 'connected') { + // Fallback to log heuristic when status.json is unavailable + result.state = 'connected'; + result.ip = result.ip || cacheGet('ip'); + result.node = result.node || cacheGet('node'); + result.tailnet = result.tailnet || cacheGet('tailnet'); + result.version = result.version || cacheGet('version'); + } + render(result); }); + }); } refresh(); diff --git a/aarch64/app/lib/tailscale b/aarch64/app/lib/tailscale index f16af6f..b8c67b9 100755 Binary files a/aarch64/app/lib/tailscale and b/aarch64/app/lib/tailscale differ diff --git a/aarch64/app/lib/tailscaled b/aarch64/app/lib/tailscaled index ba9c26d..7691b72 100755 Binary files a/aarch64/app/lib/tailscaled and b/aarch64/app/lib/tailscaled differ diff --git a/aarch64/app/param_bridge.c b/aarch64/app/param_bridge.c index b02a187..dbd98aa 100644 --- a/aarch64/app/param_bridge.c +++ b/aarch64/app/param_bridge.c @@ -31,9 +31,10 @@ #include #include -#define APP_NAME "Tailscale_VPN" -#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" -#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" +#define APP_NAME "Tailscale_VPN" +#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" +#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" +#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear" static AXParameter *g_ax_handle = NULL; static pid_t child_pid = -1; @@ -120,6 +121,31 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) { return G_SOURCE_CONTINUE; } +/* ── auth-key sentinel ───────────────────────────────────────────────────── */ + +/* The run script drops SENTINEL_FILE after a successful `tailscale up` that + * used a one-time auth key. Clear the stored AuthKey so it is not reused and + * disappears from the settings UI. This replaces the old exit-code-0 path, + * which never fired because tailscaled keeps the child alive indefinitely. */ +static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) { + if (access(SENTINEL_FILE, F_OK) != 0) + return G_SOURCE_CONTINUE; + + if (g_ax_handle && cfg_auth_key && *cfg_auth_key) { + GError *err = NULL; + if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) { + free(cfg_auth_key); cfg_auth_key = strdup(""); + syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)"); + } else { + syslog(LOG_WARNING, "failed to clear AuthKey: %s", + err ? err->message : "unknown"); + if (err) g_error_free(err); + } + } + unlink(SENTINEL_FILE); + return G_SOURCE_CONTINUE; +} + /* ── config file ─────────────────────────────────────────────────────────── */ static void load_config_cache(AXParameter *handle) { @@ -219,6 +245,10 @@ int main(void) { /* Ensure localdata dir exists */ mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); + /* Drop any stale auth-key sentinel from a previous run so we don't clear a + * freshly configured key before it has been used. */ + unlink(SENTINEL_FILE); + AXParameter *handle = ax_parameter_new(APP_NAME, &error); if (!handle) { syslog(LOG_ERR, "ax_parameter_new: %s", @@ -249,6 +279,7 @@ int main(void) { g_unix_signal_add(SIGTERM, signal_handler, loop); g_unix_signal_add(SIGINT, signal_handler, loop); g_timeout_add_seconds(60, watchdog_cb, NULL); + g_timeout_add_seconds(5, authkey_sentinel_cb, NULL); syslog(LOG_INFO, "running — watching for parameter changes"); g_main_loop_run(loop); diff --git a/aarch64_ROOT/app/Tailscale_VPN_run b/aarch64_ROOT/app/Tailscale_VPN_run index dfdb724..24c14aa 100644 --- a/aarch64_ROOT/app/Tailscale_VPN_run +++ b/aarch64_ROOT/app/Tailscale_VPN_run @@ -50,9 +50,63 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true" fi -eval $TAILSCALE_CMD -UP_EXIT=$? +# Run `tailscale up` in the background and act on its outcome. If the node needs +# (re-)authentication, `up` blocks until the user logs in; backgrounding it +# ensures the status publisher below keeps running so the UI can surface the +# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting). +# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit +# code is captured directly. We must NOT background `up` separately and `wait` +# for it from here, because in POSIX sh `wait` only works on children of the +# current shell — a subshell waiting on the parent's child returns 127. +{ + eval "$TAILSCALE_CMD" + up_exit=$? + if [ "$up_exit" -eq 0 ]; then + logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)" + # Auth succeeded with a one-time auth key — signal param_bridge to clear it + if [ -n "$AUTH_KEY" ]; then + : > "$STATE_DIR/authkey_clear" + fi + else + logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)" + fi +} & +TAILSCALE_UP_PID=$! -logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)" +# Publish tailscale's real backend state as JSON for the web UI to consume. +# This is the authoritative connection signal (BackendState / Self.Online / +# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at +# /local/Tailscale_VPN/status.json. +STATUS_FILE="$APP_DIR/html/status.json" + +publish_status() { + if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then + mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null + chmod 644 "$STATUS_FILE" 2>/dev/null + else + rm -f "$STATUS_FILE.tmp" 2>/dev/null + fi +} + +status_loop() { + while true; do + publish_status + sleep 5 + done +} +status_loop & +STATUS_LOOP_PID=$! + +# Clean up the status writer, up watcher, daemon and published status on +# stop/restart so param_bridge (which signals this script) leaves no orphans or +# stale state. +cleanup() { + [ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null + [ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null + [ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null + rm -f "$STATUS_FILE" 2>/dev/null + exit 0 +} +trap cleanup TERM INT wait $TAILSCALED_PID diff --git a/aarch64_ROOT/app/html/index.html b/aarch64_ROOT/app/html/index.html index e6b11e7..419b235 100644 --- a/aarch64_ROOT/app/html/index.html +++ b/aarch64_ROOT/app/html/index.html @@ -490,6 +490,7 @@ (function() { var APP = 'Tailscale_VPN'; var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP; + var STATUS_URL = 'status.json'; var logBox = document.getElementById('log-box'); var autoScroll = true; @@ -737,29 +738,82 @@ .catch(function() { return false; }); } + // Ground truth published by the run script from `tailscale status --json`. + function fetchStatus() { + return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' }) + .then(function(r) { return r.ok ? r.json() : null; }) + .catch(function() { return null; }); + } + + // Apply Tailscale's authoritative backend state onto the result object. + function applyStatus(result, st) { + var self = st.Self || {}; + var ips = self.TailscaleIPs || st.TailscaleIPs || []; + var ip4 = null; + for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } } + var bs = st.BackendState; + + if (st.Version) result.version = String(st.Version).split('-')[0]; + + if (bs === 'Running' && self.Online === true) { + // Genuinely connected and reachable on the tailnet + result.state = 'connected'; + result.url = null; + result.ip = ip4 || result.ip; + result.node = self.HostName || result.node; + result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet; + cacheSet('ip', result.ip); cacheSet('node', result.node); + cacheSet('tailnet', result.tailnet); cacheSet('version', result.version); + } else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') { + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Running') { + // Backend running but node not online: either a transient network + // drop (no action needed) or the node was removed/expired and needs + // re-auth. Not connected. Keep any login URL the log parser found + // (status.json's AuthURL lags during the `tailscale up` re-auth + // window) so the login button still appears when re-auth is needed. + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Stopped') { + result.state = 'disconnected'; + result.url = null; + } else { + // NoState / Starting / unknown + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } + } + function refresh() { - fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) - .then(function(r) { return r.text(); }) - .then(function(txt) { - var result = parse(txt); - renderLogs(txt); - // Always verify with the app status API - syslog can have stale entries - checkAppRunning().then(function(running) { - if (!running) { - result.state = 'disconnected'; - } else if (!result.url && result.state !== 'connected') { - result.state = 'connected'; - result.ip = result.ip || cacheGet('ip'); - result.node = result.node || cacheGet('node'); - result.tailnet = result.tailnet || cacheGet('tailnet'); - result.version = result.version || cacheGet('version'); - } - render(result); - }); - }) - .catch(function() { - document.getElementById('status-text').textContent = 'Unable to fetch logs'; + Promise.all([ + fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) + .then(function(r) { return r.text(); }) + .catch(function() { return ''; }), + fetchStatus() + ]).then(function(arr) { + var txt = arr[0]; + var st = arr[1]; + var result = parse(txt || ''); + if (txt) renderLogs(txt); + // Verify the app is actually running - status.json can be stale if stopped + checkAppRunning().then(function(running) { + if (!running) { + result.state = 'disconnected'; + } else if (st && st.BackendState) { + // Authoritative: Tailscale's own backend state + applyStatus(result, st); + } else if (!result.url && result.state !== 'connected') { + // Fallback to log heuristic when status.json is unavailable + result.state = 'connected'; + result.ip = result.ip || cacheGet('ip'); + result.node = result.node || cacheGet('node'); + result.tailnet = result.tailnet || cacheGet('tailnet'); + result.version = result.version || cacheGet('version'); + } + render(result); }); + }); } refresh(); diff --git a/aarch64_ROOT/app/lib/tailscale b/aarch64_ROOT/app/lib/tailscale index f16af6f..b8c67b9 100755 Binary files a/aarch64_ROOT/app/lib/tailscale and b/aarch64_ROOT/app/lib/tailscale differ diff --git a/aarch64_ROOT/app/lib/tailscaled b/aarch64_ROOT/app/lib/tailscaled index ba9c26d..7691b72 100755 Binary files a/aarch64_ROOT/app/lib/tailscaled and b/aarch64_ROOT/app/lib/tailscaled differ diff --git a/aarch64_ROOT/app/param_bridge.c b/aarch64_ROOT/app/param_bridge.c index 84a4f26..522b3ac 100644 --- a/aarch64_ROOT/app/param_bridge.c +++ b/aarch64_ROOT/app/param_bridge.c @@ -21,9 +21,10 @@ #include #include -#define APP_NAME "Tailscale_VPN" -#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" -#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" +#define APP_NAME "Tailscale_VPN" +#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" +#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" +#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear" static AXParameter *g_ax_handle = NULL; static pid_t child_pid = -1; @@ -104,6 +105,29 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) { return G_SOURCE_CONTINUE; } +/* The run script drops SENTINEL_FILE after a successful `tailscale up` that + * used a one-time auth key. Clear the stored AuthKey so it is not reused and + * disappears from the settings UI. This replaces the old exit-code-0 path, + * which never fired because tailscaled keeps the child alive indefinitely. */ +static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) { + if (access(SENTINEL_FILE, F_OK) != 0) + return G_SOURCE_CONTINUE; + + if (g_ax_handle && cfg_auth_key && *cfg_auth_key) { + GError *err = NULL; + if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) { + free(cfg_auth_key); cfg_auth_key = strdup(""); + syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)"); + } else { + syslog(LOG_WARNING, "failed to clear AuthKey: %s", + err ? err->message : "unknown"); + if (err) g_error_free(err); + } + } + unlink(SENTINEL_FILE); + return G_SOURCE_CONTINUE; +} + static void load_config_cache(AXParameter *handle) { GError *error = NULL; gchar *val = NULL; @@ -185,6 +209,10 @@ int main(void) { mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); + /* Drop any stale auth-key sentinel from a previous run so we don't clear a + * freshly configured key before it has been used. */ + unlink(SENTINEL_FILE); + AXParameter *handle = ax_parameter_new(APP_NAME, &error); if (!handle) { syslog(LOG_ERR, "ax_parameter_new: %s", @@ -212,6 +240,7 @@ int main(void) { g_unix_signal_add(SIGTERM, signal_handler, loop); g_unix_signal_add(SIGINT, signal_handler, loop); g_timeout_add_seconds(60, watchdog_cb, NULL); + g_timeout_add_seconds(5, authkey_sentinel_cb, NULL); syslog(LOG_INFO, "running — watching for parameter changes"); g_main_loop_run(loop); diff --git a/arm/app/Tailscale_VPN_run b/arm/app/Tailscale_VPN_run index 5dc76a1..0f1592a 100644 --- a/arm/app/Tailscale_VPN_run +++ b/arm/app/Tailscale_VPN_run @@ -76,11 +76,66 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true" fi -eval $TAILSCALE_CMD -UP_EXIT=$? +# Run `tailscale up` in the background and act on its outcome. If the node needs +# (re-)authentication, `up` blocks until the user logs in; backgrounding it +# ensures the status publisher below keeps running so the UI can surface the +# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting). +# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit +# code is captured directly. We must NOT background `up` separately and `wait` +# for it from here, because in POSIX sh `wait` only works on children of the +# current shell — a subshell waiting on the parent's child returns 127. +{ + eval "$TAILSCALE_CMD" + up_exit=$? + if [ "$up_exit" -eq 0 ]; then + logger -t "Tailscale_VPN" "Tailscale VPN is running" + # Auth succeeded with a one-time auth key — signal param_bridge to clear it + if [ -n "$AUTH_KEY" ]; then + : > "$STATE_DIR/authkey_clear" + fi + else + logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)" + fi +} & +TAILSCALE_UP_PID=$! -logger -t "Tailscale_VPN" "Tailscale VPN is running" logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP" logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS" +# Publish tailscale's real backend state as JSON for the web UI to consume. +# This is the authoritative connection signal (BackendState / Self.Online / +# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at +# /local/Tailscale_VPN/status.json. +STATUS_FILE="$APP_DIR/html/status.json" + +publish_status() { + if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then + mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null + chmod 644 "$STATUS_FILE" 2>/dev/null + else + rm -f "$STATUS_FILE.tmp" 2>/dev/null + fi +} + +status_loop() { + while true; do + publish_status + sleep 5 + done +} +status_loop & +STATUS_LOOP_PID=$! + +# Clean up the status writer, up watcher, daemon and published status on +# stop/restart so param_bridge (which signals this script) leaves no orphans or +# stale state. +cleanup() { + [ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null + [ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null + [ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null + rm -f "$STATUS_FILE" 2>/dev/null + exit 0 +} +trap cleanup TERM INT + wait $TAILSCALED_PID diff --git a/arm/app/html/index.html b/arm/app/html/index.html index e6b11e7..419b235 100644 --- a/arm/app/html/index.html +++ b/arm/app/html/index.html @@ -490,6 +490,7 @@ (function() { var APP = 'Tailscale_VPN'; var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP; + var STATUS_URL = 'status.json'; var logBox = document.getElementById('log-box'); var autoScroll = true; @@ -737,29 +738,82 @@ .catch(function() { return false; }); } + // Ground truth published by the run script from `tailscale status --json`. + function fetchStatus() { + return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' }) + .then(function(r) { return r.ok ? r.json() : null; }) + .catch(function() { return null; }); + } + + // Apply Tailscale's authoritative backend state onto the result object. + function applyStatus(result, st) { + var self = st.Self || {}; + var ips = self.TailscaleIPs || st.TailscaleIPs || []; + var ip4 = null; + for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } } + var bs = st.BackendState; + + if (st.Version) result.version = String(st.Version).split('-')[0]; + + if (bs === 'Running' && self.Online === true) { + // Genuinely connected and reachable on the tailnet + result.state = 'connected'; + result.url = null; + result.ip = ip4 || result.ip; + result.node = self.HostName || result.node; + result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet; + cacheSet('ip', result.ip); cacheSet('node', result.node); + cacheSet('tailnet', result.tailnet); cacheSet('version', result.version); + } else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') { + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Running') { + // Backend running but node not online: either a transient network + // drop (no action needed) or the node was removed/expired and needs + // re-auth. Not connected. Keep any login URL the log parser found + // (status.json's AuthURL lags during the `tailscale up` re-auth + // window) so the login button still appears when re-auth is needed. + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Stopped') { + result.state = 'disconnected'; + result.url = null; + } else { + // NoState / Starting / unknown + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } + } + function refresh() { - fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) - .then(function(r) { return r.text(); }) - .then(function(txt) { - var result = parse(txt); - renderLogs(txt); - // Always verify with the app status API - syslog can have stale entries - checkAppRunning().then(function(running) { - if (!running) { - result.state = 'disconnected'; - } else if (!result.url && result.state !== 'connected') { - result.state = 'connected'; - result.ip = result.ip || cacheGet('ip'); - result.node = result.node || cacheGet('node'); - result.tailnet = result.tailnet || cacheGet('tailnet'); - result.version = result.version || cacheGet('version'); - } - render(result); - }); - }) - .catch(function() { - document.getElementById('status-text').textContent = 'Unable to fetch logs'; + Promise.all([ + fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) + .then(function(r) { return r.text(); }) + .catch(function() { return ''; }), + fetchStatus() + ]).then(function(arr) { + var txt = arr[0]; + var st = arr[1]; + var result = parse(txt || ''); + if (txt) renderLogs(txt); + // Verify the app is actually running - status.json can be stale if stopped + checkAppRunning().then(function(running) { + if (!running) { + result.state = 'disconnected'; + } else if (st && st.BackendState) { + // Authoritative: Tailscale's own backend state + applyStatus(result, st); + } else if (!result.url && result.state !== 'connected') { + // Fallback to log heuristic when status.json is unavailable + result.state = 'connected'; + result.ip = result.ip || cacheGet('ip'); + result.node = result.node || cacheGet('node'); + result.tailnet = result.tailnet || cacheGet('tailnet'); + result.version = result.version || cacheGet('version'); + } + render(result); }); + }); } refresh(); diff --git a/arm/app/lib/tailscale b/arm/app/lib/tailscale index bd9b6e4..fb50909 100755 Binary files a/arm/app/lib/tailscale and b/arm/app/lib/tailscale differ diff --git a/arm/app/lib/tailscaled b/arm/app/lib/tailscaled index 2d21522..bc8a273 100755 Binary files a/arm/app/lib/tailscaled and b/arm/app/lib/tailscaled differ diff --git a/arm/app/param_bridge.c b/arm/app/param_bridge.c index 481e5ff..dbd98aa 100644 --- a/arm/app/param_bridge.c +++ b/arm/app/param_bridge.c @@ -31,13 +31,14 @@ #include #include -#define APP_NAME "Tailscale_VPN" -#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" -#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" +#define APP_NAME "Tailscale_VPN" +#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" +#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" +#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear" +static AXParameter *g_ax_handle = NULL; static pid_t child_pid = -1; static guint reload_timer_id = 0; -static AXParameter *g_ax_handle = NULL; static char *cfg_custom_server = NULL; static char *cfg_auth_key = NULL; @@ -120,6 +121,31 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) { return G_SOURCE_CONTINUE; } +/* ── auth-key sentinel ───────────────────────────────────────────────────── */ + +/* The run script drops SENTINEL_FILE after a successful `tailscale up` that + * used a one-time auth key. Clear the stored AuthKey so it is not reused and + * disappears from the settings UI. This replaces the old exit-code-0 path, + * which never fired because tailscaled keeps the child alive indefinitely. */ +static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) { + if (access(SENTINEL_FILE, F_OK) != 0) + return G_SOURCE_CONTINUE; + + if (g_ax_handle && cfg_auth_key && *cfg_auth_key) { + GError *err = NULL; + if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) { + free(cfg_auth_key); cfg_auth_key = strdup(""); + syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)"); + } else { + syslog(LOG_WARNING, "failed to clear AuthKey: %s", + err ? err->message : "unknown"); + if (err) g_error_free(err); + } + } + unlink(SENTINEL_FILE); + return G_SOURCE_CONTINUE; +} + /* ── config file ─────────────────────────────────────────────────────────── */ static void load_config_cache(AXParameter *handle) { @@ -219,6 +245,10 @@ int main(void) { /* Ensure localdata dir exists */ mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); + /* Drop any stale auth-key sentinel from a previous run so we don't clear a + * freshly configured key before it has been used. */ + unlink(SENTINEL_FILE); + AXParameter *handle = ax_parameter_new(APP_NAME, &error); if (!handle) { syslog(LOG_ERR, "ax_parameter_new: %s", @@ -249,6 +279,7 @@ int main(void) { g_unix_signal_add(SIGTERM, signal_handler, loop); g_unix_signal_add(SIGINT, signal_handler, loop); g_timeout_add_seconds(60, watchdog_cb, NULL); + g_timeout_add_seconds(5, authkey_sentinel_cb, NULL); syslog(LOG_INFO, "running — watching for parameter changes"); g_main_loop_run(loop); diff --git a/arm_ROOT/app/Tailscale_VPN_run b/arm_ROOT/app/Tailscale_VPN_run index dfdb724..24c14aa 100644 --- a/arm_ROOT/app/Tailscale_VPN_run +++ b/arm_ROOT/app/Tailscale_VPN_run @@ -50,9 +50,63 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true" fi -eval $TAILSCALE_CMD -UP_EXIT=$? +# Run `tailscale up` in the background and act on its outcome. If the node needs +# (re-)authentication, `up` blocks until the user logs in; backgrounding it +# ensures the status publisher below keeps running so the UI can surface the +# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting). +# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit +# code is captured directly. We must NOT background `up` separately and `wait` +# for it from here, because in POSIX sh `wait` only works on children of the +# current shell — a subshell waiting on the parent's child returns 127. +{ + eval "$TAILSCALE_CMD" + up_exit=$? + if [ "$up_exit" -eq 0 ]; then + logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)" + # Auth succeeded with a one-time auth key — signal param_bridge to clear it + if [ -n "$AUTH_KEY" ]; then + : > "$STATE_DIR/authkey_clear" + fi + else + logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)" + fi +} & +TAILSCALE_UP_PID=$! -logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)" +# Publish tailscale's real backend state as JSON for the web UI to consume. +# This is the authoritative connection signal (BackendState / Self.Online / +# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at +# /local/Tailscale_VPN/status.json. +STATUS_FILE="$APP_DIR/html/status.json" + +publish_status() { + if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then + mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null + chmod 644 "$STATUS_FILE" 2>/dev/null + else + rm -f "$STATUS_FILE.tmp" 2>/dev/null + fi +} + +status_loop() { + while true; do + publish_status + sleep 5 + done +} +status_loop & +STATUS_LOOP_PID=$! + +# Clean up the status writer, up watcher, daemon and published status on +# stop/restart so param_bridge (which signals this script) leaves no orphans or +# stale state. +cleanup() { + [ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null + [ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null + [ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null + rm -f "$STATUS_FILE" 2>/dev/null + exit 0 +} +trap cleanup TERM INT wait $TAILSCALED_PID diff --git a/arm_ROOT/app/html/index.html b/arm_ROOT/app/html/index.html index e6b11e7..419b235 100644 --- a/arm_ROOT/app/html/index.html +++ b/arm_ROOT/app/html/index.html @@ -490,6 +490,7 @@ (function() { var APP = 'Tailscale_VPN'; var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP; + var STATUS_URL = 'status.json'; var logBox = document.getElementById('log-box'); var autoScroll = true; @@ -737,29 +738,82 @@ .catch(function() { return false; }); } + // Ground truth published by the run script from `tailscale status --json`. + function fetchStatus() { + return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' }) + .then(function(r) { return r.ok ? r.json() : null; }) + .catch(function() { return null; }); + } + + // Apply Tailscale's authoritative backend state onto the result object. + function applyStatus(result, st) { + var self = st.Self || {}; + var ips = self.TailscaleIPs || st.TailscaleIPs || []; + var ip4 = null; + for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } } + var bs = st.BackendState; + + if (st.Version) result.version = String(st.Version).split('-')[0]; + + if (bs === 'Running' && self.Online === true) { + // Genuinely connected and reachable on the tailnet + result.state = 'connected'; + result.url = null; + result.ip = ip4 || result.ip; + result.node = self.HostName || result.node; + result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet; + cacheSet('ip', result.ip); cacheSet('node', result.node); + cacheSet('tailnet', result.tailnet); cacheSet('version', result.version); + } else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') { + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Running') { + // Backend running but node not online: either a transient network + // drop (no action needed) or the node was removed/expired and needs + // re-auth. Not connected. Keep any login URL the log parser found + // (status.json's AuthURL lags during the `tailscale up` re-auth + // window) so the login button still appears when re-auth is needed. + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Stopped') { + result.state = 'disconnected'; + result.url = null; + } else { + // NoState / Starting / unknown + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } + } + function refresh() { - fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) - .then(function(r) { return r.text(); }) - .then(function(txt) { - var result = parse(txt); - renderLogs(txt); - // Always verify with the app status API - syslog can have stale entries - checkAppRunning().then(function(running) { - if (!running) { - result.state = 'disconnected'; - } else if (!result.url && result.state !== 'connected') { - result.state = 'connected'; - result.ip = result.ip || cacheGet('ip'); - result.node = result.node || cacheGet('node'); - result.tailnet = result.tailnet || cacheGet('tailnet'); - result.version = result.version || cacheGet('version'); - } - render(result); - }); - }) - .catch(function() { - document.getElementById('status-text').textContent = 'Unable to fetch logs'; + Promise.all([ + fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) + .then(function(r) { return r.text(); }) + .catch(function() { return ''; }), + fetchStatus() + ]).then(function(arr) { + var txt = arr[0]; + var st = arr[1]; + var result = parse(txt || ''); + if (txt) renderLogs(txt); + // Verify the app is actually running - status.json can be stale if stopped + checkAppRunning().then(function(running) { + if (!running) { + result.state = 'disconnected'; + } else if (st && st.BackendState) { + // Authoritative: Tailscale's own backend state + applyStatus(result, st); + } else if (!result.url && result.state !== 'connected') { + // Fallback to log heuristic when status.json is unavailable + result.state = 'connected'; + result.ip = result.ip || cacheGet('ip'); + result.node = result.node || cacheGet('node'); + result.tailnet = result.tailnet || cacheGet('tailnet'); + result.version = result.version || cacheGet('version'); + } + render(result); }); + }); } refresh(); diff --git a/arm_ROOT/app/lib/tailscale b/arm_ROOT/app/lib/tailscale index bd9b6e4..fb50909 100755 Binary files a/arm_ROOT/app/lib/tailscale and b/arm_ROOT/app/lib/tailscale differ diff --git a/arm_ROOT/app/lib/tailscaled b/arm_ROOT/app/lib/tailscaled index 2d21522..bc8a273 100755 Binary files a/arm_ROOT/app/lib/tailscaled and b/arm_ROOT/app/lib/tailscaled differ diff --git a/arm_ROOT/app/param_bridge.c b/arm_ROOT/app/param_bridge.c index 93b7893..522b3ac 100644 --- a/arm_ROOT/app/param_bridge.c +++ b/arm_ROOT/app/param_bridge.c @@ -21,13 +21,14 @@ #include #include -#define APP_NAME "Tailscale_VPN" -#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" -#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" +#define APP_NAME "Tailscale_VPN" +#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" +#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" +#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear" +static AXParameter *g_ax_handle = NULL; static pid_t child_pid = -1; static guint reload_timer_id = 0; -static AXParameter *g_ax_handle = NULL; static char *cfg_custom_server = NULL; static char *cfg_auth_key = NULL; @@ -104,6 +105,29 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) { return G_SOURCE_CONTINUE; } +/* The run script drops SENTINEL_FILE after a successful `tailscale up` that + * used a one-time auth key. Clear the stored AuthKey so it is not reused and + * disappears from the settings UI. This replaces the old exit-code-0 path, + * which never fired because tailscaled keeps the child alive indefinitely. */ +static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) { + if (access(SENTINEL_FILE, F_OK) != 0) + return G_SOURCE_CONTINUE; + + if (g_ax_handle && cfg_auth_key && *cfg_auth_key) { + GError *err = NULL; + if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) { + free(cfg_auth_key); cfg_auth_key = strdup(""); + syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)"); + } else { + syslog(LOG_WARNING, "failed to clear AuthKey: %s", + err ? err->message : "unknown"); + if (err) g_error_free(err); + } + } + unlink(SENTINEL_FILE); + return G_SOURCE_CONTINUE; +} + static void load_config_cache(AXParameter *handle) { GError *error = NULL; gchar *val = NULL; @@ -185,6 +209,10 @@ int main(void) { mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); + /* Drop any stale auth-key sentinel from a previous run so we don't clear a + * freshly configured key before it has been used. */ + unlink(SENTINEL_FILE); + AXParameter *handle = ax_parameter_new(APP_NAME, &error); if (!handle) { syslog(LOG_ERR, "ax_parameter_new: %s", @@ -212,6 +240,7 @@ int main(void) { g_unix_signal_add(SIGTERM, signal_handler, loop); g_unix_signal_add(SIGINT, signal_handler, loop); g_timeout_add_seconds(60, watchdog_cb, NULL); + g_timeout_add_seconds(5, authkey_sentinel_cb, NULL); syslog(LOG_INFO, "running — watching for parameter changes"); g_main_loop_run(loop); diff --git a/arm_acap3/Dockerfile b/arm_acap3/Dockerfile index bc0b665..7e39b45 100644 --- a/arm_acap3/Dockerfile +++ b/arm_acap3/Dockerfile @@ -30,6 +30,10 @@ RUN cp html/index.html index.html # The log is written at runtime to localdata/ (resolved path at runtime). RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log +# Symlink the runtime status.json (written by start.sh from `tailscale status +# --json`) into html/ so the web UI can read Tailscale's authoritative state. +RUN ln -sf ../localdata/status.json html/status.json + # Build and package RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./ diff --git a/arm_acap3/app/Tailscale_VPN b/arm_acap3/app/Tailscale_VPN index 88cb99d..062391a 100755 --- a/arm_acap3/app/Tailscale_VPN +++ b/arm_acap3/app/Tailscale_VPN @@ -55,11 +55,37 @@ logger -t "Tailscale_VPN" "Tailscale VPN is running" logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080" logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055" -# Monitoring loop: stay alive while tailscaled is running. -# This keeps the parent Tailscale_VPN (C launcher) in the process table -# so pidof finds it and the camera web UI shows "Running" instead of "Stopped". +# Publish tailscale's real backend state as JSON for the web UI to consume. +# This is the authoritative connection signal (BackendState / Self.Online / +# TailscaleIPs / AuthURL) instead of scraping logs, which otherwise reports +# "connected" whenever the launcher keeps the process alive (e.g. no Internet). +# Written to localdata and exposed at html/status.json via a build-time symlink. +STATUS_FILE="$STATE_DIR/status.json" + +publish_status() { + if "$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then + mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null + chmod 644 "$STATUS_FILE" 2>/dev/null + else + rm -f "$STATUS_FILE.tmp" 2>/dev/null + fi +} + +# Remove stale status on stop so the UI does not show a connected node after exit. +cleanup() { + rm -f "$STATUS_FILE" 2>/dev/null + [ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null + exit 0 +} +trap cleanup TERM INT + +# Monitoring loop: stay alive while tailscaled is running and keep the published +# status fresh. This keeps the parent Tailscale_VPN (C launcher) in the process +# table so pidof finds it and the camera web UI shows "Running" instead of "Stopped". while kill -0 "$TAILSCALED_PID" 2>/dev/null; do + publish_status sleep 5 done +rm -f "$STATUS_FILE" 2>/dev/null logger -t "Tailscale_VPN" "tailscaled exited" diff --git a/arm_acap3/app/html/index.html b/arm_acap3/app/html/index.html index a91cde1..05b13ce 100644 --- a/arm_acap3/app/html/index.html +++ b/arm_acap3/app/html/index.html @@ -612,6 +612,53 @@ // ACAP3: also fetch the raw tailscaled.log (symlinked into html/) so the parser // can find IP, version, tailnet and Running state from tailscaled's own output. var DAEMON_LOG_URL = 'tailscaled.log'; + // Authoritative backend state published by start.sh (symlinked into html/). + var STATUS_URL = 'status.json'; + + // Ground truth published by start.sh from `tailscale status --json`. + function fetchStatus() { + return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' }) + .then(function(r) { return r.ok ? r.json() : null; }) + .catch(function() { return null; }); + } + + // Apply Tailscale's authoritative backend state onto the result object. + function applyStatus(result, st) { + var self = st.Self || {}; + var ips = self.TailscaleIPs || st.TailscaleIPs || []; + var ip4 = null; + for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } } + var bs = st.BackendState; + + if (st.Version) result.version = String(st.Version).split('-')[0]; + + if (bs === 'Running' && self.Online === true) { + // Genuinely connected and reachable on the tailnet + result.state = 'connected'; + result.url = null; + result.ip = ip4 || result.ip; + result.node = self.HostName || result.node; + result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet; + cacheSet('ip', result.ip); cacheSet('node', result.node); + cacheSet('tailnet', result.tailnet); cacheSet('version', result.version); + } else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') { + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Running') { + // Backend running but node not online: transient network drop or the + // node was removed/expired and needs re-auth. Not connected. Keep any + // login URL the log parser found so the login button still appears. + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } else if (bs === 'Stopped') { + result.state = 'disconnected'; + result.url = null; + } else { + // NoState / Starting / unknown + result.state = 'connecting'; + result.url = st.AuthURL || result.url; + } + } function refresh() { var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) @@ -619,17 +666,22 @@ var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) .then(function(r) { return r.text(); }).catch(function() { return ''; }); - Promise.all([syslogFetch, daemonFetch]).then(function(res) { + Promise.all([syslogFetch, daemonFetch, fetchStatus()]).then(function(res) { // Syslog provides Axis timestamp headers (node name) and start/stop events. // tailscaled.log provides IP, version, tailnet, and -> Running state. var txt = res[0] + '\n' + res[1]; + var st = res[2]; var result = parse(txt); renderLogs(res[0]); // show syslog in log panel; daemon log is too verbose - // Always verify with the app status API - syslog can have stale entries + // Always verify with the app status API - logs can have stale entries checkAppRunning().then(function(running) { if (!running) { result.state = 'disconnected'; + } else if (st && st.BackendState) { + // Authoritative: Tailscale's own backend state + applyStatus(result, st); } else if (!result.url && result.state !== 'connected') { + // Fallback to log heuristic when status.json is unavailable result.state = 'connected'; result.ip = result.ip || cacheGet('ip'); result.node = result.node || cacheGet('node');