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);