diff --git a/aarch64/app/Makefile b/aarch64/app/Makefile
index 60ca613..e6413f2 100644
--- a/aarch64/app/Makefile
+++ b/aarch64/app/Makefile
@@ -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))
diff --git a/aarch64/app/Tailscale_VPN_run b/aarch64/app/Tailscale_VPN_run
index 0f1592a..e591f27 100644
--- a/aarch64/app/Tailscale_VPN_run
+++ b/aarch64/app/Tailscale_VPN_run
@@ -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,6 +77,15 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
+# 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
diff --git a/aarch64/app/html/index.html b/aarch64/app/html/index.html
index 419b235..6a7c93e 100644
--- a/aarch64/app/html/index.html
+++ b/aarch64/app/html/index.html
@@ -462,6 +462,11 @@
Pass --accept-routes=true to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.
+
+
+
+ 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.
+
@@ -872,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() {});
}
@@ -907,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...', '');
@@ -918,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();
diff --git a/aarch64/app/lib/tailscale b/aarch64/app/lib/tailscale
index b8c67b9..0f3f29f 100755
Binary files a/aarch64/app/lib/tailscale and b/aarch64/app/lib/tailscale differ
diff --git a/aarch64/app/lib/tailscaled b/aarch64/app/lib/tailscaled
index 7691b72..2d01cc6 100755
Binary files a/aarch64/app/lib/tailscaled and b/aarch64/app/lib/tailscaled differ
diff --git a/aarch64/app/manifest.json b/aarch64/app/manifest.json
index c08dd48..a3f8963 100644
--- a/aarch64/app/manifest.json
+++ b/aarch64/app/manifest.json
@@ -13,6 +13,13 @@
},
"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"
}
]
}
diff --git a/aarch64/app/param_bridge.c b/aarch64/app/param_bridge.c
index dbd98aa..6745123 100644
--- a/aarch64/app/param_bridge.c
+++ b/aarch64/app/param_bridge.c
@@ -18,6 +18,7 @@
#include
#include
+#include
#include
#include
#include
@@ -46,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;
@@ -57,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) {
@@ -169,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
}
@@ -185,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",
@@ -219,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) {
@@ -258,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],
@@ -275,6 +532,8 @@ 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);
diff --git a/aarch64_ROOT/app/Makefile b/aarch64_ROOT/app/Makefile
index 60ca613..e6413f2 100644
--- a/aarch64_ROOT/app/Makefile
+++ b/aarch64_ROOT/app/Makefile
@@ -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))
diff --git a/aarch64_ROOT/app/Tailscale_VPN_run b/aarch64_ROOT/app/Tailscale_VPN_run
index 24c14aa..9627431 100644
--- a/aarch64_ROOT/app/Tailscale_VPN_run
+++ b/aarch64_ROOT/app/Tailscale_VPN_run
@@ -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,6 +51,16 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
+# 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
+
# 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
diff --git a/aarch64_ROOT/app/html/index.html b/aarch64_ROOT/app/html/index.html
index 419b235..6a7c93e 100644
--- a/aarch64_ROOT/app/html/index.html
+++ b/aarch64_ROOT/app/html/index.html
@@ -462,6 +462,11 @@
Pass --accept-routes=true to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.
+
+
+
+ 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.
+
@@ -872,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() {});
}
@@ -907,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...', '');
@@ -918,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();
diff --git a/aarch64_ROOT/app/manifest.json b/aarch64_ROOT/app/manifest.json
index 22d3412..f26c4c2 100644
--- a/aarch64_ROOT/app/manifest.json
+++ b/aarch64_ROOT/app/manifest.json
@@ -17,6 +17,13 @@
},
"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"
}
]
}
diff --git a/aarch64_ROOT/app/param_bridge.c b/aarch64_ROOT/app/param_bridge.c
index 522b3ac..95a7d85 100644
--- a/aarch64_ROOT/app/param_bridge.c
+++ b/aarch64_ROOT/app/param_bridge.c
@@ -8,6 +8,7 @@
#include
#include
+#include
#include
#include
#include
@@ -34,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;
@@ -45,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;
@@ -147,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
}
@@ -161,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",
@@ -188,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();
@@ -222,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)) {
@@ -236,6 +490,8 @@ 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);
diff --git a/arm/app/Makefile b/arm/app/Makefile
index 60ca613..e6413f2 100644
--- a/arm/app/Makefile
+++ b/arm/app/Makefile
@@ -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))
diff --git a/arm/app/Tailscale_VPN_run b/arm/app/Tailscale_VPN_run
index 0f1592a..e591f27 100644
--- a/arm/app/Tailscale_VPN_run
+++ b/arm/app/Tailscale_VPN_run
@@ -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,6 +77,15 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
+# 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
diff --git a/arm/app/html/index.html b/arm/app/html/index.html
index 419b235..6a7c93e 100644
--- a/arm/app/html/index.html
+++ b/arm/app/html/index.html
@@ -462,6 +462,11 @@
Pass --accept-routes=true to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.
+
+
+
+ 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.
+
@@ -872,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() {});
}
@@ -907,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...', '');
@@ -918,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();
diff --git a/arm/app/manifest.json b/arm/app/manifest.json
index 143bb03..3a9f7e3 100644
--- a/arm/app/manifest.json
+++ b/arm/app/manifest.json
@@ -13,6 +13,13 @@
},
"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"
}
]
}
diff --git a/arm/app/param_bridge.c b/arm/app/param_bridge.c
index dbd98aa..6745123 100644
--- a/arm/app/param_bridge.c
+++ b/arm/app/param_bridge.c
@@ -18,6 +18,7 @@
#include
#include
+#include
#include
#include
#include
@@ -46,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;
@@ -57,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) {
@@ -169,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
}
@@ -185,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",
@@ -219,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) {
@@ -258,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],
@@ -275,6 +532,8 @@ 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);
diff --git a/arm_ROOT/app/Makefile b/arm_ROOT/app/Makefile
index 60ca613..e6413f2 100644
--- a/arm_ROOT/app/Makefile
+++ b/arm_ROOT/app/Makefile
@@ -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))
diff --git a/arm_ROOT/app/Tailscale_VPN_run b/arm_ROOT/app/Tailscale_VPN_run
index 24c14aa..9627431 100644
--- a/arm_ROOT/app/Tailscale_VPN_run
+++ b/arm_ROOT/app/Tailscale_VPN_run
@@ -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,6 +51,16 @@ if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
+# 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
+
# 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
diff --git a/arm_ROOT/app/html/index.html b/arm_ROOT/app/html/index.html
index 419b235..6a7c93e 100644
--- a/arm_ROOT/app/html/index.html
+++ b/arm_ROOT/app/html/index.html
@@ -462,6 +462,11 @@
Pass --accept-routes=true to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.
+
+
+
+ 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.
+
@@ -872,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() {});
}
@@ -907,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...', '');
@@ -918,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();
diff --git a/arm_ROOT/app/manifest.json b/arm_ROOT/app/manifest.json
index b592c6b..5e8831e 100644
--- a/arm_ROOT/app/manifest.json
+++ b/arm_ROOT/app/manifest.json
@@ -17,6 +17,13 @@
},
"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"
}
]
}
diff --git a/arm_ROOT/app/param_bridge.c b/arm_ROOT/app/param_bridge.c
index 522b3ac..95a7d85 100644
--- a/arm_ROOT/app/param_bridge.c
+++ b/arm_ROOT/app/param_bridge.c
@@ -8,6 +8,7 @@
#include
#include
+#include
#include
#include
#include
@@ -34,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;
@@ -45,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;
@@ -147,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
}
@@ -161,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",
@@ -188,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();
@@ -222,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)) {
@@ -236,6 +490,8 @@ 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);