Compare commits

..
6 Commits
Author SHA1 Message Date
github-actions[bot] c0ef4852ae Update Tailscale to v1.98.8 2026-06-30 04:23:01 +00:00
Weston Blieden 741062bcef fix: status.json connection detection, auth-key auto-clear, IP display
Replace process-liveness/log-scraping heuristics with Tailscale's
authoritative backend state published as status.json.

- UI now uses BackendState + Self.Online so "no Internet" no longer
  shows Connected; surfaces the real Tailscale IP, node and tailnet.
- Auth key is auto-cleared from the UI after a successful keyed login
  via a sentinel file picked up by param_bridge (non-acap3 variants).
- Run scripts background `tailscale up` and publish status every 5s so
  re-auth (NeedsLogin + AuthURL) surfaces without starving the loop.
- Ported across aarch64, aarch64_ROOT, arm, arm_ROOT, and arm_acap3
  (acap3 uses the status.json detection; it has no auth-key param).
- Bump bundled Tailscale binaries to 1.98.4 for all variants.
- Fix CONTRIBUTING.md issue/discussion links to this repo.
- Add packaging/wrapper copyright to LICENSE.
2026-06-16 08:59:00 +02:00
Weston BliedenandGitHub c4ac8ab601 Clarify reverse-SSH tunnel requirements in README
Updated the section on reverse-SSH tunnel to clarify root requirements and added details about using a non-root SSH user.
2026-06-10 13:12:21 +02:00
Weston Blieden fd5cec50bb feat: add Accept DNS and Accept Routes toggles to all ACAP 4 variants
Restores the toggle implementation that shipped in the v1.96.4-dns-routes
release but was never committed to main, so weekly auto-builds (v1.98.x)
regressed and dropped the feature.

- manifest.json: register AcceptDNS / AcceptRoutes parameters
- param_bridge.c: cache, load, persist and live-reload the new params
- Tailscale_VPN_run: append --accept-dns / --accept-routes when enabled; add --reset
- html/index.html: add the Settings toggles
2026-06-10 12:24:09 +02:00
Weston Blieden 9a35f4e584 docs: add section on accessing tailnet services from the camera 2026-06-10 12:23:17 +02:00
github-actions[bot] 896fe380ff Update Tailscale to v1.98.4 2026-06-02 05:00:37 +00:00
31 changed files with 1098 additions and 137 deletions
+5 -5
View File
@@ -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. And finally when you are satisfied with your changes, open a new PR.
<!-- markdownlint-disable MD034 --> <!-- markdownlint-disable MD034 -->
[issues]: https://github.com/AxisCommunications/tailscale-acap/issues [issues]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues
[issues_new]: https://github.com/AxisCommunications/tailscale-acap/issues/new [issues_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues/new
[issues_bugs]: https://github.com/AxisCommunications/tailscale-acap/issues?q=label%3Abug [issues_bugs]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues?q=label%3Abug
[discussions]: https://github.com/AxisCommunications/tailscale-acap/discussions [discussions]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions
[discussions_new]: https://github.com/AxisCommunications/tailscale-acap/discussions/new [discussions_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions/new
<!-- markdownlint-enable MD034 --> <!-- markdownlint-enable MD034 -->
+1
View File
@@ -1,6 +1,7 @@
BSD 3-Clause License BSD 3-Clause License
Copyright (c) 2020 Tailscale & AUTHORS. Copyright (c) 2020 Tailscale & AUTHORS.
Copyright (c) 2022 Weston Blieden (ACAP packaging and wrapper code)
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without
+30
View File
@@ -27,6 +27,7 @@ This repository provides an **ACAP package** that installs the [Tailscale VPN cl
- [Usage](#usage) - [Usage](#usage)
- [Settings](#settings) - [Settings](#settings)
- [Proxy Support](#proxy-support) - [Proxy Support](#proxy-support)
- [Accessing Tailnet Services from the Camera](#accessing-tailnet-services-from-the-camera)
- [Updating Tailscale](#updating-tailscale) - [Updating Tailscale](#updating-tailscale)
- [Purpose](#purpose) - [Purpose](#purpose)
- [Useful Links](#useful-links) - [Useful Links](#useful-links)
@@ -103,6 +104,35 @@ For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:<po
--- ---
## Accessing Tailnet Services from the Camera
There is an important asymmetry to understand. Making the camera **reachable from** the tailnet (browsing to it, VAPIX, SSH from another tailnet node) works on every build. The harder direction is the camera **reaching out to** a tailnet peer, for example mounting an SMB/CIFS network share hosted on another node. How well this works depends on which build you use.
### Why the build matters
| Build | Networking mode | Camera-initiated access to tailnet peers |
|---|---|---|
| Non-root (`aarch64`, `armv7hf`) and `armv7hf_acap3` | `--tun=userspace-networking` (no kernel `tailscale0` interface) | Only through the local **SOCKS5 / HTTP proxies**, and only for **proxy-aware** apps. Firmware system services (the SMB share client, NTP, etc.) are proxy-unaware, so they **cannot** reach a peer's `100.x` Tailscale IP directly. |
| **ROOT** (`aarch64_root`, `armv7hf_root`) | Kernel networking with a real `tailscale0` interface | Peer `100.x` IPs are routable at the OS level, so firmware services **can** connect directly. Enable **Accept Routes** to also reach subnets behind other nodes. |
In short: on non-root builds the proxies cover apps that know how to use a proxy, but a system feature like "add network share" opens a raw socket that never touches the tunnel. The ROOT build is the clean way to let the camera *consume* tailnet services.
### Plan B: reverse-SSH tunnel
> **Requires root on the camera.** Port 445 is privileged, so binding it needs a root-capable build (e.g. developer certificates installed).
If you cannot use the ROOT build but still need the camera to mount a share on a machine that is on your tailnet, you can make the remote share appear **local** to the camera with a reverse SSH tunnel. Because the destination becomes `127.0.0.1`, the proxy-unaware SMB client never has to route over the tailnet.
From a computer that has both the share and tailnet access to the camera:
```bash
# Forward the camera's local port 445 back to the SMB share on this machine
ssh -R 445:localhost:445 root@<camera-tailscale-ip>
```
Then, in the camera's **System → Storage → Add network share** dialog, use `127.0.0.1` as the share host and connect.
---
## Updating Tailscale ## Updating Tailscale
- New `.eap` files are auto-built and released **weekly** (if a new Tailscale version is available). - New `.eap` files are auto-built and released **weekly** (if a new Tailscale version is available).
+69 -4
View File
@@ -18,6 +18,8 @@ CUSTOM_SERVER=""
AUTH_KEY="" AUTH_KEY=""
CONF_HTTP="8080" CONF_HTTP="8080"
CONF_SOCKS="1080" CONF_SOCKS="1080"
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
if [ -f "$STATE_DIR/params.conf" ]; then if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf" . "$STATE_DIR/params.conf"
@@ -56,7 +58,7 @@ TAILSCALED_PID=$!
sleep 2 sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)" TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
@@ -66,11 +68,74 @@ if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi fi
eval $TAILSCALE_CMD if [ "$ACCEPT_DNS" = "true" ]; then
UP_EXIT=$? TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
fi
if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
# 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" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS" 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 wait $TAILSCALED_PID
+121 -22
View File
@@ -272,6 +272,23 @@
.save-status.ok { color: var(--green); } .save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); } .save-status.err { color: var(--red); }
/* Toggle switch */
.toggle-row { display: flex; align-items: flex-start; gap: 12px; }
.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--border); border-radius: 20px; transition: background 0.2s;
}
.toggle-slider:before {
content: ''; position: absolute;
height: 14px; width: 14px; left: 3px; bottom: 3px;
background: white; border-radius: 50%; transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--green); }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
.toggle-info { flex: 1; }
/* Refresh indicator */ /* Refresh indicator */
.refresh-bar { .refresh-bar {
display: flex; display: flex;
@@ -425,6 +442,26 @@
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080"> <input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span> <span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div> </div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-dns">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept DNS</div>
<span class="settings-hint">Pass <code>--accept-dns=true</code> to tailscale up. Allows the tailnet to push DNS settings to this device. Off by default to avoid overriding the camera&apos;s DNS configuration.</span>
</div>
</div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-routes">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept Routes</div>
<span class="settings-hint">Pass <code>--accept-routes=true</code> to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.</span>
</div>
</div>
<div class="settings-actions"> <div class="settings-actions">
<span class="save-status" id="save-status"></span> <span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button> <button class="save-btn" id="save-btn">Save &amp; Restart</button>
@@ -453,6 +490,7 @@
(function() { (function() {
var APP = 'Tailscale_VPN'; var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP; var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var STATUS_URL = 'status.json';
var logBox = document.getElementById('log-box'); var logBox = document.getElementById('log-box');
var autoScroll = true; var autoScroll = true;
@@ -700,29 +738,82 @@
.catch(function() { return false; }); .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() { function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) Promise.all([
.then(function(r) { return r.text(); }) fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(txt) { .then(function(r) { return r.text(); })
var result = parse(txt); .catch(function() { return ''; }),
renderLogs(txt); fetchStatus()
// Always verify with the app status API - syslog can have stale entries ]).then(function(arr) {
checkAppRunning().then(function(running) { var txt = arr[0];
if (!running) { var st = arr[1];
result.state = 'disconnected'; var result = parse(txt || '');
} else if (!result.url && result.state !== 'connected') { if (txt) renderLogs(txt);
result.state = 'connected'; // Verify the app is actually running - status.json can be stale if stopped
result.ip = result.ip || cacheGet('ip'); checkAppRunning().then(function(running) {
result.node = result.node || cacheGet('node'); if (!running) {
result.tailnet = result.tailnet || cacheGet('tailnet'); result.state = 'disconnected';
result.version = result.version || cacheGet('version'); } else if (st && st.BackendState) {
} // Authoritative: Tailscale's own backend state
render(result); applyStatus(result, st);
}); } else if (!result.url && result.state !== 'connected') {
}) // Fallback to log heuristic when status.json is unavailable
.catch(function() { result.state = 'connected';
document.getElementById('status-text').textContent = 'Unable to fetch logs'; 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(); refresh();
@@ -779,6 +870,8 @@
var authInput = document.getElementById('input-authkey'); var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port'); var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port'); var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var saveBtn = document.getElementById('save-btn'); var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status'); var saveStatus = document.getElementById('save-status');
@@ -790,10 +883,14 @@
var am = txt.match(/root\.\S+\.AuthKey=(.*)/); var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/); var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/); var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
if (sm) serverInput.value = sm[1].trim(); if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim(); if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim(); if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim(); if (km) socksPortInput.value = km[1].trim();
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
// Update proxy display card with authoritative param values // Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render // and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null; var httpPort = hm ? hm[1].trim() : null;
@@ -819,7 +916,9 @@
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) + '&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) + '&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) + '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort); '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
fetch(PARAM_URL, { fetch(PARAM_URL, {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -8,7 +8,7 @@
"embeddedSdkVersion": "3.0", "embeddedSdkVersion": "3.0",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn", "runMode": "respawn",
"version": "1.98.3", "version": "1.98.8",
"architecture": "aarch64" "architecture": "aarch64"
}, },
"configuration": { "configuration": {
@@ -33,6 +33,16 @@
"name": "Socks5Port", "name": "Socks5Port",
"default": "1080", "default": "1080",
"type": "string" "type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
} }
] ]
} }
+44 -4
View File
@@ -31,9 +31,10 @@
#include <errno.h> #include <errno.h>
#include <signal.h> #include <signal.h>
#define APP_NAME "Tailscale_VPN" #define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" #define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" #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 AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1; static pid_t child_pid = -1;
@@ -43,6 +44,8 @@ static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL; static char *cfg_auth_key = NULL;
static char *cfg_http_proxy_port = NULL; static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL; static char *cfg_socks5_port = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static void cache_set(char **field, const char *value) { static void cache_set(char **field, const char *value) {
if (!value) return; if (!value) return;
@@ -118,6 +121,31 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
return G_SOURCE_CONTINUE; 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 ─────────────────────────────────────────────────────────── */ /* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) { static void load_config_cache(AXParameter *handle) {
@@ -139,6 +167,8 @@ static void load_config_cache(AXParameter *handle) {
LOAD("AuthKey", cfg_auth_key) LOAD("AuthKey", cfg_auth_key)
LOAD("HttpProxyPort", cfg_http_proxy_port) LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port) LOAD("Socks5Port", cfg_socks5_port)
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
#undef LOAD #undef LOAD
} }
@@ -153,6 +183,8 @@ static void write_config_file(void) {
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, "")); fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080")); fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080")); fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
fclose(f); fclose(f);
chmod(CONFIG_FILE, 0600); chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s", syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
@@ -185,6 +217,8 @@ static void parameter_changed(const gchar *name, const gchar *value,
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value); else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value); else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value); else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
if (reload_timer_id) if (reload_timer_id)
g_source_remove(reload_timer_id); g_source_remove(reload_timer_id);
@@ -211,6 +245,10 @@ int main(void) {
/* Ensure localdata dir exists */ /* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); 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); AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) { if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s", syslog(LOG_ERR, "ax_parameter_new: %s",
@@ -225,7 +263,8 @@ int main(void) {
start_child(); start_child();
const char *params[] = { const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port" "CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"AcceptDNS", "AcceptRoutes"
}; };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i], if (!ax_parameter_register_callback(handle, params[i],
@@ -240,6 +279,7 @@ int main(void) {
g_unix_signal_add(SIGTERM, signal_handler, loop); g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop); g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL); 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"); syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop); g_main_loop_run(loop);
+68 -4
View File
@@ -15,6 +15,8 @@ chmod 755 $TAILSCALE_PATH
CUSTOM_SERVER="" CUSTOM_SERVER=""
AUTH_KEY="" AUTH_KEY=""
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
if [ -f "$STATE_DIR/params.conf" ]; then if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf" . "$STATE_DIR/params.conf"
@@ -30,7 +32,7 @@ TAILSCALED_PID=$!
sleep 2 sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)" TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
@@ -40,9 +42,71 @@ if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi fi
eval $TAILSCALE_CMD if [ "$ACCEPT_DNS" = "true" ]; then
UP_EXIT=$? TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
fi
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)" if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
# 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=$!
# 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 wait $TAILSCALED_PID
+121 -22
View File
@@ -272,6 +272,23 @@
.save-status.ok { color: var(--green); } .save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); } .save-status.err { color: var(--red); }
/* Toggle switch */
.toggle-row { display: flex; align-items: flex-start; gap: 12px; }
.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--border); border-radius: 20px; transition: background 0.2s;
}
.toggle-slider:before {
content: ''; position: absolute;
height: 14px; width: 14px; left: 3px; bottom: 3px;
background: white; border-radius: 50%; transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--green); }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
.toggle-info { flex: 1; }
/* Refresh indicator */ /* Refresh indicator */
.refresh-bar { .refresh-bar {
display: flex; display: flex;
@@ -425,6 +442,26 @@
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080"> <input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span> <span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div> </div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-dns">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept DNS</div>
<span class="settings-hint">Pass <code>--accept-dns=true</code> to tailscale up. Allows the tailnet to push DNS settings to this device. Off by default to avoid overriding the camera&apos;s DNS configuration.</span>
</div>
</div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-routes">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept Routes</div>
<span class="settings-hint">Pass <code>--accept-routes=true</code> to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.</span>
</div>
</div>
<div class="settings-actions"> <div class="settings-actions">
<span class="save-status" id="save-status"></span> <span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button> <button class="save-btn" id="save-btn">Save &amp; Restart</button>
@@ -453,6 +490,7 @@
(function() { (function() {
var APP = 'Tailscale_VPN'; var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP; var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var STATUS_URL = 'status.json';
var logBox = document.getElementById('log-box'); var logBox = document.getElementById('log-box');
var autoScroll = true; var autoScroll = true;
@@ -700,29 +738,82 @@
.catch(function() { return false; }); .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() { function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) Promise.all([
.then(function(r) { return r.text(); }) fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(txt) { .then(function(r) { return r.text(); })
var result = parse(txt); .catch(function() { return ''; }),
renderLogs(txt); fetchStatus()
// Always verify with the app status API - syslog can have stale entries ]).then(function(arr) {
checkAppRunning().then(function(running) { var txt = arr[0];
if (!running) { var st = arr[1];
result.state = 'disconnected'; var result = parse(txt || '');
} else if (!result.url && result.state !== 'connected') { if (txt) renderLogs(txt);
result.state = 'connected'; // Verify the app is actually running - status.json can be stale if stopped
result.ip = result.ip || cacheGet('ip'); checkAppRunning().then(function(running) {
result.node = result.node || cacheGet('node'); if (!running) {
result.tailnet = result.tailnet || cacheGet('tailnet'); result.state = 'disconnected';
result.version = result.version || cacheGet('version'); } else if (st && st.BackendState) {
} // Authoritative: Tailscale's own backend state
render(result); applyStatus(result, st);
}); } else if (!result.url && result.state !== 'connected') {
}) // Fallback to log heuristic when status.json is unavailable
.catch(function() { result.state = 'connected';
document.getElementById('status-text').textContent = 'Unable to fetch logs'; 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(); refresh();
@@ -779,6 +870,8 @@
var authInput = document.getElementById('input-authkey'); var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port'); var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port'); var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var saveBtn = document.getElementById('save-btn'); var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status'); var saveStatus = document.getElementById('save-status');
@@ -790,10 +883,14 @@
var am = txt.match(/root\.\S+\.AuthKey=(.*)/); var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/); var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/); var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
if (sm) serverInput.value = sm[1].trim(); if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim(); if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim(); if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim(); if (km) socksPortInput.value = km[1].trim();
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
// Update proxy display card with authoritative param values // Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render // and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null; var httpPort = hm ? hm[1].trim() : null;
@@ -819,7 +916,9 @@
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) + '&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) + '&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) + '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort); '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
fetch(PARAM_URL, { fetch(PARAM_URL, {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -12,7 +12,7 @@
}, },
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn", "runMode": "respawn",
"version": "1.98.3", "version": "1.98.8",
"architecture": "aarch64" "architecture": "aarch64"
}, },
"configuration": { "configuration": {
@@ -27,6 +27,16 @@
"name": "AuthKey", "name": "AuthKey",
"default": "", "default": "",
"type": "string" "type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
} }
] ]
} }
+41 -4
View File
@@ -21,9 +21,10 @@
#include <errno.h> #include <errno.h>
#include <signal.h> #include <signal.h>
#define APP_NAME "Tailscale_VPN" #define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" #define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" #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 AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1; static pid_t child_pid = -1;
@@ -31,6 +32,8 @@ static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL; static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL; static char *cfg_auth_key = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static void cache_set(char **field, const char *value) { static void cache_set(char **field, const char *value) {
if (!value) return; if (!value) return;
@@ -102,6 +105,29 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
return G_SOURCE_CONTINUE; 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) { static void load_config_cache(AXParameter *handle) {
GError *error = NULL; GError *error = NULL;
gchar *val = NULL; gchar *val = NULL;
@@ -119,6 +145,8 @@ static void load_config_cache(AXParameter *handle) {
LOAD("CustomServer", cfg_custom_server) LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key) LOAD("AuthKey", cfg_auth_key)
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
#undef LOAD #undef LOAD
} }
@@ -131,6 +159,8 @@ static void write_config_file(void) {
} }
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, "")); fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, "")); fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
fclose(f); fclose(f);
chmod(CONFIG_FILE, 0600); chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s", syslog(LOG_INFO, "config updated: server=%s",
@@ -156,6 +186,8 @@ static void parameter_changed(const gchar *name, const gchar *value,
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value); if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value); else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
if (reload_timer_id) if (reload_timer_id)
g_source_remove(reload_timer_id); g_source_remove(reload_timer_id);
@@ -177,6 +209,10 @@ int main(void) {
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); 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); AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) { if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s", syslog(LOG_ERR, "ax_parameter_new: %s",
@@ -190,7 +226,7 @@ int main(void) {
write_config_file(); write_config_file();
start_child(); start_child();
const char *params[] = { "CustomServer", "AuthKey" }; const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i], if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) { parameter_changed, handle, &error)) {
@@ -204,6 +240,7 @@ int main(void) {
g_unix_signal_add(SIGTERM, signal_handler, loop); g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop); g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL); 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"); syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop); g_main_loop_run(loop);
+69 -4
View File
@@ -18,6 +18,8 @@ CUSTOM_SERVER=""
AUTH_KEY="" AUTH_KEY=""
CONF_HTTP="8080" CONF_HTTP="8080"
CONF_SOCKS="1080" CONF_SOCKS="1080"
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
if [ -f "$STATE_DIR/params.conf" ]; then if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf" . "$STATE_DIR/params.conf"
@@ -56,7 +58,7 @@ TAILSCALED_PID=$!
sleep 2 sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)" TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
@@ -66,11 +68,74 @@ if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi fi
eval $TAILSCALE_CMD if [ "$ACCEPT_DNS" = "true" ]; then
UP_EXIT=$? TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
fi
if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
# 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" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS" 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 wait $TAILSCALED_PID
+121 -22
View File
@@ -272,6 +272,23 @@
.save-status.ok { color: var(--green); } .save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); } .save-status.err { color: var(--red); }
/* Toggle switch */
.toggle-row { display: flex; align-items: flex-start; gap: 12px; }
.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--border); border-radius: 20px; transition: background 0.2s;
}
.toggle-slider:before {
content: ''; position: absolute;
height: 14px; width: 14px; left: 3px; bottom: 3px;
background: white; border-radius: 50%; transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--green); }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
.toggle-info { flex: 1; }
/* Refresh indicator */ /* Refresh indicator */
.refresh-bar { .refresh-bar {
display: flex; display: flex;
@@ -425,6 +442,26 @@
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080"> <input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span> <span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div> </div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-dns">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept DNS</div>
<span class="settings-hint">Pass <code>--accept-dns=true</code> to tailscale up. Allows the tailnet to push DNS settings to this device. Off by default to avoid overriding the camera&apos;s DNS configuration.</span>
</div>
</div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-routes">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept Routes</div>
<span class="settings-hint">Pass <code>--accept-routes=true</code> to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.</span>
</div>
</div>
<div class="settings-actions"> <div class="settings-actions">
<span class="save-status" id="save-status"></span> <span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button> <button class="save-btn" id="save-btn">Save &amp; Restart</button>
@@ -453,6 +490,7 @@
(function() { (function() {
var APP = 'Tailscale_VPN'; var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP; var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var STATUS_URL = 'status.json';
var logBox = document.getElementById('log-box'); var logBox = document.getElementById('log-box');
var autoScroll = true; var autoScroll = true;
@@ -700,29 +738,82 @@
.catch(function() { return false; }); .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() { function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) Promise.all([
.then(function(r) { return r.text(); }) fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(txt) { .then(function(r) { return r.text(); })
var result = parse(txt); .catch(function() { return ''; }),
renderLogs(txt); fetchStatus()
// Always verify with the app status API - syslog can have stale entries ]).then(function(arr) {
checkAppRunning().then(function(running) { var txt = arr[0];
if (!running) { var st = arr[1];
result.state = 'disconnected'; var result = parse(txt || '');
} else if (!result.url && result.state !== 'connected') { if (txt) renderLogs(txt);
result.state = 'connected'; // Verify the app is actually running - status.json can be stale if stopped
result.ip = result.ip || cacheGet('ip'); checkAppRunning().then(function(running) {
result.node = result.node || cacheGet('node'); if (!running) {
result.tailnet = result.tailnet || cacheGet('tailnet'); result.state = 'disconnected';
result.version = result.version || cacheGet('version'); } else if (st && st.BackendState) {
} // Authoritative: Tailscale's own backend state
render(result); applyStatus(result, st);
}); } else if (!result.url && result.state !== 'connected') {
}) // Fallback to log heuristic when status.json is unavailable
.catch(function() { result.state = 'connected';
document.getElementById('status-text').textContent = 'Unable to fetch logs'; 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(); refresh();
@@ -779,6 +870,8 @@
var authInput = document.getElementById('input-authkey'); var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port'); var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port'); var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var saveBtn = document.getElementById('save-btn'); var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status'); var saveStatus = document.getElementById('save-status');
@@ -790,10 +883,14 @@
var am = txt.match(/root\.\S+\.AuthKey=(.*)/); var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/); var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/); var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
if (sm) serverInput.value = sm[1].trim(); if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim(); if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim(); if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim(); if (km) socksPortInput.value = km[1].trim();
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
// Update proxy display card with authoritative param values // Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render // and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null; var httpPort = hm ? hm[1].trim() : null;
@@ -819,7 +916,9 @@
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) + '&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) + '&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) + '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort); '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
fetch(PARAM_URL, { fetch(PARAM_URL, {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -8,7 +8,7 @@
"embeddedSdkVersion": "3.0", "embeddedSdkVersion": "3.0",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn", "runMode": "respawn",
"version": "1.98.3", "version": "1.98.8",
"architecture": "armv7hf" "architecture": "armv7hf"
}, },
"configuration": { "configuration": {
@@ -33,6 +33,16 @@
"name": "Socks5Port", "name": "Socks5Port",
"default": "1080", "default": "1080",
"type": "string" "type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
} }
] ]
} }
+45 -5
View File
@@ -31,18 +31,21 @@
#include <errno.h> #include <errno.h>
#include <signal.h> #include <signal.h>
#define APP_NAME "Tailscale_VPN" #define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" #define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" #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 pid_t child_pid = -1;
static guint reload_timer_id = 0; static guint reload_timer_id = 0;
static AXParameter *g_ax_handle = NULL;
static char *cfg_custom_server = NULL; static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL; static char *cfg_auth_key = NULL;
static char *cfg_http_proxy_port = NULL; static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL; static char *cfg_socks5_port = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static void cache_set(char **field, const char *value) { static void cache_set(char **field, const char *value) {
if (!value) return; if (!value) return;
@@ -118,6 +121,31 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
return G_SOURCE_CONTINUE; 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 ─────────────────────────────────────────────────────────── */ /* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) { static void load_config_cache(AXParameter *handle) {
@@ -139,6 +167,8 @@ static void load_config_cache(AXParameter *handle) {
LOAD("AuthKey", cfg_auth_key) LOAD("AuthKey", cfg_auth_key)
LOAD("HttpProxyPort", cfg_http_proxy_port) LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port) LOAD("Socks5Port", cfg_socks5_port)
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
#undef LOAD #undef LOAD
} }
@@ -153,6 +183,8 @@ static void write_config_file(void) {
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, "")); fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080")); fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080")); fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
fclose(f); fclose(f);
chmod(CONFIG_FILE, 0600); chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s", syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
@@ -185,6 +217,8 @@ static void parameter_changed(const gchar *name, const gchar *value,
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value); else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value); else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value); else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
if (reload_timer_id) if (reload_timer_id)
g_source_remove(reload_timer_id); g_source_remove(reload_timer_id);
@@ -211,6 +245,10 @@ int main(void) {
/* Ensure localdata dir exists */ /* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); 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); AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) { if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s", syslog(LOG_ERR, "ax_parameter_new: %s",
@@ -225,7 +263,8 @@ int main(void) {
start_child(); start_child();
const char *params[] = { const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port" "CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"AcceptDNS", "AcceptRoutes"
}; };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i], if (!ax_parameter_register_callback(handle, params[i],
@@ -240,6 +279,7 @@ int main(void) {
g_unix_signal_add(SIGTERM, signal_handler, loop); g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop); g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL); 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"); syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop); g_main_loop_run(loop);
+68 -4
View File
@@ -15,6 +15,8 @@ chmod 755 $TAILSCALE_PATH
CUSTOM_SERVER="" CUSTOM_SERVER=""
AUTH_KEY="" AUTH_KEY=""
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
if [ -f "$STATE_DIR/params.conf" ]; then if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf" . "$STATE_DIR/params.conf"
@@ -30,7 +32,7 @@ TAILSCALED_PID=$!
sleep 2 sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)" TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
@@ -40,9 +42,71 @@ if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi fi
eval $TAILSCALE_CMD if [ "$ACCEPT_DNS" = "true" ]; then
UP_EXIT=$? TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
fi
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)" if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
# 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=$!
# 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 wait $TAILSCALED_PID
+121 -22
View File
@@ -272,6 +272,23 @@
.save-status.ok { color: var(--green); } .save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); } .save-status.err { color: var(--red); }
/* Toggle switch */
.toggle-row { display: flex; align-items: flex-start; gap: 12px; }
.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--border); border-radius: 20px; transition: background 0.2s;
}
.toggle-slider:before {
content: ''; position: absolute;
height: 14px; width: 14px; left: 3px; bottom: 3px;
background: white; border-radius: 50%; transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--green); }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
.toggle-info { flex: 1; }
/* Refresh indicator */ /* Refresh indicator */
.refresh-bar { .refresh-bar {
display: flex; display: flex;
@@ -425,6 +442,26 @@
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080"> <input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span> <span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div> </div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-dns">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept DNS</div>
<span class="settings-hint">Pass <code>--accept-dns=true</code> to tailscale up. Allows the tailnet to push DNS settings to this device. Off by default to avoid overriding the camera&apos;s DNS configuration.</span>
</div>
</div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-routes">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept Routes</div>
<span class="settings-hint">Pass <code>--accept-routes=true</code> to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.</span>
</div>
</div>
<div class="settings-actions"> <div class="settings-actions">
<span class="save-status" id="save-status"></span> <span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button> <button class="save-btn" id="save-btn">Save &amp; Restart</button>
@@ -453,6 +490,7 @@
(function() { (function() {
var APP = 'Tailscale_VPN'; var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP; var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var STATUS_URL = 'status.json';
var logBox = document.getElementById('log-box'); var logBox = document.getElementById('log-box');
var autoScroll = true; var autoScroll = true;
@@ -700,29 +738,82 @@
.catch(function() { return false; }); .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() { function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) Promise.all([
.then(function(r) { return r.text(); }) fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(txt) { .then(function(r) { return r.text(); })
var result = parse(txt); .catch(function() { return ''; }),
renderLogs(txt); fetchStatus()
// Always verify with the app status API - syslog can have stale entries ]).then(function(arr) {
checkAppRunning().then(function(running) { var txt = arr[0];
if (!running) { var st = arr[1];
result.state = 'disconnected'; var result = parse(txt || '');
} else if (!result.url && result.state !== 'connected') { if (txt) renderLogs(txt);
result.state = 'connected'; // Verify the app is actually running - status.json can be stale if stopped
result.ip = result.ip || cacheGet('ip'); checkAppRunning().then(function(running) {
result.node = result.node || cacheGet('node'); if (!running) {
result.tailnet = result.tailnet || cacheGet('tailnet'); result.state = 'disconnected';
result.version = result.version || cacheGet('version'); } else if (st && st.BackendState) {
} // Authoritative: Tailscale's own backend state
render(result); applyStatus(result, st);
}); } else if (!result.url && result.state !== 'connected') {
}) // Fallback to log heuristic when status.json is unavailable
.catch(function() { result.state = 'connected';
document.getElementById('status-text').textContent = 'Unable to fetch logs'; 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(); refresh();
@@ -779,6 +870,8 @@
var authInput = document.getElementById('input-authkey'); var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port'); var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port'); var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var saveBtn = document.getElementById('save-btn'); var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status'); var saveStatus = document.getElementById('save-status');
@@ -790,10 +883,14 @@
var am = txt.match(/root\.\S+\.AuthKey=(.*)/); var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/); var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/); var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
if (sm) serverInput.value = sm[1].trim(); if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim(); if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim(); if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim(); if (km) socksPortInput.value = km[1].trim();
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
// Update proxy display card with authoritative param values // Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render // and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null; var httpPort = hm ? hm[1].trim() : null;
@@ -819,7 +916,9 @@
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) + '&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) + '&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) + '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort); '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
fetch(PARAM_URL, { fetch(PARAM_URL, {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -12,7 +12,7 @@
}, },
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn", "runMode": "respawn",
"version": "1.98.3", "version": "1.98.8",
"architecture": "armv7hf" "architecture": "armv7hf"
}, },
"configuration": { "configuration": {
@@ -27,6 +27,16 @@
"name": "AuthKey", "name": "AuthKey",
"default": "", "default": "",
"type": "string" "type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
} }
] ]
} }
+42 -5
View File
@@ -21,16 +21,19 @@
#include <errno.h> #include <errno.h>
#include <signal.h> #include <signal.h>
#define APP_NAME "Tailscale_VPN" #define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" #define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" #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 pid_t child_pid = -1;
static guint reload_timer_id = 0; static guint reload_timer_id = 0;
static AXParameter *g_ax_handle = NULL;
static char *cfg_custom_server = NULL; static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL; static char *cfg_auth_key = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static void cache_set(char **field, const char *value) { static void cache_set(char **field, const char *value) {
if (!value) return; if (!value) return;
@@ -102,6 +105,29 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
return G_SOURCE_CONTINUE; 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) { static void load_config_cache(AXParameter *handle) {
GError *error = NULL; GError *error = NULL;
gchar *val = NULL; gchar *val = NULL;
@@ -119,6 +145,8 @@ static void load_config_cache(AXParameter *handle) {
LOAD("CustomServer", cfg_custom_server) LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key) LOAD("AuthKey", cfg_auth_key)
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
#undef LOAD #undef LOAD
} }
@@ -131,6 +159,8 @@ static void write_config_file(void) {
} }
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, "")); fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, "")); fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
fclose(f); fclose(f);
chmod(CONFIG_FILE, 0600); chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s", syslog(LOG_INFO, "config updated: server=%s",
@@ -156,6 +186,8 @@ static void parameter_changed(const gchar *name, const gchar *value,
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value); if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value); else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
if (reload_timer_id) if (reload_timer_id)
g_source_remove(reload_timer_id); g_source_remove(reload_timer_id);
@@ -177,6 +209,10 @@ int main(void) {
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); 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); AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) { if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s", syslog(LOG_ERR, "ax_parameter_new: %s",
@@ -190,7 +226,7 @@ int main(void) {
write_config_file(); write_config_file();
start_child(); start_child();
const char *params[] = { "CustomServer", "AuthKey" }; const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i], if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) { parameter_changed, handle, &error)) {
@@ -204,6 +240,7 @@ int main(void) {
g_unix_signal_add(SIGTERM, signal_handler, loop); g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop); g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL); 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"); syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop); g_main_loop_run(loop);
+4
View File
@@ -30,6 +30,10 @@ RUN cp html/index.html index.html
# The log is written at runtime to localdata/ (resolved path at runtime). # The log is written at runtime to localdata/ (resolved path at runtime).
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log 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 # Build and package
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./ RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
+29 -3
View File
@@ -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" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055" logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
# Monitoring loop: stay alive while tailscaled is running. # Publish tailscale's real backend state as JSON for the web UI to consume.
# This keeps the parent Tailscale_VPN (C launcher) in the process table # This is the authoritative connection signal (BackendState / Self.Online /
# so pidof finds it and the camera web UI shows "Running" instead of "Stopped". # 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 while kill -0 "$TAILSCALED_PID" 2>/dev/null; do
publish_status
sleep 5 sleep 5
done done
rm -f "$STATUS_FILE" 2>/dev/null
logger -t "Tailscale_VPN" "tailscaled exited" logger -t "Tailscale_VPN" "tailscaled exited"
+54 -2
View File
@@ -612,6 +612,53 @@
// ACAP3: also fetch the raw tailscaled.log (symlinked into html/) so the parser // 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. // can find IP, version, tailnet and Running state from tailscaled's own output.
var DAEMON_LOG_URL = 'tailscaled.log'; 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() { function refresh() {
var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) 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' }) var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); }).catch(function() { return ''; }); .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. // Syslog provides Axis timestamp headers (node name) and start/stop events.
// tailscaled.log provides IP, version, tailnet, and -> Running state. // tailscaled.log provides IP, version, tailnet, and -> Running state.
var txt = res[0] + '\n' + res[1]; var txt = res[0] + '\n' + res[1];
var st = res[2];
var result = parse(txt); var result = parse(txt);
renderLogs(res[0]); // show syslog in log panel; daemon log is too verbose 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) { checkAppRunning().then(function(running) {
if (!running) { if (!running) {
result.state = 'disconnected'; result.state = 'disconnected';
} else if (st && st.BackendState) {
// Authoritative: Tailscale's own backend state
applyStatus(result, st);
} else if (!result.url && result.state !== 'connected') { } else if (!result.url && result.state !== 'connected') {
// Fallback to log heuristic when status.json is unavailable
result.state = 'connected'; result.state = 'connected';
result.ip = result.ip || cacheGet('ip'); result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node'); result.node = result.node || cacheGet('node');
+1 -1
View File
@@ -3,7 +3,7 @@ MENUNAME="Tailscale VPN"
VENDOR="Mo3he" VENDOR="Mo3he"
APPMAJORVERSION=1 APPMAJORVERSION=1
APPMINORVERSION=98 APPMINORVERSION=98
APPMICROVERSION=3 APPMICROVERSION=8
APPTYPE=armv7hf APPTYPE=armv7hf
APPNAME=Tailscale_VPN APPNAME=Tailscale_VPN
APPOPTS="" APPOPTS=""