Compare commits

...
Author SHA1 Message Date
Weston Blieden 3ed71ccae3 Add subnet routing and param.cgi-less settings fallback
- Advertise routes (subnet router) support wired through run scripts and params
- Settings UI tries param.cgi first, then falls back to an app-hosted endpoint
  exposed via manifest reverseProxy, so devices without param.cgi (recorder/NVR
  class) can load and save settings without a reinstall
- Embedded GSocketService HTTP server in param_bridge serves the fallback
- Adds gio-2.0 dependency; correct aarch64 tailscale binaries
2026-07-01 09:02:28 +02:00
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
35 changed files with 2272 additions and 256 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.
<!-- markdownlint-disable MD034 -->
[issues]: https://github.com/AxisCommunications/tailscale-acap/issues
[issues_new]: https://github.com/AxisCommunications/tailscale-acap/issues/new
[issues_bugs]: https://github.com/AxisCommunications/tailscale-acap/issues?q=label%3Abug
[discussions]: https://github.com/AxisCommunications/tailscale-acap/discussions
[discussions_new]: https://github.com/AxisCommunications/tailscale-acap/discussions/new
[issues]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues
[issues_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues/new
[issues_bugs]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues?q=label%3Abug
[discussions]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions
[discussions_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions/new
<!-- markdownlint-enable MD034 -->
+1
View File
@@ -1,6 +1,7 @@
BSD 3-Clause License
Copyright (c) 2020 Tailscale & AUTHORS.
Copyright (c) 2022 Weston Blieden (ACAP packaging and wrapper code)
All rights reserved.
Redistribution and use in source and binary forms, with or without
+2 -3
View File
@@ -117,7 +117,8 @@ There is an important asymmetry to understand. Making the camera **reachable fro
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 (works even on non-root)
### 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.
@@ -130,8 +131,6 @@ 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.
> **Requires root on the camera.** Port 445 is privileged, so binding it needs a root-capable build (e.g. developer certificates installed, or the ROOT variant). If the share dialog ever supports a custom (unprivileged) SMB port, the same trick works as a non-root SSH user.
---
## Updating Tailscale
+1 -1
View File
@@ -1,6 +1,6 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
PKGS = axparameter glib-2.0 gio-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
+68 -3
View File
@@ -20,6 +20,7 @@ CONF_HTTP="8080"
CONF_SOCKS="1080"
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
ADVERTISE_ROUTES=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
@@ -76,11 +77,75 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In userspace-networking mode the
# tailscaled netstack forwards tailnet traffic to these subnets, so no kernel IP
# forwarding is required. Routes must still be approved in the Tailscale admin
# console before peers can use them.
if [ -n "$ADVERTISE_ROUTES" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
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" "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
+170 -51
View File
@@ -462,6 +462,11 @@
<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-row">
<label class="settings-label" for="input-advertise-routes">Advertise Routes (Subnet Router)</label>
<input class="settings-input" id="input-advertise-routes" type="text" autocomplete="off" placeholder="192.168.1.0/24,10.0.0.0/8 (leave blank to disable)">
<span class="settings-hint">Comma-separated CIDRs this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
@@ -490,6 +495,7 @@
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var STATUS_URL = 'status.json';
var logBox = document.getElementById('log-box');
var autoScroll = true;
@@ -737,29 +743,82 @@
.catch(function() { return false; });
}
// Ground truth published by the run script from `tailscale status --json`.
function fetchStatus() {
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.catch(function() { return null; });
}
// Apply Tailscale's authoritative backend state onto the result object.
function applyStatus(result, st) {
var self = st.Self || {};
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
var ip4 = null;
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
var bs = st.BackendState;
if (st.Version) result.version = String(st.Version).split('-')[0];
if (bs === 'Running' && self.Online === true) {
// Genuinely connected and reachable on the tailnet
result.state = 'connected';
result.url = null;
result.ip = ip4 || result.ip;
result.node = self.HostName || result.node;
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
cacheSet('ip', result.ip); cacheSet('node', result.node);
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Running') {
// Backend running but node not online: either a transient network
// drop (no action needed) or the node was removed/expired and needs
// re-auth. Not connected. Keep any login URL the log parser found
// (status.json's AuthURL lags during the `tailscale up` re-auth
// window) so the login button still appears when re-auth is needed.
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Stopped') {
result.state = 'disconnected';
result.url = null;
} else {
// NoState / Starting / unknown
result.state = 'connecting';
result.url = st.AuthURL || result.url;
}
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
Promise.all([
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.catch(function() { return ''; }),
fetchStatus()
]).then(function(arr) {
var txt = arr[0];
var st = arr[1];
var result = parse(txt || '');
if (txt) renderLogs(txt);
// Verify the app is actually running - status.json can be stale if stopped
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (st && st.BackendState) {
// Authoritative: Tailscale's own backend state
applyStatus(result, st);
} else if (!result.url && result.state !== 'connected') {
// Fallback to log heuristic when status.json is unavailable
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
});
}
refresh();
@@ -818,32 +877,64 @@
var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var advertiseRoutesInput = document.getElementById('input-advertise-routes');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
// param.cgi is used when available; on devices that lack it (e.g. some
// recorder/NVR-class devices) we fall back to the app's own endpoint,
// exposed through the manifest reverseProxy mapping at API_URL.
var API_URL = '/local/' + APP + '/api/settings';
function updateProxyDisplay(httpPort, socksPort) {
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
}
function applyParamText(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
var avm = txt.match(/root\.\S+\.AdvertiseRoutes=(.*)/);
// If none of the expected keys are present the endpoint isn't param.cgi
// (e.g. a generic 404 page); signal the caller to use the fallback.
if (!sm && !hm && !km) return false;
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[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';
if (avm) advertiseRoutesInput.value = avm[1].trim();
updateProxyDisplay(hm ? hm[1].trim() : null, km ? km[1].trim() : null);
return true;
}
function applyJson(obj) {
if (typeof obj.CustomServer === 'string') serverInput.value = obj.CustomServer;
if (typeof obj.AuthKey === 'string') authInput.value = obj.AuthKey;
if (typeof obj.HttpProxyPort === 'string') httpPortInput.value = obj.HttpProxyPort;
if (typeof obj.Socks5Port === 'string') socksPortInput.value = obj.Socks5Port;
if (typeof obj.AcceptDNS === 'string') acceptDnsInput.checked = obj.AcceptDNS === 'true';
if (typeof obj.AcceptRoutes === 'string') acceptRoutesInput.checked = obj.AcceptRoutes === 'true';
if (typeof obj.AdvertiseRoutes === 'string') advertiseRoutesInput.value = obj.AdvertiseRoutes;
updateProxyDisplay(obj.HttpProxyPort, obj.Socks5Port);
}
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
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 (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[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
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) { if (!applyParamText(txt)) return Promise.reject(); })
.catch(function() { loadSettingsFallback(); });
}
function loadSettingsFallback() {
fetch(API_URL + '?t=' + Date.now(), { credentials: 'same-origin', cache: 'no-store' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(obj) { if (obj) applyJson(obj); })
.catch(function() {});
}
@@ -853,6 +944,32 @@
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
function saveViaFallback(httpPort, socksPort) {
var body = 'CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&HttpProxyPort=' + encodeURIComponent(httpPort) +
'&Socks5Port=' + encodeURIComponent(socksPort) +
'&AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
return fetch(API_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
if (/OK/.test(txt)) {
// The app applies the change and restarts its tunnel itself,
// so no separate control.cgi restart is needed here.
setStatus('Saved. Restarting...', 'ok');
} else {
setStatus('Error saving settings', 'err');
}
});
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
@@ -864,29 +981,31 @@
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
// param.cgi reachable but rejected the update — surface the error.
setStatus('Error: ' + txt.trim(), 'err');
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
.catch(function() {
// param.cgi unavailable (e.g. recorder-class device) — use the fallback.
return saveViaFallback(httpPort, socksPort);
})
.then(function() { saveBtn.disabled = false; })
.catch(function() { saveBtn.disabled = false; setStatus('Failed to save', 'err'); });
});
loadSettings();
Binary file not shown.
Binary file not shown.
+13 -1
View File
@@ -8,11 +8,18 @@
"embeddedSdkVersion": "3.0",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.98.4",
"version": "1.98.8",
"architecture": "aarch64"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
@@ -43,6 +50,11 @@
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
+294 -4
View File
@@ -18,6 +18,7 @@
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <gio/gio.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
@@ -31,9 +32,10 @@
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
@@ -45,6 +47,7 @@ static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static char *cfg_advertise_routes = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
@@ -56,6 +59,17 @@ static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
* upgrades a newly introduced manifest parameter is not always auto-registered,
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
GError *err = NULL;
if (!ax_parameter_add(handle, name, def, "string", &err)) {
if (err) g_error_free(err);
}
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
@@ -120,6 +134,31 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
return G_SOURCE_CONTINUE;
}
/* ── auth-key sentinel ───────────────────────────────────────────────────── */
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
* disappears from the settings UI. This replaces the old exit-code-0 path,
* which never fired because tailscaled keeps the child alive indefinitely. */
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
if (access(SENTINEL_FILE, F_OK) != 0)
return G_SOURCE_CONTINUE;
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
unlink(SENTINEL_FILE);
return G_SOURCE_CONTINUE;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
@@ -143,6 +182,7 @@ static void load_config_cache(AXParameter *handle) {
LOAD("Socks5Port", cfg_socks5_port)
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
LOAD("AdvertiseRoutes", cfg_advertise_routes)
#undef LOAD
}
@@ -159,6 +199,7 @@ static void write_config_file(void) {
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"));
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
@@ -193,12 +234,252 @@ static void parameter_changed(const gchar *name, const gchar *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);
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
* settings through it. This tiny HTTP server, reached through the manifest
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
* fall back to reading and writing the parameters directly. */
#define HTTP_PORT 2201
static const char *http_param_names[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
static void cache_set_by_name(const char *name, const char *value) {
if (strcmp(name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
else if (strcmp(name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
}
static int http_is_known_param(const char *name) {
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++)
if (strcmp(name, http_param_names[i]) == 0) return 1;
return 0;
}
static void http_json_append_escaped(GString *out, const char *s) {
for (const char *p = s; *p; p++) {
switch (*p) {
case '"': g_string_append(out, "\\\""); break;
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
case '\t': g_string_append(out, "\\t"); break;
default:
if ((unsigned char)*p < 0x20)
g_string_append_printf(out, "\\u%04x", (unsigned char)*p);
else
g_string_append_c(out, *p);
}
}
}
static gchar *http_build_settings_json(AXParameter *handle) {
GString *out = g_string_new("{");
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++) {
gchar *val = NULL;
GError *err = NULL;
if (!ax_parameter_get(handle, http_param_names[i], &val, &err)) {
if (err) g_error_free(err);
val = g_strdup("");
}
if (i) g_string_append_c(out, ',');
g_string_append_printf(out, "\"%s\":\"", http_param_names[i]);
http_json_append_escaped(out, val ? val : "");
g_string_append_c(out, '"');
g_free(val);
}
g_string_append_c(out, '}');
return g_string_free(out, FALSE);
}
static gchar *http_url_decode(const char *s, size_t len) {
GString *out = g_string_new(NULL);
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (c == '+') {
g_string_append_c(out, ' ');
} else if (c == '%' && i + 2 < len &&
g_ascii_isxdigit(s[i + 1]) && g_ascii_isxdigit(s[i + 2])) {
int hi = g_ascii_xdigit_value(s[i + 1]);
int lo = g_ascii_xdigit_value(s[i + 2]);
g_string_append_c(out, (char)((hi << 4) | lo));
i += 2;
} else {
g_string_append_c(out, c);
}
}
return g_string_free(out, FALSE);
}
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
* the parameter store. Returns the number of parameters successfully set. */
static int http_apply_settings(AXParameter *handle, const char *body, size_t len) {
int applied = 0;
size_t start = 0;
for (size_t i = 0; i <= len; i++) {
if (i == len || body[i] == '&') {
size_t seg_len = i - start;
if (seg_len > 0) {
const char *seg = body + start;
const char *eq = memchr(seg, '=', seg_len);
if (eq) {
size_t nlen = (size_t)(eq - seg);
gchar *name = g_strndup(seg, nlen);
gchar *value = http_url_decode(eq + 1, seg_len - nlen - 1);
if (http_is_known_param(name)) {
GError *err = NULL;
if (ax_parameter_set(handle, name, value, TRUE, &err)) {
cache_set_by_name(name, value);
applied++;
} else {
syslog(LOG_WARNING, "http set %s failed: %s",
name, err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
g_free(name);
g_free(value);
}
}
start = i + 1;
}
}
return applied;
}
static size_t http_parse_content_length(const char *hdr, size_t hlen) {
const char *key = "content-length:";
size_t klen = strlen(key);
for (size_t i = 0; i + klen <= hlen; i++) {
if (g_ascii_strncasecmp(hdr + i, key, klen) == 0) {
i += klen;
while (i < hlen && (hdr[i] == ' ' || hdr[i] == '\t')) i++;
return (size_t)strtoul(hdr + i, NULL, 10);
}
}
return 0;
}
static void http_send(GOutputStream *out, const char *status,
const char *ctype, const char *body) {
gchar *resp = g_strdup_printf(
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
status, ctype, strlen(body), body);
g_output_stream_write_all(out, resp, strlen(resp), NULL, NULL, NULL);
g_free(resp);
}
static gboolean http_on_incoming(GSocketService *service G_GNUC_UNUSED,
GSocketConnection *connection,
GObject *source G_GNUC_UNUSED,
gpointer user_data) {
AXParameter *handle = (AXParameter *)user_data;
GInputStream *in = g_io_stream_get_input_stream(G_IO_STREAM(connection));
GOutputStream *out = g_io_stream_get_output_stream(G_IO_STREAM(connection));
GString *req = g_string_new(NULL);
char buf[2048];
int have_headers = 0;
size_t header_end = 0;
size_t content_length = 0;
while (1) {
gssize n = g_input_stream_read(in, buf, sizeof(buf), NULL, NULL);
if (n <= 0) break;
g_string_append_len(req, buf, n);
if (!have_headers) {
char *p = g_strstr_len(req->str, req->len, "\r\n\r\n");
if (p) {
have_headers = 1;
header_end = (size_t)(p - req->str) + 4;
content_length = http_parse_content_length(req->str, header_end);
}
}
if (have_headers && req->len - header_end >= content_length) break;
if (req->len > 262144) break; /* safety cap */
}
int is_get = 0, is_post = 0, is_settings = 0;
if (have_headers) {
if (g_str_has_prefix(req->str, "GET ")) is_get = 1;
if (g_str_has_prefix(req->str, "POST ")) is_post = 1;
const char *sp1 = strchr(req->str, ' ');
if (sp1) {
const char *path = sp1 + 1;
const char *sp2 = strchr(path, ' ');
size_t plen = sp2 ? (size_t)(sp2 - path) : strlen(path);
const char *q = memchr(path, '?', plen);
size_t match_len = q ? (size_t)(q - path) : plen;
if (match_len >= 8 &&
g_ascii_strncasecmp(path + match_len - 8, "settings", 8) == 0)
is_settings = 1;
}
}
if (is_settings && is_get) {
gchar *json = http_build_settings_json(handle);
http_send(out, "200 OK", "application/json", json);
g_free(json);
} else if (is_settings && is_post) {
const char *body = req->str + header_end;
size_t body_len = req->len - header_end;
if (body_len > content_length) body_len = content_length;
int applied = http_apply_settings(handle, body, body_len);
syslog(LOG_INFO, "settings http: applied %d parameter(s)", applied);
if (reload_timer_id) g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
http_send(out, "200 OK", "text/plain", "OK");
} else {
http_send(out, "404 Not Found", "text/plain", "Not found");
}
g_string_free(req, TRUE);
g_io_stream_close(G_IO_STREAM(connection), NULL, NULL);
return TRUE;
}
static void http_server_start(AXParameter *handle) {
GError *err = NULL;
GSocketService *service = g_socket_service_new();
GInetAddress *addr = g_inet_address_new_from_string("127.0.0.1");
GSocketAddress *saddr = g_inet_socket_address_new(addr, HTTP_PORT);
if (!g_socket_listener_add_address(G_SOCKET_LISTENER(service), saddr,
G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP,
NULL, NULL, &err)) {
syslog(LOG_WARNING, "settings http: bind 127.0.0.1:%d failed: %s",
HTTP_PORT, err ? err->message : "unknown");
if (err) g_error_free(err);
g_object_unref(service);
} else {
g_signal_connect(service, "incoming", G_CALLBACK(http_on_incoming), handle);
g_socket_service_start(service);
syslog(LOG_INFO, "settings http server listening on 127.0.0.1:%d", HTTP_PORT);
}
g_object_unref(addr);
g_object_unref(saddr);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
@@ -219,6 +500,10 @@ int main(void) {
/* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
* freshly configured key before it has been used. */
unlink(SENTINEL_FILE);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
@@ -228,13 +513,15 @@ int main(void) {
}
g_ax_handle = handle;
ensure_param(handle, "AdvertiseRoutes", "");
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"AcceptDNS", "AcceptRoutes"
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
@@ -245,10 +532,13 @@ int main(void) {
}
}
http_server_start(handle);
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
+1 -1
View File
@@ -1,6 +1,6 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
PKGS = axparameter glib-2.0 gio-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
+68 -3
View File
@@ -17,6 +17,7 @@ CUSTOM_SERVER=""
AUTH_KEY=""
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
ADVERTISE_ROUTES=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
@@ -50,9 +51,73 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In kernel-networking (root) mode the
# host must forward packets between the tailnet and the LAN, so enable IP
# forwarding. Routes must still be approved in the Tailscale admin console.
if [ -n "$ADVERTISE_ROUTES" ]; then
echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
echo 1 > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
fi
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
# 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
+170 -51
View File
@@ -462,6 +462,11 @@
<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-row">
<label class="settings-label" for="input-advertise-routes">Advertise Routes (Subnet Router)</label>
<input class="settings-input" id="input-advertise-routes" type="text" autocomplete="off" placeholder="192.168.1.0/24,10.0.0.0/8 (leave blank to disable)">
<span class="settings-hint">Comma-separated CIDRs this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
@@ -490,6 +495,7 @@
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var STATUS_URL = 'status.json';
var logBox = document.getElementById('log-box');
var autoScroll = true;
@@ -737,29 +743,82 @@
.catch(function() { return false; });
}
// Ground truth published by the run script from `tailscale status --json`.
function fetchStatus() {
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.catch(function() { return null; });
}
// Apply Tailscale's authoritative backend state onto the result object.
function applyStatus(result, st) {
var self = st.Self || {};
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
var ip4 = null;
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
var bs = st.BackendState;
if (st.Version) result.version = String(st.Version).split('-')[0];
if (bs === 'Running' && self.Online === true) {
// Genuinely connected and reachable on the tailnet
result.state = 'connected';
result.url = null;
result.ip = ip4 || result.ip;
result.node = self.HostName || result.node;
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
cacheSet('ip', result.ip); cacheSet('node', result.node);
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Running') {
// Backend running but node not online: either a transient network
// drop (no action needed) or the node was removed/expired and needs
// re-auth. Not connected. Keep any login URL the log parser found
// (status.json's AuthURL lags during the `tailscale up` re-auth
// window) so the login button still appears when re-auth is needed.
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Stopped') {
result.state = 'disconnected';
result.url = null;
} else {
// NoState / Starting / unknown
result.state = 'connecting';
result.url = st.AuthURL || result.url;
}
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
Promise.all([
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.catch(function() { return ''; }),
fetchStatus()
]).then(function(arr) {
var txt = arr[0];
var st = arr[1];
var result = parse(txt || '');
if (txt) renderLogs(txt);
// Verify the app is actually running - status.json can be stale if stopped
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (st && st.BackendState) {
// Authoritative: Tailscale's own backend state
applyStatus(result, st);
} else if (!result.url && result.state !== 'connected') {
// Fallback to log heuristic when status.json is unavailable
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
});
}
refresh();
@@ -818,32 +877,64 @@
var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var advertiseRoutesInput = document.getElementById('input-advertise-routes');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
// param.cgi is used when available; on devices that lack it (e.g. some
// recorder/NVR-class devices) we fall back to the app's own endpoint,
// exposed through the manifest reverseProxy mapping at API_URL.
var API_URL = '/local/' + APP + '/api/settings';
function updateProxyDisplay(httpPort, socksPort) {
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
}
function applyParamText(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
var avm = txt.match(/root\.\S+\.AdvertiseRoutes=(.*)/);
// If none of the expected keys are present the endpoint isn't param.cgi
// (e.g. a generic 404 page); signal the caller to use the fallback.
if (!sm && !hm && !km) return false;
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[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';
if (avm) advertiseRoutesInput.value = avm[1].trim();
updateProxyDisplay(hm ? hm[1].trim() : null, km ? km[1].trim() : null);
return true;
}
function applyJson(obj) {
if (typeof obj.CustomServer === 'string') serverInput.value = obj.CustomServer;
if (typeof obj.AuthKey === 'string') authInput.value = obj.AuthKey;
if (typeof obj.HttpProxyPort === 'string') httpPortInput.value = obj.HttpProxyPort;
if (typeof obj.Socks5Port === 'string') socksPortInput.value = obj.Socks5Port;
if (typeof obj.AcceptDNS === 'string') acceptDnsInput.checked = obj.AcceptDNS === 'true';
if (typeof obj.AcceptRoutes === 'string') acceptRoutesInput.checked = obj.AcceptRoutes === 'true';
if (typeof obj.AdvertiseRoutes === 'string') advertiseRoutesInput.value = obj.AdvertiseRoutes;
updateProxyDisplay(obj.HttpProxyPort, obj.Socks5Port);
}
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
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 (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[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
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) { if (!applyParamText(txt)) return Promise.reject(); })
.catch(function() { loadSettingsFallback(); });
}
function loadSettingsFallback() {
fetch(API_URL + '?t=' + Date.now(), { credentials: 'same-origin', cache: 'no-store' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(obj) { if (obj) applyJson(obj); })
.catch(function() {});
}
@@ -853,6 +944,32 @@
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
function saveViaFallback(httpPort, socksPort) {
var body = 'CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&HttpProxyPort=' + encodeURIComponent(httpPort) +
'&Socks5Port=' + encodeURIComponent(socksPort) +
'&AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
return fetch(API_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
if (/OK/.test(txt)) {
// The app applies the change and restarts its tunnel itself,
// so no separate control.cgi restart is needed here.
setStatus('Saved. Restarting...', 'ok');
} else {
setStatus('Error saving settings', 'err');
}
});
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
@@ -864,29 +981,31 @@
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
// param.cgi reachable but rejected the update — surface the error.
setStatus('Error: ' + txt.trim(), 'err');
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
.catch(function() {
// param.cgi unavailable (e.g. recorder-class device) — use the fallback.
return saveViaFallback(httpPort, socksPort);
})
.then(function() { saveBtn.disabled = false; })
.catch(function() { saveBtn.disabled = false; setStatus('Failed to save', 'err'); });
});
loadSettings();
Binary file not shown.
Binary file not shown.
+13 -1
View File
@@ -12,11 +12,18 @@
},
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.98.4",
"version": "1.98.8",
"architecture": "aarch64"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
@@ -37,6 +44,11 @@
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
+289 -4
View File
@@ -8,6 +8,7 @@
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <gio/gio.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
@@ -21,9 +22,10 @@
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
@@ -33,6 +35,7 @@ static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static char *cfg_advertise_routes = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
@@ -44,6 +47,17 @@ static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
* upgrades a newly introduced manifest parameter is not always auto-registered,
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
GError *err = NULL;
if (!ax_parameter_add(handle, name, def, "string", &err)) {
if (err) g_error_free(err);
}
}
static void stop_child(void) {
if (child_pid <= 0)
return;
@@ -104,6 +118,29 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
return G_SOURCE_CONTINUE;
}
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
* disappears from the settings UI. This replaces the old exit-code-0 path,
* which never fired because tailscaled keeps the child alive indefinitely. */
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
if (access(SENTINEL_FILE, F_OK) != 0)
return G_SOURCE_CONTINUE;
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
unlink(SENTINEL_FILE);
return G_SOURCE_CONTINUE;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
@@ -123,6 +160,7 @@ static void load_config_cache(AXParameter *handle) {
LOAD("AuthKey", cfg_auth_key)
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
LOAD("AdvertiseRoutes", cfg_advertise_routes)
#undef LOAD
}
@@ -137,6 +175,7 @@ static void write_config_file(void) {
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"));
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
@@ -164,12 +203,249 @@ 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, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
* settings through it. This tiny HTTP server, reached through the manifest
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
* fall back to reading and writing the parameters directly. */
#define HTTP_PORT 2201
static const char *http_param_names[] = {
"CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
static void cache_set_by_name(const char *name, const char *value) {
if (strcmp(name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
}
static int http_is_known_param(const char *name) {
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++)
if (strcmp(name, http_param_names[i]) == 0) return 1;
return 0;
}
static void http_json_append_escaped(GString *out, const char *s) {
for (const char *p = s; *p; p++) {
switch (*p) {
case '"': g_string_append(out, "\\\""); break;
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
case '\t': g_string_append(out, "\\t"); break;
default:
if ((unsigned char)*p < 0x20)
g_string_append_printf(out, "\\u%04x", (unsigned char)*p);
else
g_string_append_c(out, *p);
}
}
}
static gchar *http_build_settings_json(AXParameter *handle) {
GString *out = g_string_new("{");
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++) {
gchar *val = NULL;
GError *err = NULL;
if (!ax_parameter_get(handle, http_param_names[i], &val, &err)) {
if (err) g_error_free(err);
val = g_strdup("");
}
if (i) g_string_append_c(out, ',');
g_string_append_printf(out, "\"%s\":\"", http_param_names[i]);
http_json_append_escaped(out, val ? val : "");
g_string_append_c(out, '"');
g_free(val);
}
g_string_append_c(out, '}');
return g_string_free(out, FALSE);
}
static gchar *http_url_decode(const char *s, size_t len) {
GString *out = g_string_new(NULL);
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (c == '+') {
g_string_append_c(out, ' ');
} else if (c == '%' && i + 2 < len &&
g_ascii_isxdigit(s[i + 1]) && g_ascii_isxdigit(s[i + 2])) {
int hi = g_ascii_xdigit_value(s[i + 1]);
int lo = g_ascii_xdigit_value(s[i + 2]);
g_string_append_c(out, (char)((hi << 4) | lo));
i += 2;
} else {
g_string_append_c(out, c);
}
}
return g_string_free(out, FALSE);
}
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
* the parameter store. Returns the number of parameters successfully set. */
static int http_apply_settings(AXParameter *handle, const char *body, size_t len) {
int applied = 0;
size_t start = 0;
for (size_t i = 0; i <= len; i++) {
if (i == len || body[i] == '&') {
size_t seg_len = i - start;
if (seg_len > 0) {
const char *seg = body + start;
const char *eq = memchr(seg, '=', seg_len);
if (eq) {
size_t nlen = (size_t)(eq - seg);
gchar *name = g_strndup(seg, nlen);
gchar *value = http_url_decode(eq + 1, seg_len - nlen - 1);
if (http_is_known_param(name)) {
GError *err = NULL;
if (ax_parameter_set(handle, name, value, TRUE, &err)) {
cache_set_by_name(name, value);
applied++;
} else {
syslog(LOG_WARNING, "http set %s failed: %s",
name, err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
g_free(name);
g_free(value);
}
}
start = i + 1;
}
}
return applied;
}
static size_t http_parse_content_length(const char *hdr, size_t hlen) {
const char *key = "content-length:";
size_t klen = strlen(key);
for (size_t i = 0; i + klen <= hlen; i++) {
if (g_ascii_strncasecmp(hdr + i, key, klen) == 0) {
i += klen;
while (i < hlen && (hdr[i] == ' ' || hdr[i] == '\t')) i++;
return (size_t)strtoul(hdr + i, NULL, 10);
}
}
return 0;
}
static void http_send(GOutputStream *out, const char *status,
const char *ctype, const char *body) {
gchar *resp = g_strdup_printf(
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
status, ctype, strlen(body), body);
g_output_stream_write_all(out, resp, strlen(resp), NULL, NULL, NULL);
g_free(resp);
}
static gboolean http_on_incoming(GSocketService *service G_GNUC_UNUSED,
GSocketConnection *connection,
GObject *source G_GNUC_UNUSED,
gpointer user_data) {
AXParameter *handle = (AXParameter *)user_data;
GInputStream *in = g_io_stream_get_input_stream(G_IO_STREAM(connection));
GOutputStream *out = g_io_stream_get_output_stream(G_IO_STREAM(connection));
GString *req = g_string_new(NULL);
char buf[2048];
int have_headers = 0;
size_t header_end = 0;
size_t content_length = 0;
while (1) {
gssize n = g_input_stream_read(in, buf, sizeof(buf), NULL, NULL);
if (n <= 0) break;
g_string_append_len(req, buf, n);
if (!have_headers) {
char *p = g_strstr_len(req->str, req->len, "\r\n\r\n");
if (p) {
have_headers = 1;
header_end = (size_t)(p - req->str) + 4;
content_length = http_parse_content_length(req->str, header_end);
}
}
if (have_headers && req->len - header_end >= content_length) break;
if (req->len > 262144) break; /* safety cap */
}
int is_get = 0, is_post = 0, is_settings = 0;
if (have_headers) {
if (g_str_has_prefix(req->str, "GET ")) is_get = 1;
if (g_str_has_prefix(req->str, "POST ")) is_post = 1;
const char *sp1 = strchr(req->str, ' ');
if (sp1) {
const char *path = sp1 + 1;
const char *sp2 = strchr(path, ' ');
size_t plen = sp2 ? (size_t)(sp2 - path) : strlen(path);
const char *q = memchr(path, '?', plen);
size_t match_len = q ? (size_t)(q - path) : plen;
if (match_len >= 8 &&
g_ascii_strncasecmp(path + match_len - 8, "settings", 8) == 0)
is_settings = 1;
}
}
if (is_settings && is_get) {
gchar *json = http_build_settings_json(handle);
http_send(out, "200 OK", "application/json", json);
g_free(json);
} else if (is_settings && is_post) {
const char *body = req->str + header_end;
size_t body_len = req->len - header_end;
if (body_len > content_length) body_len = content_length;
int applied = http_apply_settings(handle, body, body_len);
syslog(LOG_INFO, "settings http: applied %d parameter(s)", applied);
if (reload_timer_id) g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
http_send(out, "200 OK", "text/plain", "OK");
} else {
http_send(out, "404 Not Found", "text/plain", "Not found");
}
g_string_free(req, TRUE);
g_io_stream_close(G_IO_STREAM(connection), NULL, NULL);
return TRUE;
}
static void http_server_start(AXParameter *handle) {
GError *err = NULL;
GSocketService *service = g_socket_service_new();
GInetAddress *addr = g_inet_address_new_from_string("127.0.0.1");
GSocketAddress *saddr = g_inet_socket_address_new(addr, HTTP_PORT);
if (!g_socket_listener_add_address(G_SOCKET_LISTENER(service), saddr,
G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP,
NULL, NULL, &err)) {
syslog(LOG_WARNING, "settings http: bind 127.0.0.1:%d failed: %s",
HTTP_PORT, err ? err->message : "unknown");
if (err) g_error_free(err);
g_object_unref(service);
} else {
g_signal_connect(service, "incoming", G_CALLBACK(http_on_incoming), handle);
g_socket_service_start(service);
syslog(LOG_INFO, "settings http server listening on 127.0.0.1:%d", HTTP_PORT);
}
g_object_unref(addr);
g_object_unref(saddr);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
@@ -185,6 +461,10 @@ int main(void) {
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
* freshly configured key before it has been used. */
unlink(SENTINEL_FILE);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
@@ -194,11 +474,13 @@ int main(void) {
}
g_ax_handle = handle;
ensure_param(handle, "AdvertiseRoutes", "");
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes" };
const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes", "AdvertiseRoutes" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
@@ -208,10 +490,13 @@ int main(void) {
}
}
http_server_start(handle);
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
+1 -1
View File
@@ -1,6 +1,6 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
PKGS = axparameter glib-2.0 gio-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
+68 -3
View File
@@ -20,6 +20,7 @@ CONF_HTTP="8080"
CONF_SOCKS="1080"
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
ADVERTISE_ROUTES=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
@@ -76,11 +77,75 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In userspace-networking mode the
# tailscaled netstack forwards tailnet traffic to these subnets, so no kernel IP
# forwarding is required. Routes must still be approved in the Tailscale admin
# console before peers can use them.
if [ -n "$ADVERTISE_ROUTES" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
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" "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
+170 -51
View File
@@ -462,6 +462,11 @@
<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-row">
<label class="settings-label" for="input-advertise-routes">Advertise Routes (Subnet Router)</label>
<input class="settings-input" id="input-advertise-routes" type="text" autocomplete="off" placeholder="192.168.1.0/24,10.0.0.0/8 (leave blank to disable)">
<span class="settings-hint">Comma-separated CIDRs this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
@@ -490,6 +495,7 @@
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var STATUS_URL = 'status.json';
var logBox = document.getElementById('log-box');
var autoScroll = true;
@@ -737,29 +743,82 @@
.catch(function() { return false; });
}
// Ground truth published by the run script from `tailscale status --json`.
function fetchStatus() {
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.catch(function() { return null; });
}
// Apply Tailscale's authoritative backend state onto the result object.
function applyStatus(result, st) {
var self = st.Self || {};
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
var ip4 = null;
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
var bs = st.BackendState;
if (st.Version) result.version = String(st.Version).split('-')[0];
if (bs === 'Running' && self.Online === true) {
// Genuinely connected and reachable on the tailnet
result.state = 'connected';
result.url = null;
result.ip = ip4 || result.ip;
result.node = self.HostName || result.node;
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
cacheSet('ip', result.ip); cacheSet('node', result.node);
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Running') {
// Backend running but node not online: either a transient network
// drop (no action needed) or the node was removed/expired and needs
// re-auth. Not connected. Keep any login URL the log parser found
// (status.json's AuthURL lags during the `tailscale up` re-auth
// window) so the login button still appears when re-auth is needed.
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Stopped') {
result.state = 'disconnected';
result.url = null;
} else {
// NoState / Starting / unknown
result.state = 'connecting';
result.url = st.AuthURL || result.url;
}
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
Promise.all([
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.catch(function() { return ''; }),
fetchStatus()
]).then(function(arr) {
var txt = arr[0];
var st = arr[1];
var result = parse(txt || '');
if (txt) renderLogs(txt);
// Verify the app is actually running - status.json can be stale if stopped
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (st && st.BackendState) {
// Authoritative: Tailscale's own backend state
applyStatus(result, st);
} else if (!result.url && result.state !== 'connected') {
// Fallback to log heuristic when status.json is unavailable
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
});
}
refresh();
@@ -818,32 +877,64 @@
var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var advertiseRoutesInput = document.getElementById('input-advertise-routes');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
// param.cgi is used when available; on devices that lack it (e.g. some
// recorder/NVR-class devices) we fall back to the app's own endpoint,
// exposed through the manifest reverseProxy mapping at API_URL.
var API_URL = '/local/' + APP + '/api/settings';
function updateProxyDisplay(httpPort, socksPort) {
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
}
function applyParamText(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
var avm = txt.match(/root\.\S+\.AdvertiseRoutes=(.*)/);
// If none of the expected keys are present the endpoint isn't param.cgi
// (e.g. a generic 404 page); signal the caller to use the fallback.
if (!sm && !hm && !km) return false;
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[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';
if (avm) advertiseRoutesInput.value = avm[1].trim();
updateProxyDisplay(hm ? hm[1].trim() : null, km ? km[1].trim() : null);
return true;
}
function applyJson(obj) {
if (typeof obj.CustomServer === 'string') serverInput.value = obj.CustomServer;
if (typeof obj.AuthKey === 'string') authInput.value = obj.AuthKey;
if (typeof obj.HttpProxyPort === 'string') httpPortInput.value = obj.HttpProxyPort;
if (typeof obj.Socks5Port === 'string') socksPortInput.value = obj.Socks5Port;
if (typeof obj.AcceptDNS === 'string') acceptDnsInput.checked = obj.AcceptDNS === 'true';
if (typeof obj.AcceptRoutes === 'string') acceptRoutesInput.checked = obj.AcceptRoutes === 'true';
if (typeof obj.AdvertiseRoutes === 'string') advertiseRoutesInput.value = obj.AdvertiseRoutes;
updateProxyDisplay(obj.HttpProxyPort, obj.Socks5Port);
}
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
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 (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[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
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) { if (!applyParamText(txt)) return Promise.reject(); })
.catch(function() { loadSettingsFallback(); });
}
function loadSettingsFallback() {
fetch(API_URL + '?t=' + Date.now(), { credentials: 'same-origin', cache: 'no-store' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(obj) { if (obj) applyJson(obj); })
.catch(function() {});
}
@@ -853,6 +944,32 @@
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
function saveViaFallback(httpPort, socksPort) {
var body = 'CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&HttpProxyPort=' + encodeURIComponent(httpPort) +
'&Socks5Port=' + encodeURIComponent(socksPort) +
'&AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
return fetch(API_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
if (/OK/.test(txt)) {
// The app applies the change and restarts its tunnel itself,
// so no separate control.cgi restart is needed here.
setStatus('Saved. Restarting...', 'ok');
} else {
setStatus('Error saving settings', 'err');
}
});
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
@@ -864,29 +981,31 @@
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
// param.cgi reachable but rejected the update — surface the error.
setStatus('Error: ' + txt.trim(), 'err');
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
.catch(function() {
// param.cgi unavailable (e.g. recorder-class device) — use the fallback.
return saveViaFallback(httpPort, socksPort);
})
.then(function() { saveBtn.disabled = false; })
.catch(function() { saveBtn.disabled = false; setStatus('Failed to save', 'err'); });
});
loadSettings();
Binary file not shown.
Binary file not shown.
+13 -1
View File
@@ -8,11 +8,18 @@
"embeddedSdkVersion": "3.0",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.98.4",
"version": "1.98.8",
"architecture": "armv7hf"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
@@ -43,6 +50,11 @@
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
+295 -5
View File
@@ -18,6 +18,7 @@
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <gio/gio.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
@@ -31,13 +32,14 @@
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static AXParameter *g_ax_handle = NULL;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
@@ -45,6 +47,7 @@ static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static char *cfg_advertise_routes = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
@@ -56,6 +59,17 @@ static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
* upgrades a newly introduced manifest parameter is not always auto-registered,
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
GError *err = NULL;
if (!ax_parameter_add(handle, name, def, "string", &err)) {
if (err) g_error_free(err);
}
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
@@ -120,6 +134,31 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
return G_SOURCE_CONTINUE;
}
/* ── auth-key sentinel ───────────────────────────────────────────────────── */
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
* disappears from the settings UI. This replaces the old exit-code-0 path,
* which never fired because tailscaled keeps the child alive indefinitely. */
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
if (access(SENTINEL_FILE, F_OK) != 0)
return G_SOURCE_CONTINUE;
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
unlink(SENTINEL_FILE);
return G_SOURCE_CONTINUE;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
@@ -143,6 +182,7 @@ static void load_config_cache(AXParameter *handle) {
LOAD("Socks5Port", cfg_socks5_port)
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
LOAD("AdvertiseRoutes", cfg_advertise_routes)
#undef LOAD
}
@@ -159,6 +199,7 @@ static void write_config_file(void) {
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"));
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
@@ -193,12 +234,252 @@ static void parameter_changed(const gchar *name, const gchar *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);
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
* settings through it. This tiny HTTP server, reached through the manifest
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
* fall back to reading and writing the parameters directly. */
#define HTTP_PORT 2201
static const char *http_param_names[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
static void cache_set_by_name(const char *name, const char *value) {
if (strcmp(name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
else if (strcmp(name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
}
static int http_is_known_param(const char *name) {
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++)
if (strcmp(name, http_param_names[i]) == 0) return 1;
return 0;
}
static void http_json_append_escaped(GString *out, const char *s) {
for (const char *p = s; *p; p++) {
switch (*p) {
case '"': g_string_append(out, "\\\""); break;
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
case '\t': g_string_append(out, "\\t"); break;
default:
if ((unsigned char)*p < 0x20)
g_string_append_printf(out, "\\u%04x", (unsigned char)*p);
else
g_string_append_c(out, *p);
}
}
}
static gchar *http_build_settings_json(AXParameter *handle) {
GString *out = g_string_new("{");
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++) {
gchar *val = NULL;
GError *err = NULL;
if (!ax_parameter_get(handle, http_param_names[i], &val, &err)) {
if (err) g_error_free(err);
val = g_strdup("");
}
if (i) g_string_append_c(out, ',');
g_string_append_printf(out, "\"%s\":\"", http_param_names[i]);
http_json_append_escaped(out, val ? val : "");
g_string_append_c(out, '"');
g_free(val);
}
g_string_append_c(out, '}');
return g_string_free(out, FALSE);
}
static gchar *http_url_decode(const char *s, size_t len) {
GString *out = g_string_new(NULL);
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (c == '+') {
g_string_append_c(out, ' ');
} else if (c == '%' && i + 2 < len &&
g_ascii_isxdigit(s[i + 1]) && g_ascii_isxdigit(s[i + 2])) {
int hi = g_ascii_xdigit_value(s[i + 1]);
int lo = g_ascii_xdigit_value(s[i + 2]);
g_string_append_c(out, (char)((hi << 4) | lo));
i += 2;
} else {
g_string_append_c(out, c);
}
}
return g_string_free(out, FALSE);
}
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
* the parameter store. Returns the number of parameters successfully set. */
static int http_apply_settings(AXParameter *handle, const char *body, size_t len) {
int applied = 0;
size_t start = 0;
for (size_t i = 0; i <= len; i++) {
if (i == len || body[i] == '&') {
size_t seg_len = i - start;
if (seg_len > 0) {
const char *seg = body + start;
const char *eq = memchr(seg, '=', seg_len);
if (eq) {
size_t nlen = (size_t)(eq - seg);
gchar *name = g_strndup(seg, nlen);
gchar *value = http_url_decode(eq + 1, seg_len - nlen - 1);
if (http_is_known_param(name)) {
GError *err = NULL;
if (ax_parameter_set(handle, name, value, TRUE, &err)) {
cache_set_by_name(name, value);
applied++;
} else {
syslog(LOG_WARNING, "http set %s failed: %s",
name, err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
g_free(name);
g_free(value);
}
}
start = i + 1;
}
}
return applied;
}
static size_t http_parse_content_length(const char *hdr, size_t hlen) {
const char *key = "content-length:";
size_t klen = strlen(key);
for (size_t i = 0; i + klen <= hlen; i++) {
if (g_ascii_strncasecmp(hdr + i, key, klen) == 0) {
i += klen;
while (i < hlen && (hdr[i] == ' ' || hdr[i] == '\t')) i++;
return (size_t)strtoul(hdr + i, NULL, 10);
}
}
return 0;
}
static void http_send(GOutputStream *out, const char *status,
const char *ctype, const char *body) {
gchar *resp = g_strdup_printf(
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
status, ctype, strlen(body), body);
g_output_stream_write_all(out, resp, strlen(resp), NULL, NULL, NULL);
g_free(resp);
}
static gboolean http_on_incoming(GSocketService *service G_GNUC_UNUSED,
GSocketConnection *connection,
GObject *source G_GNUC_UNUSED,
gpointer user_data) {
AXParameter *handle = (AXParameter *)user_data;
GInputStream *in = g_io_stream_get_input_stream(G_IO_STREAM(connection));
GOutputStream *out = g_io_stream_get_output_stream(G_IO_STREAM(connection));
GString *req = g_string_new(NULL);
char buf[2048];
int have_headers = 0;
size_t header_end = 0;
size_t content_length = 0;
while (1) {
gssize n = g_input_stream_read(in, buf, sizeof(buf), NULL, NULL);
if (n <= 0) break;
g_string_append_len(req, buf, n);
if (!have_headers) {
char *p = g_strstr_len(req->str, req->len, "\r\n\r\n");
if (p) {
have_headers = 1;
header_end = (size_t)(p - req->str) + 4;
content_length = http_parse_content_length(req->str, header_end);
}
}
if (have_headers && req->len - header_end >= content_length) break;
if (req->len > 262144) break; /* safety cap */
}
int is_get = 0, is_post = 0, is_settings = 0;
if (have_headers) {
if (g_str_has_prefix(req->str, "GET ")) is_get = 1;
if (g_str_has_prefix(req->str, "POST ")) is_post = 1;
const char *sp1 = strchr(req->str, ' ');
if (sp1) {
const char *path = sp1 + 1;
const char *sp2 = strchr(path, ' ');
size_t plen = sp2 ? (size_t)(sp2 - path) : strlen(path);
const char *q = memchr(path, '?', plen);
size_t match_len = q ? (size_t)(q - path) : plen;
if (match_len >= 8 &&
g_ascii_strncasecmp(path + match_len - 8, "settings", 8) == 0)
is_settings = 1;
}
}
if (is_settings && is_get) {
gchar *json = http_build_settings_json(handle);
http_send(out, "200 OK", "application/json", json);
g_free(json);
} else if (is_settings && is_post) {
const char *body = req->str + header_end;
size_t body_len = req->len - header_end;
if (body_len > content_length) body_len = content_length;
int applied = http_apply_settings(handle, body, body_len);
syslog(LOG_INFO, "settings http: applied %d parameter(s)", applied);
if (reload_timer_id) g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
http_send(out, "200 OK", "text/plain", "OK");
} else {
http_send(out, "404 Not Found", "text/plain", "Not found");
}
g_string_free(req, TRUE);
g_io_stream_close(G_IO_STREAM(connection), NULL, NULL);
return TRUE;
}
static void http_server_start(AXParameter *handle) {
GError *err = NULL;
GSocketService *service = g_socket_service_new();
GInetAddress *addr = g_inet_address_new_from_string("127.0.0.1");
GSocketAddress *saddr = g_inet_socket_address_new(addr, HTTP_PORT);
if (!g_socket_listener_add_address(G_SOCKET_LISTENER(service), saddr,
G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP,
NULL, NULL, &err)) {
syslog(LOG_WARNING, "settings http: bind 127.0.0.1:%d failed: %s",
HTTP_PORT, err ? err->message : "unknown");
if (err) g_error_free(err);
g_object_unref(service);
} else {
g_signal_connect(service, "incoming", G_CALLBACK(http_on_incoming), handle);
g_socket_service_start(service);
syslog(LOG_INFO, "settings http server listening on 127.0.0.1:%d", HTTP_PORT);
}
g_object_unref(addr);
g_object_unref(saddr);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
@@ -219,6 +500,10 @@ int main(void) {
/* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
* freshly configured key before it has been used. */
unlink(SENTINEL_FILE);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
@@ -228,13 +513,15 @@ int main(void) {
}
g_ax_handle = handle;
ensure_param(handle, "AdvertiseRoutes", "");
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"AcceptDNS", "AcceptRoutes"
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
@@ -245,10 +532,13 @@ int main(void) {
}
}
http_server_start(handle);
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
+1 -1
View File
@@ -1,6 +1,6 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
PKGS = axparameter glib-2.0 gio-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
+68 -3
View File
@@ -17,6 +17,7 @@ CUSTOM_SERVER=""
AUTH_KEY=""
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
ADVERTISE_ROUTES=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
@@ -50,9 +51,73 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In kernel-networking (root) mode the
# host must forward packets between the tailnet and the LAN, so enable IP
# forwarding. Routes must still be approved in the Tailscale admin console.
if [ -n "$ADVERTISE_ROUTES" ]; then
echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
echo 1 > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
fi
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
# 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
+170 -51
View File
@@ -462,6 +462,11 @@
<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-row">
<label class="settings-label" for="input-advertise-routes">Advertise Routes (Subnet Router)</label>
<input class="settings-input" id="input-advertise-routes" type="text" autocomplete="off" placeholder="192.168.1.0/24,10.0.0.0/8 (leave blank to disable)">
<span class="settings-hint">Comma-separated CIDRs this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
@@ -490,6 +495,7 @@
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var STATUS_URL = 'status.json';
var logBox = document.getElementById('log-box');
var autoScroll = true;
@@ -737,29 +743,82 @@
.catch(function() { return false; });
}
// Ground truth published by the run script from `tailscale status --json`.
function fetchStatus() {
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.catch(function() { return null; });
}
// Apply Tailscale's authoritative backend state onto the result object.
function applyStatus(result, st) {
var self = st.Self || {};
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
var ip4 = null;
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
var bs = st.BackendState;
if (st.Version) result.version = String(st.Version).split('-')[0];
if (bs === 'Running' && self.Online === true) {
// Genuinely connected and reachable on the tailnet
result.state = 'connected';
result.url = null;
result.ip = ip4 || result.ip;
result.node = self.HostName || result.node;
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
cacheSet('ip', result.ip); cacheSet('node', result.node);
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Running') {
// Backend running but node not online: either a transient network
// drop (no action needed) or the node was removed/expired and needs
// re-auth. Not connected. Keep any login URL the log parser found
// (status.json's AuthURL lags during the `tailscale up` re-auth
// window) so the login button still appears when re-auth is needed.
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Stopped') {
result.state = 'disconnected';
result.url = null;
} else {
// NoState / Starting / unknown
result.state = 'connecting';
result.url = st.AuthURL || result.url;
}
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
Promise.all([
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.catch(function() { return ''; }),
fetchStatus()
]).then(function(arr) {
var txt = arr[0];
var st = arr[1];
var result = parse(txt || '');
if (txt) renderLogs(txt);
// Verify the app is actually running - status.json can be stale if stopped
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (st && st.BackendState) {
// Authoritative: Tailscale's own backend state
applyStatus(result, st);
} else if (!result.url && result.state !== 'connected') {
// Fallback to log heuristic when status.json is unavailable
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
});
}
refresh();
@@ -818,32 +877,64 @@
var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var advertiseRoutesInput = document.getElementById('input-advertise-routes');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
// param.cgi is used when available; on devices that lack it (e.g. some
// recorder/NVR-class devices) we fall back to the app's own endpoint,
// exposed through the manifest reverseProxy mapping at API_URL.
var API_URL = '/local/' + APP + '/api/settings';
function updateProxyDisplay(httpPort, socksPort) {
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
}
function applyParamText(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
var avm = txt.match(/root\.\S+\.AdvertiseRoutes=(.*)/);
// If none of the expected keys are present the endpoint isn't param.cgi
// (e.g. a generic 404 page); signal the caller to use the fallback.
if (!sm && !hm && !km) return false;
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[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';
if (avm) advertiseRoutesInput.value = avm[1].trim();
updateProxyDisplay(hm ? hm[1].trim() : null, km ? km[1].trim() : null);
return true;
}
function applyJson(obj) {
if (typeof obj.CustomServer === 'string') serverInput.value = obj.CustomServer;
if (typeof obj.AuthKey === 'string') authInput.value = obj.AuthKey;
if (typeof obj.HttpProxyPort === 'string') httpPortInput.value = obj.HttpProxyPort;
if (typeof obj.Socks5Port === 'string') socksPortInput.value = obj.Socks5Port;
if (typeof obj.AcceptDNS === 'string') acceptDnsInput.checked = obj.AcceptDNS === 'true';
if (typeof obj.AcceptRoutes === 'string') acceptRoutesInput.checked = obj.AcceptRoutes === 'true';
if (typeof obj.AdvertiseRoutes === 'string') advertiseRoutesInput.value = obj.AdvertiseRoutes;
updateProxyDisplay(obj.HttpProxyPort, obj.Socks5Port);
}
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
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 (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[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
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) { if (!applyParamText(txt)) return Promise.reject(); })
.catch(function() { loadSettingsFallback(); });
}
function loadSettingsFallback() {
fetch(API_URL + '?t=' + Date.now(), { credentials: 'same-origin', cache: 'no-store' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(obj) { if (obj) applyJson(obj); })
.catch(function() {});
}
@@ -853,6 +944,32 @@
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
function saveViaFallback(httpPort, socksPort) {
var body = 'CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&HttpProxyPort=' + encodeURIComponent(httpPort) +
'&Socks5Port=' + encodeURIComponent(socksPort) +
'&AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
return fetch(API_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
if (/OK/.test(txt)) {
// The app applies the change and restarts its tunnel itself,
// so no separate control.cgi restart is needed here.
setStatus('Saved. Restarting...', 'ok');
} else {
setStatus('Error saving settings', 'err');
}
});
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
@@ -864,29 +981,31 @@
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
// param.cgi reachable but rejected the update — surface the error.
setStatus('Error: ' + txt.trim(), 'err');
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
.catch(function() {
// param.cgi unavailable (e.g. recorder-class device) — use the fallback.
return saveViaFallback(httpPort, socksPort);
})
.then(function() { saveBtn.disabled = false; })
.catch(function() { saveBtn.disabled = false; setStatus('Failed to save', 'err'); });
});
loadSettings();
Binary file not shown.
Binary file not shown.
+13 -1
View File
@@ -12,11 +12,18 @@
},
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.98.4",
"version": "1.98.8",
"architecture": "armv7hf"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
@@ -37,6 +44,11 @@
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
+290 -5
View File
@@ -8,6 +8,7 @@
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <gio/gio.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
@@ -21,18 +22,20 @@
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static AXParameter *g_ax_handle = NULL;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static char *cfg_advertise_routes = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
@@ -44,6 +47,17 @@ static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
* upgrades a newly introduced manifest parameter is not always auto-registered,
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
GError *err = NULL;
if (!ax_parameter_add(handle, name, def, "string", &err)) {
if (err) g_error_free(err);
}
}
static void stop_child(void) {
if (child_pid <= 0)
return;
@@ -104,6 +118,29 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
return G_SOURCE_CONTINUE;
}
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
* disappears from the settings UI. This replaces the old exit-code-0 path,
* which never fired because tailscaled keeps the child alive indefinitely. */
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
if (access(SENTINEL_FILE, F_OK) != 0)
return G_SOURCE_CONTINUE;
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
unlink(SENTINEL_FILE);
return G_SOURCE_CONTINUE;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
@@ -123,6 +160,7 @@ static void load_config_cache(AXParameter *handle) {
LOAD("AuthKey", cfg_auth_key)
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
LOAD("AdvertiseRoutes", cfg_advertise_routes)
#undef LOAD
}
@@ -137,6 +175,7 @@ static void write_config_file(void) {
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"));
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
@@ -164,12 +203,249 @@ 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, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
* settings through it. This tiny HTTP server, reached through the manifest
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
* fall back to reading and writing the parameters directly. */
#define HTTP_PORT 2201
static const char *http_param_names[] = {
"CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
static void cache_set_by_name(const char *name, const char *value) {
if (strcmp(name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
}
static int http_is_known_param(const char *name) {
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++)
if (strcmp(name, http_param_names[i]) == 0) return 1;
return 0;
}
static void http_json_append_escaped(GString *out, const char *s) {
for (const char *p = s; *p; p++) {
switch (*p) {
case '"': g_string_append(out, "\\\""); break;
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
case '\t': g_string_append(out, "\\t"); break;
default:
if ((unsigned char)*p < 0x20)
g_string_append_printf(out, "\\u%04x", (unsigned char)*p);
else
g_string_append_c(out, *p);
}
}
}
static gchar *http_build_settings_json(AXParameter *handle) {
GString *out = g_string_new("{");
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++) {
gchar *val = NULL;
GError *err = NULL;
if (!ax_parameter_get(handle, http_param_names[i], &val, &err)) {
if (err) g_error_free(err);
val = g_strdup("");
}
if (i) g_string_append_c(out, ',');
g_string_append_printf(out, "\"%s\":\"", http_param_names[i]);
http_json_append_escaped(out, val ? val : "");
g_string_append_c(out, '"');
g_free(val);
}
g_string_append_c(out, '}');
return g_string_free(out, FALSE);
}
static gchar *http_url_decode(const char *s, size_t len) {
GString *out = g_string_new(NULL);
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (c == '+') {
g_string_append_c(out, ' ');
} else if (c == '%' && i + 2 < len &&
g_ascii_isxdigit(s[i + 1]) && g_ascii_isxdigit(s[i + 2])) {
int hi = g_ascii_xdigit_value(s[i + 1]);
int lo = g_ascii_xdigit_value(s[i + 2]);
g_string_append_c(out, (char)((hi << 4) | lo));
i += 2;
} else {
g_string_append_c(out, c);
}
}
return g_string_free(out, FALSE);
}
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
* the parameter store. Returns the number of parameters successfully set. */
static int http_apply_settings(AXParameter *handle, const char *body, size_t len) {
int applied = 0;
size_t start = 0;
for (size_t i = 0; i <= len; i++) {
if (i == len || body[i] == '&') {
size_t seg_len = i - start;
if (seg_len > 0) {
const char *seg = body + start;
const char *eq = memchr(seg, '=', seg_len);
if (eq) {
size_t nlen = (size_t)(eq - seg);
gchar *name = g_strndup(seg, nlen);
gchar *value = http_url_decode(eq + 1, seg_len - nlen - 1);
if (http_is_known_param(name)) {
GError *err = NULL;
if (ax_parameter_set(handle, name, value, TRUE, &err)) {
cache_set_by_name(name, value);
applied++;
} else {
syslog(LOG_WARNING, "http set %s failed: %s",
name, err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
g_free(name);
g_free(value);
}
}
start = i + 1;
}
}
return applied;
}
static size_t http_parse_content_length(const char *hdr, size_t hlen) {
const char *key = "content-length:";
size_t klen = strlen(key);
for (size_t i = 0; i + klen <= hlen; i++) {
if (g_ascii_strncasecmp(hdr + i, key, klen) == 0) {
i += klen;
while (i < hlen && (hdr[i] == ' ' || hdr[i] == '\t')) i++;
return (size_t)strtoul(hdr + i, NULL, 10);
}
}
return 0;
}
static void http_send(GOutputStream *out, const char *status,
const char *ctype, const char *body) {
gchar *resp = g_strdup_printf(
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
status, ctype, strlen(body), body);
g_output_stream_write_all(out, resp, strlen(resp), NULL, NULL, NULL);
g_free(resp);
}
static gboolean http_on_incoming(GSocketService *service G_GNUC_UNUSED,
GSocketConnection *connection,
GObject *source G_GNUC_UNUSED,
gpointer user_data) {
AXParameter *handle = (AXParameter *)user_data;
GInputStream *in = g_io_stream_get_input_stream(G_IO_STREAM(connection));
GOutputStream *out = g_io_stream_get_output_stream(G_IO_STREAM(connection));
GString *req = g_string_new(NULL);
char buf[2048];
int have_headers = 0;
size_t header_end = 0;
size_t content_length = 0;
while (1) {
gssize n = g_input_stream_read(in, buf, sizeof(buf), NULL, NULL);
if (n <= 0) break;
g_string_append_len(req, buf, n);
if (!have_headers) {
char *p = g_strstr_len(req->str, req->len, "\r\n\r\n");
if (p) {
have_headers = 1;
header_end = (size_t)(p - req->str) + 4;
content_length = http_parse_content_length(req->str, header_end);
}
}
if (have_headers && req->len - header_end >= content_length) break;
if (req->len > 262144) break; /* safety cap */
}
int is_get = 0, is_post = 0, is_settings = 0;
if (have_headers) {
if (g_str_has_prefix(req->str, "GET ")) is_get = 1;
if (g_str_has_prefix(req->str, "POST ")) is_post = 1;
const char *sp1 = strchr(req->str, ' ');
if (sp1) {
const char *path = sp1 + 1;
const char *sp2 = strchr(path, ' ');
size_t plen = sp2 ? (size_t)(sp2 - path) : strlen(path);
const char *q = memchr(path, '?', plen);
size_t match_len = q ? (size_t)(q - path) : plen;
if (match_len >= 8 &&
g_ascii_strncasecmp(path + match_len - 8, "settings", 8) == 0)
is_settings = 1;
}
}
if (is_settings && is_get) {
gchar *json = http_build_settings_json(handle);
http_send(out, "200 OK", "application/json", json);
g_free(json);
} else if (is_settings && is_post) {
const char *body = req->str + header_end;
size_t body_len = req->len - header_end;
if (body_len > content_length) body_len = content_length;
int applied = http_apply_settings(handle, body, body_len);
syslog(LOG_INFO, "settings http: applied %d parameter(s)", applied);
if (reload_timer_id) g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
http_send(out, "200 OK", "text/plain", "OK");
} else {
http_send(out, "404 Not Found", "text/plain", "Not found");
}
g_string_free(req, TRUE);
g_io_stream_close(G_IO_STREAM(connection), NULL, NULL);
return TRUE;
}
static void http_server_start(AXParameter *handle) {
GError *err = NULL;
GSocketService *service = g_socket_service_new();
GInetAddress *addr = g_inet_address_new_from_string("127.0.0.1");
GSocketAddress *saddr = g_inet_socket_address_new(addr, HTTP_PORT);
if (!g_socket_listener_add_address(G_SOCKET_LISTENER(service), saddr,
G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP,
NULL, NULL, &err)) {
syslog(LOG_WARNING, "settings http: bind 127.0.0.1:%d failed: %s",
HTTP_PORT, err ? err->message : "unknown");
if (err) g_error_free(err);
g_object_unref(service);
} else {
g_signal_connect(service, "incoming", G_CALLBACK(http_on_incoming), handle);
g_socket_service_start(service);
syslog(LOG_INFO, "settings http server listening on 127.0.0.1:%d", HTTP_PORT);
}
g_object_unref(addr);
g_object_unref(saddr);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
@@ -185,6 +461,10 @@ int main(void) {
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
* freshly configured key before it has been used. */
unlink(SENTINEL_FILE);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
@@ -194,11 +474,13 @@ int main(void) {
}
g_ax_handle = handle;
ensure_param(handle, "AdvertiseRoutes", "");
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes" };
const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes", "AdvertiseRoutes" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
@@ -208,10 +490,13 @@ int main(void) {
}
}
http_server_start(handle);
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
+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).
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log
# Symlink the runtime status.json (written by start.sh from `tailscale status
# --json`) into html/ so the web UI can read Tailscale's authoritative state.
RUN ln -sf ../localdata/status.json html/status.json
# Build and package
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
+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" "SOCKS5 proxy: 127.0.0.1:1055"
# Monitoring loop: stay alive while tailscaled is running.
# This keeps the parent Tailscale_VPN (C launcher) in the process table
# so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
# Publish tailscale's real backend state as JSON for the web UI to consume.
# This is the authoritative connection signal (BackendState / Self.Online /
# TailscaleIPs / AuthURL) instead of scraping logs, which otherwise reports
# "connected" whenever the launcher keeps the process alive (e.g. no Internet).
# Written to localdata and exposed at html/status.json via a build-time symlink.
STATUS_FILE="$STATE_DIR/status.json"
publish_status() {
if "$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
chmod 644 "$STATUS_FILE" 2>/dev/null
else
rm -f "$STATUS_FILE.tmp" 2>/dev/null
fi
}
# Remove stale status on stop so the UI does not show a connected node after exit.
cleanup() {
rm -f "$STATUS_FILE" 2>/dev/null
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
exit 0
}
trap cleanup TERM INT
# Monitoring loop: stay alive while tailscaled is running and keep the published
# status fresh. This keeps the parent Tailscale_VPN (C launcher) in the process
# table so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
while kill -0 "$TAILSCALED_PID" 2>/dev/null; do
publish_status
sleep 5
done
rm -f "$STATUS_FILE" 2>/dev/null
logger -t "Tailscale_VPN" "tailscaled exited"
+54 -2
View File
@@ -612,6 +612,53 @@
// ACAP3: also fetch the raw tailscaled.log (symlinked into html/) so the parser
// can find IP, version, tailnet and Running state from tailscaled's own output.
var DAEMON_LOG_URL = 'tailscaled.log';
// Authoritative backend state published by start.sh (symlinked into html/).
var STATUS_URL = 'status.json';
// Ground truth published by start.sh from `tailscale status --json`.
function fetchStatus() {
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.catch(function() { return null; });
}
// Apply Tailscale's authoritative backend state onto the result object.
function applyStatus(result, st) {
var self = st.Self || {};
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
var ip4 = null;
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
var bs = st.BackendState;
if (st.Version) result.version = String(st.Version).split('-')[0];
if (bs === 'Running' && self.Online === true) {
// Genuinely connected and reachable on the tailnet
result.state = 'connected';
result.url = null;
result.ip = ip4 || result.ip;
result.node = self.HostName || result.node;
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
cacheSet('ip', result.ip); cacheSet('node', result.node);
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Running') {
// Backend running but node not online: transient network drop or the
// node was removed/expired and needs re-auth. Not connected. Keep any
// login URL the log parser found so the login button still appears.
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Stopped') {
result.state = 'disconnected';
result.url = null;
} else {
// NoState / Starting / unknown
result.state = 'connecting';
result.url = st.AuthURL || result.url;
}
}
function refresh() {
var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
@@ -619,17 +666,22 @@
var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); }).catch(function() { return ''; });
Promise.all([syslogFetch, daemonFetch]).then(function(res) {
Promise.all([syslogFetch, daemonFetch, fetchStatus()]).then(function(res) {
// Syslog provides Axis timestamp headers (node name) and start/stop events.
// tailscaled.log provides IP, version, tailnet, and -> Running state.
var txt = res[0] + '\n' + res[1];
var st = res[2];
var result = parse(txt);
renderLogs(res[0]); // show syslog in log panel; daemon log is too verbose
// Always verify with the app status API - syslog can have stale entries
// Always verify with the app status API - logs can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (st && st.BackendState) {
// Authoritative: Tailscale's own backend state
applyStatus(result, st);
} else if (!result.url && result.state !== 'connected') {
// Fallback to log heuristic when status.json is unavailable
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
+1 -1
View File
@@ -3,7 +3,7 @@ MENUNAME="Tailscale VPN"
VENDOR="Mo3he"
APPMAJORVERSION=1
APPMINORVERSION=98
APPMICROVERSION=4
APPMICROVERSION=8
APPTYPE=armv7hf
APPNAME=Tailscale_VPN
APPOPTS=""