diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 499b0f4..29abb69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,8 +126,6 @@ jobs: # Detect variant suffix for .eap naming if [[ "$FOLDER_NAME" == *_ROOT ]]; then VARIANT="_root" - elif [[ "$FOLDER_NAME" == *_custom ]]; then - VARIANT="_custom" elif [[ "$FOLDER_NAME" == *_acap3 ]]; then VARIANT="_acap3" else @@ -152,7 +150,9 @@ jobs: # Extract .eap files into build folder EAP_OUTPUT="./build/${TAG_NAME}" mkdir -p "$EAP_OUTPUT" - docker cp $(docker create "$TAG_NAME"):/opt/app "$EAP_OUTPUT" + CID=$(docker create "$TAG_NAME") + docker cp "$CID":/opt/app "$EAP_OUTPUT" + docker rm "$CID" >/dev/null # Move all .eap files to releases folder, append variant if needed find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do diff --git a/README.md b/README.md index ff9675b..c195a17 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,9 @@ This repository provides an **ACAP package** that installs the [Tailscale VPN cl - [Installation](#installation) - [Usage](#usage) +- [Settings](#settings) - [Proxy Support](#proxy-support) - [Updating Tailscale](#updating-tailscale) -- [Testers Needed](#testers-needed) -- [Good News](#good-news) - [Purpose](#purpose) - [Useful Links](#useful-links) - [Compatibility](#compatibility) @@ -55,32 +54,48 @@ Once installed: ## Usage -- Runs a startup script to set permissions and launch Tailscale. -- View logs via the **Open** button in the app. -- Authenticate using the provided URL. +- Runs a C-based parameter bridge (compiled via ACAP SDK 1.15.1) that reads settings from the ACAP parameter store and launches Tailscale. +- View logs and connection status via the **Open** button in the app. +- Authenticate using the provided URL, or pre-enter an auth key in **Settings**. +- Change the **Custom Server URL** in Settings to use a self-hosted [Headscale](https://headscale.net/) control server. +- Parameter changes (ports, server URL, auth key) are applied automatically without needing to reinstall the app. --- -## Proxy Support +## Settings -All non-ROOT variants expose two local proxy endpoints that route outbound traffic through the Tailscale tunnel: +All parameters are configurable via the web UI (**Open → Settings** card) and take effect immediately without reinstalling: -### HTTP CONNECT Proxy — `http://127.0.0.1:8080` +| Parameter | Default | Description | +|---|---|---| +| Custom Server URL | *(empty)* | Control server URL for [Headscale](https://headscale.net/) or other self-hosted servers. Leave blank to use Tailscale's official servers. | +| Auth Key | *(empty)* | Pre-authentication key (`tskey-auth-...`). Cleared automatically after first successful connection. Leave blank to authenticate via browser. | +| HTTP Proxy Port | `8080` | Port for the outbound HTTP/HTTPS proxy. | +| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 proxy. | + +--- + + +All non-ROOT variants expose two local proxy endpoints that route outbound traffic through the Tailscale tunnel. The ports are configurable via **Settings → HTTP Proxy Port / SOCKS5 Proxy Port** in the web UI. + +### HTTP CONNECT Proxy — `http://127.0.0.1:8080` (default) Routes HTTP and HTTPS traffic. Set this wherever an HTTP/HTTPS proxy field is available on the camera: | Location | Field | Value | |---|---|---| -| System → Network → Global proxies | HTTP proxy | `http://127.0.0.1:8080` | -| System → Network → Global proxies | HTTPS proxy | `http://127.0.0.1:8080` | -| System → MQTT → Broker | HTTP proxy | `http://127.0.0.1:8080` | -| System → MQTT → Broker | HTTPS proxy | `http://127.0.0.1:8080` | +| System → Network → Global proxies | HTTP proxy | `http://127.0.0.1:` | +| System → Network → Global proxies | HTTPS proxy | `http://127.0.0.1:` | +| System → MQTT → Broker | HTTP proxy | `http://127.0.0.1:` | +| System → MQTT → Broker | HTTPS proxy | `http://127.0.0.1:` | -### SOCKS5 Proxy — `127.0.0.1:1055` +### SOCKS5 Proxy — `127.0.0.1:1080` (default) -For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:1055`. +For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:`. -> Proxy addresses are shown in the **Connection Details** panel of the web UI when connected. +> The active proxy addresses are always shown in the **Proxy Configuration** card of the web UI. + +> If you change a port that is already in use by another process, the app will log an error and exit rather than silently falling back to a different port. --- @@ -108,16 +123,6 @@ docker cp $(docker create ):/opt/app ./build --- -## Testers Needed - -A new **custom** version is available: -- Allows setting a custom server and auth key (for [Headscale](https://headscale.net/)). -- Go to **Settings (⋮ → Settings)** to add your details. - -Please give it a try and share your feedback! - ---- - ## Good News Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 12+**. @@ -157,15 +162,13 @@ The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**- | Variant | Architecture | Axis OS | Notes | |---|---|---|---| -| `armv7hf` | ARMv7 | 11+ (ACAP 4) | Standard, userspace networking | -| `aarch64` | AArch64 | 11+ (ACAP 4) | Standard, userspace networking | -| `armv7hf_root` | ARMv7 | 10 or earlier | Full kernel networking (root) | +| `aarch64` | AArch64 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports | +| `armv7hf` | ARMv7 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports | | `aarch64_root` | AArch64 | 10 or earlier | Full kernel networking (root) | -| `armv7hf_custom` | ARMv7 | 11+ (ACAP 4) | Custom server / Headscale support | -| `aarch64_custom` | AArch64 | 11+ (ACAP 4) | Custom server / Headscale support | +| `armv7hf_root` | ARMv7 | 10 or earlier | Full kernel networking (root) | | `armv7hf_acap3` | ARMv7 | **9.x – 10.x** | Legacy cameras, ACAP SDK 3 | -> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 11+ → use the standard variant. Axis OS 9/10 on ARMv7 → use `armv7hf_acap3`. +> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 11.11+ → use the standard variant (`aarch64` or `armv7hf`). Axis OS 9/10 on ARMv7 → use `armv7hf_acap3`. Axis OS 10 or earlier on AArch64 → use `aarch64_root`. You can verify your device details using the following command: diff --git a/aarch64/Dockerfile b/aarch64/Dockerfile index d91549a..1e8dce4 100644 --- a/aarch64/Dockerfile +++ b/aarch64/Dockerfile @@ -1,5 +1,5 @@ ARG ARCH=aarch64 -ARG VERSION=1.3 +ARG VERSION=1.15.1 ARG UBUNTU_VERSION=22.04 ARG REPO=axisecp ARG SDK=acap-native-sdk @@ -10,4 +10,4 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION} COPY ./app /opt/app/ WORKDIR /opt/app RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled -RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ +RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./ diff --git a/aarch64/app/Makefile b/aarch64/app/Makefile index 74caba9..60ca613 100644 --- a/aarch64/app/Makefile +++ b/aarch64/app/Makefile @@ -1 +1,14 @@ -nop: \ No newline at end of file +PROG = Tailscale_VPN +SRCS = param_bridge.c +PKGS = axparameter glib-2.0 +CFLAGS += $(shell pkg-config --cflags $(PKGS)) +LDADD = $(shell pkg-config --libs $(PKGS)) + +all: $(PROG) + chmod +x Tailscale_VPN_run + +$(PROG): $(SRCS) + $(CC) $(CFLAGS) -o $@ $^ $(LDADD) + +clean: + rm -f $(PROG) diff --git a/aarch64/app/Tailscale_VPN b/aarch64/app/Tailscale_VPN deleted file mode 100755 index db4f573..0000000 --- a/aarch64/app/Tailscale_VPN +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh - -APP_DIR="/usr/local/packages/Tailscale_VPN" -STATE_DIR="$APP_DIR/localdata" - -logger -t "Tailscale_VPN" "Starting Tailscale VPN service" - -mkdir -p "$STATE_DIR" -chmod 755 "$APP_DIR/lib/tailscale" -chmod 755 "$APP_DIR/lib/tailscaled" - -# Kill any leftover daemon from a previous run -killall tailscaled 2>/dev/null || true - -logger -t "Tailscale_VPN" "Starting tailscaled daemon (userspace networking)" -"$APP_DIR/lib/tailscaled" \ - --state="$STATE_DIR/tailscaled.state" \ - --socket="$STATE_DIR/tailscaled.sock" \ - --socks5-server=localhost:1055 \ - --outbound-http-proxy-listen=localhost:8080 \ - --tun=userspace-networking \ - 2>&1 | logger -t "Tailscale_VPN" & -TAILSCALED_PID=$! - -sleep 2 - -logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)" -"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --hostname="$(hostname)" 2>&1 | logger -t "Tailscale_VPN" - -logger -t "Tailscale_VPN" "Tailscale VPN is running" -logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080" -logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055" -wait $TAILSCALED_PID -logger -t "Tailscale_VPN" "tailscaled exited" diff --git a/aarch64/app/Tailscale_VPN_run b/aarch64/app/Tailscale_VPN_run new file mode 100644 index 0000000..1bad46e --- /dev/null +++ b/aarch64/app/Tailscale_VPN_run @@ -0,0 +1,76 @@ +#!/bin/sh +# Tailscale VPN run script — called by the param_bridge C binary. +# Config is sourced from $STATE_DIR/params.conf (written by param_bridge). +killall tailscaled 2>/dev/null || true + +APP_DIR="/usr/local/packages/Tailscale_VPN" +STATE_DIR="$APP_DIR/localdata" +TAILSCALED_PATH="$APP_DIR/lib/tailscaled" +TAILSCALE_PATH="$APP_DIR/lib/tailscale" +SOCKET_PATH="$STATE_DIR/tailscaled.sock" + +mkdir -p "$STATE_DIR" +chmod 755 $TAILSCALED_PATH +chmod 755 $TAILSCALE_PATH + +# Defaults — overridden by sourcing params.conf written by param_bridge +CUSTOM_SERVER="" +AUTH_KEY="" +CONF_HTTP="8080" +CONF_SOCKS="1080" + +if [ -f "$STATE_DIR/params.conf" ]; then + . "$STATE_DIR/params.conf" +fi + +logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}" + +# Check whether a TCP port is already bound +is_port_in_use() { + local port=$1 + local hex_port + hex_port=$(printf '%04X' "$port") + grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0 + grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0 + return 1 +} + +if is_port_in_use "$CONF_HTTP"; then + logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings." + exit 1 +fi +if is_port_in_use "$CONF_SOCKS"; then + logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings." + exit 1 +fi + +logger -t "Tailscale_VPN" "Starting tailscaled daemon" +$TAILSCALED_PATH \ + --state="$STATE_DIR/tailscaled.state" \ + --socket=$SOCKET_PATH \ + --socks5-server=localhost:$CONF_SOCKS \ + --outbound-http-proxy-listen=localhost:$CONF_HTTP \ + --tun=userspace-networking \ + >/dev/null 2>&1 & +TAILSCALED_PID=$! + +sleep 2 + +TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)" + +if [ -n "$CUSTOM_SERVER" ]; then + TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" +fi + +if [ -n "$AUTH_KEY" ]; then + TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" +fi + +eval $TAILSCALE_CMD +UP_EXIT=$? + +logger -t "Tailscale_VPN" "Tailscale VPN is running" +logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP" +logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS" + +wait $TAILSCALED_PID diff --git a/aarch64/app/html/index.html b/aarch64/app/html/index.html index 6183343..515182e 100644 --- a/aarch64/app/html/index.html +++ b/aarch64/app/html/index.html @@ -238,6 +238,40 @@ .log-line .msg-err { color: var(--red); } .log-line .msg-ok { color: var(--green); } + /* Settings form */ + .settings-form { display: flex; flex-direction: column; gap: 12px; } + .settings-row { display: flex; flex-direction: column; gap: 4px; } + .settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); } + .settings-input { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 13px; + font-family: var(--mono); + padding: 8px 10px; + width: 100%; + outline: none; + } + .settings-input:focus { border-color: var(--accent); } + .settings-hint { font-size: 11px; color: var(--muted); } + .settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; } + .save-btn { + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + .save-btn:hover { opacity: 0.9; } + .save-btn:disabled { opacity: 0.5; cursor: default; } + .save-status { font-size: 12px; color: var(--muted); } + .save-status.ok { color: var(--green); } + .save-status.err { color: var(--red); } + /* Refresh indicator */ .refresh-bar { display: flex; @@ -364,6 +398,52 @@ + +
+
Proxy Configuration
+
+
+
HTTP/HTTPS Proxy
+
http://127.0.0.1:8080
+
+
+
SOCKS5 Proxy
+
127.0.0.1:1080
+
+
+
+ + +
+
Settings
+
+
+ + + Leave blank to use official Tailscale servers. +
+
+ + + One-time use. Cleared automatically after first successful connection. +
+
+ + + Port for the outbound HTTP/HTTPS proxy. Default: 8080. +
+
+ + + Port for the SOCKS5 proxy. Default: 1080. +
+
+ + +
+
+
+
@@ -494,19 +574,34 @@ } } + // Parse proxy ports from log — use last match so old entries don't win + var httpPort = null; + var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g); + if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; } + var socksPort = null; + var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g); + if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; } + // Cache when found, restore from cache when missing cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version); + cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort); tsIP = tsIP || cacheGet('ip'); node = node || cacheGet('node'); tailnet = tailnet || cacheGet('tailnet'); version = version || cacheGet('version'); + httpPort = httpPort || cacheGet('http-port'); + socksPort = socksPort || cacheGet('socks-port'); var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; - var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); - // Detect running even after syslog rotation (daemon active but startup lines gone) - if (!isRunning && !latestUrl) { - isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || + var isRunning = /-> Running/.test(lastState); + + // Fallbacks only when syslog has rotated and no state transitions are visible. + // If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own + // "Tailscale VPN is running" message which stays in syslog indefinitely. + if (!isRunning && stateLines.length === 0) { + isRunning = /Tailscale VPN is running/.test(txt) || + /health\(warnable=[^)]+\): ok/.test(txt) || /derp-\d+ connected/.test(txt) || /c2n: GET/.test(txt) || /localapi:/.test(txt); @@ -515,17 +610,22 @@ // If an auth URL appears AFTER the last Running state, re-auth is needed // (handles stale Running entries in syslog after reinstall or token expiry) if (isRunning && latestUrl) { + // Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running' + // (our shell log). The shell log is written AFTER auth completes, so it correctly + // post-dates the auth URL when connection succeeds. var lastRunIdx = txt.lastIndexOf('-> Running'); + var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running'); + if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx; var urlSnippet = latestUrl.substring(0, 60); var lastUrlIdx = -1, upos = 0, uidx; while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; } if (lastUrlIdx > lastRunIdx) isRunning = false; } - if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version }; - if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version }; - if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version }; - return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version }; + if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort }; + if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; + if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; + return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; } function classifyLine(msg) { @@ -576,6 +676,10 @@ auth.style.display = 'none'; } + // Proxy card is always visible — update ports whenever known + if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort; + if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort; + if (r.state === 'connected') { document.getElementById('ts-ip').textContent = r.ip || '-'; document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim'); @@ -619,7 +723,6 @@ if (!running) { result.state = 'disconnected'; } else if (!result.url && result.state !== 'connected') { - // App is running but syslog is empty/rotated result.state = 'connected'; result.ip = result.ip || cacheGet('ip'); result.node = result.node || cacheGet('node'); @@ -681,6 +784,79 @@ } return 0; } + + // Settings — load current param values and save on submit + var PARAM_URL = '/axis-cgi/param.cgi'; + var serverInput = document.getElementById('input-server'); + var authInput = document.getElementById('input-authkey'); + var httpPortInput = document.getElementById('input-http-port'); + var socksPortInput= document.getElementById('input-socks-port'); + var saveBtn = document.getElementById('save-btn'); + var saveStatus = document.getElementById('save-status'); + + 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=(.*)/); + 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(); + // 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; } + }) + .catch(function() {}); + } + + function setStatus(msg, cls) { + saveStatus.textContent = msg; + saveStatus.className = 'save-status' + (cls ? ' ' + cls : ''); + if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000); + } + + saveBtn.addEventListener('click', function() { + saveBtn.disabled = true; + setStatus('Saving...', ''); + var httpPort = httpPortInput.value.trim() || '8080'; + var socksPort = socksPortInput.value.trim() || '1080'; + var params = 'action=update' + + '&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) + + '&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) + + '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) + + '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort); + 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(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'); + } + }) + .catch(function(e) { + saveBtn.disabled = false; + setStatus('Failed to save', 'err'); + }); + }); + + loadSettings(); })(); diff --git a/aarch64/app/manifest.json b/aarch64/app/manifest.json index 5b6534c..d1575f3 100644 --- a/aarch64/app/manifest.json +++ b/aarch64/app/manifest.json @@ -1,5 +1,5 @@ { - "schemaVersion": "1.3", + "schemaVersion": "1.7.0", "acapPackageConf": { "setup": { "appName": "Tailscale_VPN", @@ -12,7 +12,29 @@ "architecture": "aarch64" }, "configuration": { - "settingPage": "index.html" + "settingPage": "index.html", + "paramConfig": [ + { + "name": "CustomServer", + "default": "", + "type": "string" + }, + { + "name": "AuthKey", + "default": "", + "type": "string" + }, + { + "name": "HttpProxyPort", + "default": "8080", + "type": "string" + }, + { + "name": "Socks5Port", + "default": "1080", + "type": "string" + } + ] } } } diff --git a/aarch64/app/param_bridge.c b/aarch64/app/param_bridge.c new file mode 100644 index 0000000..315bb2a --- /dev/null +++ b/aarch64/app/param_bridge.c @@ -0,0 +1,251 @@ +// Copyright (C) 2024 Mo3he +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * ACAP parameter bridge for Tailscale VPN (userspace variant). + * + * Responsibilities: + * 1. Read Tailscale parameters from the ACAP parameter store (axparameter). + * 2. Write them to CONFIG_FILE so the shell script can source them. + * 3. Launch the shell script (Tailscale_VPN_run) as a child process. + * 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart + * of the child so the new config is picked up. + * Rapid changes within 300 ms are coalesced into a single restart. + * 5. Watchdog: if the child exits unexpectedly, restart it. + * + * Runs as the unprivileged 'sdk' ACAP user — no root required. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define APP_NAME "Tailscale_VPN" +#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" +#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" + +static pid_t child_pid = -1; +static guint reload_timer_id = 0; + +static char *cfg_custom_server = NULL; +static char *cfg_auth_key = NULL; +static char *cfg_http_proxy_port = NULL; +static char *cfg_socks5_port = NULL; + +static void cache_set(char **field, const char *value) { + if (!value) return; + free(*field); + *field = strdup(value); +} + +static const char *cache_get(char **field, const char *fallback) { + return (*field && **field) ? *field : fallback; +} + +/* ── child process management ──────────────────────────────────────────── */ + +static void stop_child(void) { + if (child_pid <= 0) + return; + kill(child_pid, SIGTERM); + for (int i = 0; i < 30; i++) { + int status; + if (waitpid(child_pid, &status, WNOHANG) == child_pid) { + child_pid = -1; + return; + } + usleep(100000); + } + syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL"); + kill(child_pid, SIGKILL); + waitpid(child_pid, NULL, 0); + child_pid = -1; +} + +static void start_child(void) { + stop_child(); + pid_t pid = fork(); + if (pid < 0) { + syslog(LOG_ERR, "fork failed: %s", strerror(errno)); + return; + } + if (pid == 0) { + execl(RUN_SCRIPT, RUN_SCRIPT, NULL); + syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno)); + _exit(1); + } + child_pid = pid; + syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid); +} + +/* ── watchdog ────────────────────────────────────────────────────────────── */ + +static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) { + if (child_pid > 0) { + int status; + pid_t ret = waitpid(child_pid, &status, WNOHANG); + if (ret == child_pid) { + int exit_code = WEXITSTATUS(status); + syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code); + child_pid = -1; + /* If child exited 0, auth succeeded — clear AuthKey via axparameter */ + if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) { + GError *err = NULL; + if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) { + free(cfg_auth_key); cfg_auth_key = strdup(""); + syslog(LOG_INFO, "AuthKey cleared after successful auth"); + } else { + syslog(LOG_WARNING, "failed to clear AuthKey: %s", + err ? err->message : "unknown"); + if (err) g_error_free(err); + } + } + start_child(); + } + } + return G_SOURCE_CONTINUE; +} + +/* ── config file ─────────────────────────────────────────────────────────── */ + +static void load_config_cache(AXParameter *handle) { + GError *error = NULL; + gchar *val = NULL; + +#define LOAD(name, field) \ + val = NULL; error = NULL; \ + if (ax_parameter_get(handle, name, &val, &error)) { \ + free(field); field = val ? strdup(val) : strdup(""); \ + g_free(val); val = NULL; \ + } else { \ + syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \ + error ? error->message : "unknown"); \ + if (error) { g_error_free(error); error = NULL; } \ + } + + LOAD("CustomServer", cfg_custom_server) + LOAD("AuthKey", cfg_auth_key) + LOAD("HttpProxyPort", cfg_http_proxy_port) + LOAD("Socks5Port", cfg_socks5_port) +#undef LOAD +} + +static void write_config_file(void) { + FILE *f = fopen(CONFIG_FILE, "w"); + if (!f) { + syslog(LOG_ERR, "cannot open config file %s: %s", + CONFIG_FILE, strerror(errno)); + return; + } + fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, "")); + fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, "")); + fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080")); + fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080")); + fclose(f); + chmod(CONFIG_FILE, 0600); + syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s", + cache_get(&cfg_http_proxy_port, "8080"), + cache_get(&cfg_socks5_port, "1080"), + cache_get(&cfg_custom_server, "(default)")); +} + +/* ── ACAP parameter callback ─────────────────────────────────────────────── */ + +static AXParameter *g_ax_handle = NULL; + +static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) { + reload_timer_id = 0; + if (g_ax_handle) + load_config_cache(g_ax_handle); + write_config_file(); + syslog(LOG_INFO, "restarting with new config"); + stop_child(); + start_child(); + return G_SOURCE_REMOVE; +} + +static void parameter_changed(const gchar *name, const gchar *value, + gpointer G_GNUC_UNUSED handle_void_ptr) { + const char *dot = strrchr(name, '.'); + const char *short_name = dot ? dot + 1 : name; + + syslog(LOG_INFO, "parameter changed: %s", short_name); + + if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value); + else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value); + else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value); + else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value); + + if (reload_timer_id) + g_source_remove(reload_timer_id); + reload_timer_id = g_timeout_add(300, debounced_restart, NULL); +} + +/* ── signal handler ──────────────────────────────────────────────────────── */ + +static gboolean signal_handler(gpointer loop) { + syslog(LOG_INFO, "stopping"); + stop_child(); + g_main_loop_quit((GMainLoop *)loop); + return G_SOURCE_REMOVE; +} + +/* ── main ────────────────────────────────────────────────────────────────── */ + +int main(void) { + GError *error = NULL; + + openlog(APP_NAME, LOG_PID, LOG_USER); + syslog(LOG_INFO, "starting"); + + /* Ensure localdata dir exists */ + mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); + + AXParameter *handle = ax_parameter_new(APP_NAME, &error); + if (!handle) { + syslog(LOG_ERR, "ax_parameter_new: %s", + error ? error->message : "unknown"); + if (error) g_error_free(error); + return 1; + } + g_ax_handle = handle; + + load_config_cache(handle); + write_config_file(); + start_child(); + + const char *params[] = { + "CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port" + }; + for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { + if (!ax_parameter_register_callback(handle, params[i], + parameter_changed, handle, &error)) { + syslog(LOG_WARNING, "register callback %s: %s", + params[i], error ? error->message : "unknown"); + if (error) { g_error_free(error); error = NULL; } + } + } + + GMainLoop *loop = g_main_loop_new(NULL, FALSE); + g_unix_signal_add(SIGTERM, signal_handler, loop); + g_unix_signal_add(SIGINT, signal_handler, loop); + g_timeout_add_seconds(60, watchdog_cb, NULL); + + syslog(LOG_INFO, "running — watching for parameter changes"); + g_main_loop_run(loop); + + g_main_loop_unref(loop); + ax_parameter_free(handle); + return 0; +} diff --git a/aarch64_ROOT/Dockerfile b/aarch64_ROOT/Dockerfile index d91549a..1e8dce4 100644 --- a/aarch64_ROOT/Dockerfile +++ b/aarch64_ROOT/Dockerfile @@ -1,5 +1,5 @@ ARG ARCH=aarch64 -ARG VERSION=1.3 +ARG VERSION=1.15.1 ARG UBUNTU_VERSION=22.04 ARG REPO=axisecp ARG SDK=acap-native-sdk @@ -10,4 +10,4 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION} COPY ./app /opt/app/ WORKDIR /opt/app RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled -RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ +RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./ diff --git a/aarch64_ROOT/app/Makefile b/aarch64_ROOT/app/Makefile index 74caba9..60ca613 100644 --- a/aarch64_ROOT/app/Makefile +++ b/aarch64_ROOT/app/Makefile @@ -1 +1,14 @@ -nop: \ No newline at end of file +PROG = Tailscale_VPN +SRCS = param_bridge.c +PKGS = axparameter glib-2.0 +CFLAGS += $(shell pkg-config --cflags $(PKGS)) +LDADD = $(shell pkg-config --libs $(PKGS)) + +all: $(PROG) + chmod +x Tailscale_VPN_run + +$(PROG): $(SRCS) + $(CC) $(CFLAGS) -o $@ $^ $(LDADD) + +clean: + rm -f $(PROG) diff --git a/aarch64_ROOT/app/Tailscale_VPN b/aarch64_ROOT/app/Tailscale_VPN deleted file mode 100755 index 54f1ee3..0000000 --- a/aarch64_ROOT/app/Tailscale_VPN +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh - -APP_DIR="/usr/local/packages/Tailscale_VPN" -STATE_DIR="$APP_DIR/localdata" - -logger -t "Tailscale_VPN" "Starting Tailscale VPN service (root mode)" - -mkdir -p "$STATE_DIR" -chmod 755 "$APP_DIR/lib/tailscale" -chmod 755 "$APP_DIR/lib/tailscaled" - -# Kill any leftover daemon from a previous run -killall tailscaled 2>/dev/null || true - -logger -t "Tailscale_VPN" "Starting tailscaled daemon" -"$APP_DIR/lib/tailscaled" \ - --state="$STATE_DIR/tailscaled.state" \ - --socket="$STATE_DIR/tailscaled.sock" \ - 2>&1 | logger -t "Tailscale_VPN" & -TAILSCALED_PID=$! - -sleep 2 - -logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)" -"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --accept-routes --hostname="$(hostname)" 2>&1 | logger -t "Tailscale_VPN" - -logger -t "Tailscale_VPN" "Tailscale VPN is running" -wait $TAILSCALED_PID -logger -t "Tailscale_VPN" "tailscaled exited" diff --git a/aarch64_ROOT/app/Tailscale_VPN_run b/aarch64_ROOT/app/Tailscale_VPN_run new file mode 100644 index 0000000..1bf556a --- /dev/null +++ b/aarch64_ROOT/app/Tailscale_VPN_run @@ -0,0 +1,48 @@ +#!/bin/sh +# Tailscale VPN run script (ROOT / kernel networking variant). +# Sources config from params.conf written by param_bridge. +killall tailscaled 2>/dev/null || true + +APP_DIR="/usr/local/packages/Tailscale_VPN" +STATE_DIR="$APP_DIR/localdata" +TAILSCALED_PATH="$APP_DIR/lib/tailscaled" +TAILSCALE_PATH="$APP_DIR/lib/tailscale" +SOCKET_PATH="$STATE_DIR/tailscaled.sock" + +mkdir -p "$STATE_DIR" +chmod 755 $TAILSCALED_PATH +chmod 755 $TAILSCALE_PATH + +CUSTOM_SERVER="" +AUTH_KEY="" + +if [ -f "$STATE_DIR/params.conf" ]; then + . "$STATE_DIR/params.conf" +fi + +logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}" + +$TAILSCALED_PATH \ + --state="$STATE_DIR/tailscaled.state" \ + --socket=$SOCKET_PATH \ + >/dev/null 2>&1 & +TAILSCALED_PID=$! + +sleep 2 + +TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)" + +if [ -n "$CUSTOM_SERVER" ]; then + TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" +fi + +if [ -n "$AUTH_KEY" ]; then + TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" +fi + +eval $TAILSCALE_CMD +UP_EXIT=$? + +logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)" + +wait $TAILSCALED_PID diff --git a/aarch64_ROOT/app/html/index.html b/aarch64_ROOT/app/html/index.html index e5a4419..515182e 100644 --- a/aarch64_ROOT/app/html/index.html +++ b/aarch64_ROOT/app/html/index.html @@ -238,6 +238,40 @@ .log-line .msg-err { color: var(--red); } .log-line .msg-ok { color: var(--green); } + /* Settings form */ + .settings-form { display: flex; flex-direction: column; gap: 12px; } + .settings-row { display: flex; flex-direction: column; gap: 4px; } + .settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); } + .settings-input { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 13px; + font-family: var(--mono); + padding: 8px 10px; + width: 100%; + outline: none; + } + .settings-input:focus { border-color: var(--accent); } + .settings-hint { font-size: 11px; color: var(--muted); } + .settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; } + .save-btn { + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + .save-btn:hover { opacity: 0.9; } + .save-btn:disabled { opacity: 0.5; cursor: default; } + .save-status { font-size: 12px; color: var(--muted); } + .save-status.ok { color: var(--green); } + .save-status.err { color: var(--red); } + /* Refresh indicator */ .refresh-bar { display: flex; @@ -364,6 +398,52 @@
+ +
+
Proxy Configuration
+
+
+
HTTP/HTTPS Proxy
+
http://127.0.0.1:8080
+
+
+
SOCKS5 Proxy
+
127.0.0.1:1080
+
+
+
+ + +
+
Settings
+
+
+ + + Leave blank to use official Tailscale servers. +
+
+ + + One-time use. Cleared automatically after first successful connection. +
+
+ + + Port for the outbound HTTP/HTTPS proxy. Default: 8080. +
+
+ + + Port for the SOCKS5 proxy. Default: 1080. +
+
+ + +
+
+
+
@@ -494,19 +574,34 @@ } } + // Parse proxy ports from log — use last match so old entries don't win + var httpPort = null; + var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g); + if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; } + var socksPort = null; + var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g); + if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; } + // Cache when found, restore from cache when missing cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version); + cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort); tsIP = tsIP || cacheGet('ip'); node = node || cacheGet('node'); tailnet = tailnet || cacheGet('tailnet'); version = version || cacheGet('version'); + httpPort = httpPort || cacheGet('http-port'); + socksPort = socksPort || cacheGet('socks-port'); var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; - var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); - // Detect running even after syslog rotation (daemon active but startup lines gone) - if (!isRunning && !latestUrl) { - isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || + var isRunning = /-> Running/.test(lastState); + + // Fallbacks only when syslog has rotated and no state transitions are visible. + // If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own + // "Tailscale VPN is running" message which stays in syslog indefinitely. + if (!isRunning && stateLines.length === 0) { + isRunning = /Tailscale VPN is running/.test(txt) || + /health\(warnable=[^)]+\): ok/.test(txt) || /derp-\d+ connected/.test(txt) || /c2n: GET/.test(txt) || /localapi:/.test(txt); @@ -515,17 +610,22 @@ // If an auth URL appears AFTER the last Running state, re-auth is needed // (handles stale Running entries in syslog after reinstall or token expiry) if (isRunning && latestUrl) { + // Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running' + // (our shell log). The shell log is written AFTER auth completes, so it correctly + // post-dates the auth URL when connection succeeds. var lastRunIdx = txt.lastIndexOf('-> Running'); + var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running'); + if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx; var urlSnippet = latestUrl.substring(0, 60); var lastUrlIdx = -1, upos = 0, uidx; while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; } if (lastUrlIdx > lastRunIdx) isRunning = false; } - if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version }; - if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version }; - if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version }; - return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version }; + if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort }; + if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; + if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; + return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; } function classifyLine(msg) { @@ -576,6 +676,10 @@ auth.style.display = 'none'; } + // Proxy card is always visible — update ports whenever known + if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort; + if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort; + if (r.state === 'connected') { document.getElementById('ts-ip').textContent = r.ip || '-'; document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim'); @@ -680,6 +784,79 @@ } return 0; } + + // Settings — load current param values and save on submit + var PARAM_URL = '/axis-cgi/param.cgi'; + var serverInput = document.getElementById('input-server'); + var authInput = document.getElementById('input-authkey'); + var httpPortInput = document.getElementById('input-http-port'); + var socksPortInput= document.getElementById('input-socks-port'); + var saveBtn = document.getElementById('save-btn'); + var saveStatus = document.getElementById('save-status'); + + 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=(.*)/); + 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(); + // 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; } + }) + .catch(function() {}); + } + + function setStatus(msg, cls) { + saveStatus.textContent = msg; + saveStatus.className = 'save-status' + (cls ? ' ' + cls : ''); + if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000); + } + + saveBtn.addEventListener('click', function() { + saveBtn.disabled = true; + setStatus('Saving...', ''); + var httpPort = httpPortInput.value.trim() || '8080'; + var socksPort = socksPortInput.value.trim() || '1080'; + var params = 'action=update' + + '&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) + + '&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) + + '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) + + '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort); + 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(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'); + } + }) + .catch(function(e) { + saveBtn.disabled = false; + setStatus('Failed to save', 'err'); + }); + }); + + loadSettings(); })(); diff --git a/aarch64_ROOT/app/manifest.json b/aarch64_ROOT/app/manifest.json index eb42286..bd3b2aa 100644 --- a/aarch64_ROOT/app/manifest.json +++ b/aarch64_ROOT/app/manifest.json @@ -1,5 +1,5 @@ { - "schemaVersion": "1.3", + "schemaVersion": "1.7.0", "acapPackageConf": { "setup": { "appName": "Tailscale_VPN", @@ -16,7 +16,19 @@ "architecture": "aarch64" }, "configuration": { - "settingPage": "index.html" + "settingPage": "index.html", + "paramConfig": [ + { + "name": "CustomServer", + "default": "", + "type": "string" + }, + { + "name": "AuthKey", + "default": "", + "type": "string" + } + ] } } } diff --git a/aarch64_ROOT/app/param_bridge.c b/aarch64_ROOT/app/param_bridge.c new file mode 100644 index 0000000..add6c4f --- /dev/null +++ b/aarch64_ROOT/app/param_bridge.c @@ -0,0 +1,215 @@ +// Copyright (C) 2024 Mo3he +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant). + * Same structure as regular param_bridge.c but without proxy port params. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define APP_NAME "Tailscale_VPN" +#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" +#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" + +static pid_t child_pid = -1; +static guint reload_timer_id = 0; + +static char *cfg_custom_server = NULL; +static char *cfg_auth_key = NULL; + +static void cache_set(char **field, const char *value) { + if (!value) return; + free(*field); + *field = strdup(value); +} + +static const char *cache_get(char **field, const char *fallback) { + return (*field && **field) ? *field : fallback; +} + +static void stop_child(void) { + if (child_pid <= 0) + return; + kill(child_pid, SIGTERM); + for (int i = 0; i < 30; i++) { + int status; + if (waitpid(child_pid, &status, WNOHANG) == child_pid) { + child_pid = -1; + return; + } + usleep(100000); + } + syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL"); + kill(child_pid, SIGKILL); + waitpid(child_pid, NULL, 0); + child_pid = -1; +} + +static void start_child(void) { + stop_child(); + pid_t pid = fork(); + if (pid < 0) { + syslog(LOG_ERR, "fork failed: %s", strerror(errno)); + return; + } + if (pid == 0) { + execl(RUN_SCRIPT, RUN_SCRIPT, NULL); + syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno)); + _exit(1); + } + child_pid = pid; + syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid); +} + +static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) { + if (child_pid > 0) { + int status; + pid_t ret = waitpid(child_pid, &status, WNOHANG); + if (ret == child_pid) { + int exit_code = WEXITSTATUS(status); + syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code); + child_pid = -1; + /* If child exited 0, auth succeeded — clear AuthKey via axparameter */ + if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) { + GError *err = NULL; + if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) { + free(cfg_auth_key); cfg_auth_key = strdup(""); + syslog(LOG_INFO, "AuthKey cleared after successful auth"); + } else { + syslog(LOG_WARNING, "failed to clear AuthKey: %s", + err ? err->message : "unknown"); + if (err) g_error_free(err); + } + } + start_child(); + } + } + return G_SOURCE_CONTINUE; +} + +static void load_config_cache(AXParameter *handle) { + GError *error = NULL; + gchar *val = NULL; + +#define LOAD(name, field) \ + val = NULL; error = NULL; \ + if (ax_parameter_get(handle, name, &val, &error)) { \ + free(field); field = val ? strdup(val) : strdup(""); \ + g_free(val); val = NULL; \ + } else { \ + syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \ + error ? error->message : "unknown"); \ + if (error) { g_error_free(error); error = NULL; } \ + } + + LOAD("CustomServer", cfg_custom_server) + LOAD("AuthKey", cfg_auth_key) +#undef LOAD +} + +static void write_config_file(void) { + FILE *f = fopen(CONFIG_FILE, "w"); + if (!f) { + syslog(LOG_ERR, "cannot open config file %s: %s", + CONFIG_FILE, strerror(errno)); + return; + } + fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, "")); + fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, "")); + fclose(f); + chmod(CONFIG_FILE, 0600); + syslog(LOG_INFO, "config updated: server=%s", + cache_get(&cfg_custom_server, "(default)")); +} + +static AXParameter *g_ax_handle = NULL; + +static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) { + reload_timer_id = 0; + if (g_ax_handle) + load_config_cache(g_ax_handle); + write_config_file(); + syslog(LOG_INFO, "restarting with new config"); + stop_child(); + start_child(); + return G_SOURCE_REMOVE; +} + +static void parameter_changed(const gchar *name, const gchar *value, + gpointer G_GNUC_UNUSED handle_void_ptr) { + const char *dot = strrchr(name, '.'); + const char *short_name = dot ? dot + 1 : name; + syslog(LOG_INFO, "parameter changed: %s", short_name); + + if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value); + else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value); + + if (reload_timer_id) + g_source_remove(reload_timer_id); + reload_timer_id = g_timeout_add(300, debounced_restart, NULL); +} + +static gboolean signal_handler(gpointer loop) { + syslog(LOG_INFO, "stopping"); + stop_child(); + g_main_loop_quit((GMainLoop *)loop); + return G_SOURCE_REMOVE; +} + +int main(void) { + GError *error = NULL; + + openlog(APP_NAME, LOG_PID, LOG_USER); + syslog(LOG_INFO, "starting (root mode)"); + + mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); + + AXParameter *handle = ax_parameter_new(APP_NAME, &error); + if (!handle) { + syslog(LOG_ERR, "ax_parameter_new: %s", + error ? error->message : "unknown"); + if (error) g_error_free(error); + return 1; + } + g_ax_handle = handle; + + load_config_cache(handle); + write_config_file(); + start_child(); + + const char *params[] = { "CustomServer", "AuthKey" }; + for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { + if (!ax_parameter_register_callback(handle, params[i], + parameter_changed, handle, &error)) { + syslog(LOG_WARNING, "register callback %s: %s", + params[i], error ? error->message : "unknown"); + if (error) { g_error_free(error); error = NULL; } + } + } + + GMainLoop *loop = g_main_loop_new(NULL, FALSE); + g_unix_signal_add(SIGTERM, signal_handler, loop); + g_unix_signal_add(SIGINT, signal_handler, loop); + g_timeout_add_seconds(60, watchdog_cb, NULL); + + syslog(LOG_INFO, "running — watching for parameter changes"); + g_main_loop_run(loop); + + g_main_loop_unref(loop); + ax_parameter_free(handle); + return 0; +} diff --git a/aarch64_custom/Dockerfile b/aarch64_custom/Dockerfile deleted file mode 100644 index 4353d54..0000000 --- a/aarch64_custom/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -ARG ARCH=aarch64 -ARG VERSION=12.3.0 -ARG UBUNTU_VERSION=24.04 -ARG REPO=axisecp -ARG SDK=acap-native-sdk - -FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION} - -# Building the ACAP application -COPY ./app /opt/app/ -WORKDIR /opt/app -RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled -RUN . /opt/axis/acapsdk/environment-setup* && acap-build . \ No newline at end of file diff --git a/aarch64_custom/README.md b/aarch64_custom/README.md deleted file mode 100644 index 6e0fc02..0000000 --- a/aarch64_custom/README.md +++ /dev/null @@ -1,5 +0,0 @@ -To build, from main directory - -docker build --tag aarch64 . - -docker cp $(docker create aarch64):/opt/app ./build \ No newline at end of file diff --git a/aarch64_custom/app/LICENSE b/aarch64_custom/app/LICENSE deleted file mode 100644 index 9241b06..0000000 --- a/aarch64_custom/app/LICENSE +++ /dev/null @@ -1,29 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2020 Tailscale & AUTHORS. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/aarch64_custom/app/Makefile b/aarch64_custom/app/Makefile deleted file mode 100644 index 393a400..0000000 --- a/aarch64_custom/app/Makefile +++ /dev/null @@ -1,29 +0,0 @@ -PROGS = serverconfig -SRCS = config_updater.c -OBJS = $(SRCS:.c=.o) - -PKGS = glib-2.0 gio-2.0 axparameter - -CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) -LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) - -CFLAGS += -Wall \ - -Wextra \ - -Wformat=2 \ - -Wpointer-arith \ - -Wbad-function-cast \ - -Wstrict-prototypes \ - -Wmissing-prototypes \ - -Winline \ - -Wdisabled-optimization \ - -Wfloat-equal \ - -W \ - -Werror - -all: $(PROGS) - -$(PROGS): $(OBJS) - $(CC) $(LDFLAGS) $^ $(LIBS) $(LDLIBS) -o $@ - -clean: - rm -f $(PROGS) *.o *.eap* *_LICENSE.txt package.conf* param.conf tmp* \ No newline at end of file diff --git a/aarch64_custom/app/config_updater.c b/aarch64_custom/app/config_updater.c deleted file mode 100644 index 4fcfb43..0000000 --- a/aarch64_custom/app/config_updater.c +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Simple file-based configuration updater for Tailscale - * This avoids the AXParameter system and just writes directly to a config file - */ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define APP_NAME "serverconfig" -#define APP_DIR "/usr/local/packages/serverconfig" -#define STATE_DIR APP_DIR "/localdata" -#define CONFIG_FILE STATE_DIR "/config.txt" -#define SCRIPT_PATH "/usr/local/packages/serverconfig/start_tailscale.sh" -#define SCRIPT_SOURCE "/usr/local/packages/serverconfig/lib/start_tailscale.sh" - -static gboolean signal_handler(gpointer loop) { - g_main_loop_quit((GMainLoop*)loop); - syslog(LOG_INFO, "Configuration updater stopping."); - return G_SOURCE_REMOVE; -} - -// Create localdata directory -static void ensure_localdata_exists(void) { - struct stat st = {0}; - - if (stat(STATE_DIR, &st) == -1) { - if (mkdir(STATE_DIR, 0755) != 0) { - syslog(LOG_ERR, "Failed to create localdata directory: %s", strerror(errno)); - } else { - syslog(LOG_INFO, "Created localdata directory: %s", STATE_DIR); - } - } -} - -// Copy script from lib folder to main directory -static void copy_script_file(void) { - char buffer[4096]; - ssize_t bytes_read, bytes_written; - int source_fd, dest_fd; - - syslog(LOG_INFO, "Copying script from %s to %s", SCRIPT_SOURCE, SCRIPT_PATH); - - // Open source file - source_fd = open(SCRIPT_SOURCE, O_RDONLY); - if (source_fd < 0) { - syslog(LOG_ERR, "Failed to open source script: %s", strerror(errno)); - return; - } - - // Open destination file (create if doesn't exist, truncate if exists) - dest_fd = open(SCRIPT_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0755); - if (dest_fd < 0) { - syslog(LOG_ERR, "Failed to open destination script: %s", strerror(errno)); - close(source_fd); - return; - } - - // Copy the file - while ((bytes_read = read(source_fd, buffer, sizeof(buffer))) > 0) { - bytes_written = write(dest_fd, buffer, bytes_read); - if (bytes_written != bytes_read) { - syslog(LOG_ERR, "Error writing to destination file: %s", strerror(errno)); - close(source_fd); - close(dest_fd); - return; - } - } - - // Close file descriptors - close(source_fd); - close(dest_fd); - - // Make the script executable - if (chmod(SCRIPT_PATH, 0755) != 0) { - syslog(LOG_ERR, "Failed to make script executable: %s", strerror(errno)); - return; - } - - syslog(LOG_INFO, "Script copied and made executable successfully"); -} - -// Execute the Tailscale script -static void start_tailscale(void) { - syslog(LOG_INFO, "Starting Tailscale VPN script"); - - // Check if script exists, if not, copy it - struct stat st; - if (stat(SCRIPT_PATH, &st) != 0) { - syslog(LOG_INFO, "Script not found at %s, copying from lib folder", SCRIPT_PATH); - copy_script_file(); - } - - // Fork and execute the script - pid_t pid = fork(); - if (pid < 0) { - syslog(LOG_ERR, "Failed to fork for Tailscale script: %s", strerror(errno)); - return; - } else if (pid == 0) { - // Child process - execute the script - execl(SCRIPT_PATH, "start_tailscale.sh", NULL); - - // If we get here, execl failed - syslog(LOG_ERR, "Failed to execute Tailscale script: %s", strerror(errno)); - _exit(1); - } - - syslog(LOG_INFO, "Tailscale script started with PID: %d", pid); -} - -// Update the configuration file with current parameter values -static void update_config_file(AXParameter* handle) { - GError* error = NULL; - gchar* server_value = NULL; - gchar* key_value = NULL; - FILE* file; - - // Ensure localdata directory exists - ensure_localdata_exists(); - - // Get parameter values - if (!ax_parameter_get(handle, "CustomServer", &server_value, &error)) { - syslog(LOG_ERR, "Failed to get CustomServer: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - error = NULL; - server_value = g_strdup(""); - } - - if (!ax_parameter_get(handle, "AuthKey", &key_value, &error)) { - syslog(LOG_ERR, "Failed to get AuthKey: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - key_value = g_strdup(""); - } - - // Write to config file in localdata - file = fopen(CONFIG_FILE, "w"); - if (file) { - fprintf(file, "custom_server=%s\n", server_value ? server_value : ""); - fprintf(file, "auth_key=%s\n", key_value ? key_value : ""); - fclose(file); - - // Set permissions to ensure the file is readable - chmod(CONFIG_FILE, 0644); - - syslog(LOG_INFO, "Updated configuration file in local custom_server=%s", - server_value ? server_value : ""); - syslog(LOG_INFO, "Updated configuration file in local auth_key=%s", - key_value && strlen(key_value) > 0 ? "(set)" : "(empty)"); - } else { - syslog(LOG_ERR, "Failed to open config file for writing: %s", strerror(errno)); - } - - // Clean up - g_free(server_value); - g_free(key_value); -} - -// Handle parameter changes -static void parameter_changed(const gchar* name, const gchar* value, gpointer handle_void_ptr) { - AXParameter* handle = handle_void_ptr; - - // Extract simple parameter name from the fully qualified name - const char* simple_name = name; - const char* prefix = "root." APP_NAME "."; - if (strncmp(name, prefix, strlen(prefix)) == 0) { - simple_name = name + strlen(prefix); - } - - syslog(LOG_INFO, "Parameter changed: %s = %s", simple_name, value); - - // Update config file whenever any parameter changes - update_config_file(handle); - - // Restart Tailscale to apply the new settings - start_tailscale(); -} - -int main(void) { - GError* error = NULL; - GMainLoop* loop = NULL; - - // Open syslog for logging - openlog(APP_NAME, LOG_PID, LOG_USER); - syslog(LOG_INFO, "Config updater starting"); - - // Initialize parameter handling - AXParameter* handle = ax_parameter_new(APP_NAME, &error); - if (handle == NULL) { - syslog(LOG_ERR, "Failed to initialize parameters: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - exit(1); - } - - // Ensure localdata directory exists - ensure_localdata_exists(); - - // Ensure script is copied from lib folder - copy_script_file(); - - // Create initial config file - update_config_file(handle); - - // Start Tailscale VPN script - start_tailscale(); - - // Register for parameter changes - if (!ax_parameter_register_callback(handle, "CustomServer", parameter_changed, handle, &error)) { - syslog(LOG_ERR, "Failed to register CustomServer callback: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - error = NULL; - } - - if (!ax_parameter_register_callback(handle, "AuthKey", parameter_changed, handle, &error)) { - syslog(LOG_ERR, "Failed to register AuthKey callback: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - } - - // Register for parameter changes with fully qualified names as fallback - if (!ax_parameter_register_callback(handle, "root." APP_NAME ".CustomServer", parameter_changed, handle, NULL)) { - syslog(LOG_INFO, "Fallback CustomServer registration failed (this may be normal)"); - } - if (!ax_parameter_register_callback(handle, "root." APP_NAME ".AuthKey", parameter_changed, handle, NULL)) { - syslog(LOG_INFO, "Fallback AuthKey registration failed (this may be normal)"); - } - - // Set up main loop - loop = g_main_loop_new(NULL, FALSE); - g_unix_signal_add(SIGTERM, signal_handler, loop); - g_unix_signal_add(SIGINT, signal_handler, loop); - - syslog(LOG_INFO, "Config updater running. Waiting for parameter changes..."); - g_main_loop_run(loop); - - // Clean up - g_main_loop_unref(loop); - ax_parameter_free(handle); - - return 0; -} diff --git a/aarch64_custom/app/html/index.html b/aarch64_custom/app/html/index.html deleted file mode 100644 index 9358891..0000000 --- a/aarch64_custom/app/html/index.html +++ /dev/null @@ -1,695 +0,0 @@ - - - - - Tailscale VPN - - - - -
-
- - - - - - - - - - - - -

Tailscale VPN

-
- -
- - -
- - Checking... - -
- - -
-
Update available:
- - - Download - -
- - - - - - - - -
-
-
Service Log
-
- - -
-
-
Loading logs...
-
- -
- - Auto-refresh every 5s -
- - - - - diff --git a/aarch64_custom/app/lib/start_tailscale.sh b/aarch64_custom/app/lib/start_tailscale.sh deleted file mode 100644 index 969dbc3..0000000 --- a/aarch64_custom/app/lib/start_tailscale.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/bin/sh -# Make sure this script terminates any existing Tailscale processes before starting new ones - -# Kill any existing tailscaled processes -killall tailscaled 2>/dev/null || true - -# Simple script to start Tailscale with custom configuration -APP_DIR="/usr/local/packages/serverconfig" -STATE_DIR="$APP_DIR/localdata" -CONFIG_FILE="$STATE_DIR/config.txt" -TAILSCALED_PATH="$APP_DIR/lib/tailscaled" -TAILSCALE_PATH="$APP_DIR/lib/tailscale" -SOCKET_PATH="$STATE_DIR/tailscaled.sock" - -# Create localdata directory if it doesn't exist -mkdir -p "$STATE_DIR" - -# Log to syslog -logger -t "tailscale_script" "Starting Tailscale VPN service" - -# Set execute permissions -chmod 755 $TAILSCALED_PATH -chmod 755 $TAILSCALE_PATH - -# Read configuration (if exists) -CUSTOM_SERVER="" -AUTH_KEY="" -if [ -f "$CONFIG_FILE" ]; then - logger -t "tailscale_script" "Reading configuration from $CONFIG_FILE" - # Read values from config file - while IFS='=' read -r key value; do - case "$key" in - "custom_server") CUSTOM_SERVER="$value" ;; - "auth_key") AUTH_KEY="$value" ;; - esac - done < "$CONFIG_FILE" -fi - -# Start tailscaled with state stored in localdata -logger -t "tailscale_script" "Starting tailscaled daemon" -$TAILSCALED_PATH \ - --state="$STATE_DIR/tailscaled.state" \ - --socket=$SOCKET_PATH \ - --socks5-server=localhost:1055 \ - --outbound-http-proxy-listen=localhost:8080 \ - --tun=userspace-networking \ - 2>&1 | logger -t "tailscale_script" & -TAILSCALED_PID=$! - -# Wait for tailscaled to initialize -sleep 2 - -# Build up the command based on available parameters -TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)" - -if [ -n "$CUSTOM_SERVER" ]; then - logger -t "tailscale_script" "Using custom server: $CUSTOM_SERVER" - TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" -fi - -if [ -n "$AUTH_KEY" ]; then - logger -t "tailscale_script" "Using authentication key" - TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" -fi - -# Connect to Tailscale network -logger -t "tailscale_script" "Running: $TAILSCALE_CMD" -eval $TAILSCALE_CMD -UP_EXIT=$? - -# Clear auth key from config after first use — Tailscale auth keys are single-use -# and the node identity is persisted in tailscaled.state, so the key is no longer needed. -if [ -n "$AUTH_KEY" ] && [ "$UP_EXIT" -eq 0 ] && [ -f "$CONFIG_FILE" ]; then - logger -t "tailscale_script" "Clearing auth key from config after successful authentication" - printf 'custom_server=%s\nauth_key=\n' "$CUSTOM_SERVER" > "$CONFIG_FILE" -fi - -# Keep the script running to maintain the tailscaled process -logger -t "tailscale_script" "Tailscale VPN is running" -logger -t "tailscale_script" "HTTP/HTTPS proxy: http://127.0.0.1:8080" -logger -t "tailscale_script" "SOCKS5 proxy: 127.0.0.1:1055" -logger -t "tailscale_script" "To change settings, modify parameters in ACAP web interface" - -# Wait for tailscaled process to exit -wait $TAILSCALED_PID diff --git a/aarch64_custom/app/lib/tailscale b/aarch64_custom/app/lib/tailscale deleted file mode 100755 index f16af6f..0000000 Binary files a/aarch64_custom/app/lib/tailscale and /dev/null differ diff --git a/aarch64_custom/app/lib/tailscaled b/aarch64_custom/app/lib/tailscaled deleted file mode 100755 index ba9c26d..0000000 Binary files a/aarch64_custom/app/lib/tailscaled and /dev/null differ diff --git a/aarch64_custom/app/manifest.json b/aarch64_custom/app/manifest.json deleted file mode 100644 index 91b1918..0000000 --- a/aarch64_custom/app/manifest.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "schemaVersion": "1.7.3", - "acapPackageConf": { - "setup": { - "friendlyName": "Tailscale VPN", - "appName": "serverconfig", - "vendor": "Mo3he", - "embeddedSdkVersion": "3.0", - "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", - "runMode": "respawn", - "version": "1.96.4" - }, - "configuration": { - "settingPage": "index.html", - "paramConfig": [ - { - "name": "CustomServer", - "default": "", - "type": "string" - }, - { - "name": "AuthKey", - "default": "", - "type": "string" - } - ] - } - } -} diff --git a/arm/Dockerfile b/arm/Dockerfile index 6b4f556..75b3bd6 100644 --- a/arm/Dockerfile +++ b/arm/Dockerfile @@ -1,5 +1,5 @@ ARG ARCH=armv7hf -ARG VERSION=1.3 +ARG VERSION=1.15.1 ARG UBUNTU_VERSION=22.04 ARG REPO=axisecp ARG SDK=acap-native-sdk @@ -10,4 +10,4 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION} COPY ./app /opt/app/ WORKDIR /opt/app RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled -RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ +RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./ diff --git a/arm/app/Makefile b/arm/app/Makefile index 74caba9..60ca613 100644 --- a/arm/app/Makefile +++ b/arm/app/Makefile @@ -1 +1,14 @@ -nop: \ No newline at end of file +PROG = Tailscale_VPN +SRCS = param_bridge.c +PKGS = axparameter glib-2.0 +CFLAGS += $(shell pkg-config --cflags $(PKGS)) +LDADD = $(shell pkg-config --libs $(PKGS)) + +all: $(PROG) + chmod +x Tailscale_VPN_run + +$(PROG): $(SRCS) + $(CC) $(CFLAGS) -o $@ $^ $(LDADD) + +clean: + rm -f $(PROG) diff --git a/arm/app/Tailscale_VPN b/arm/app/Tailscale_VPN deleted file mode 100755 index db4f573..0000000 --- a/arm/app/Tailscale_VPN +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh - -APP_DIR="/usr/local/packages/Tailscale_VPN" -STATE_DIR="$APP_DIR/localdata" - -logger -t "Tailscale_VPN" "Starting Tailscale VPN service" - -mkdir -p "$STATE_DIR" -chmod 755 "$APP_DIR/lib/tailscale" -chmod 755 "$APP_DIR/lib/tailscaled" - -# Kill any leftover daemon from a previous run -killall tailscaled 2>/dev/null || true - -logger -t "Tailscale_VPN" "Starting tailscaled daemon (userspace networking)" -"$APP_DIR/lib/tailscaled" \ - --state="$STATE_DIR/tailscaled.state" \ - --socket="$STATE_DIR/tailscaled.sock" \ - --socks5-server=localhost:1055 \ - --outbound-http-proxy-listen=localhost:8080 \ - --tun=userspace-networking \ - 2>&1 | logger -t "Tailscale_VPN" & -TAILSCALED_PID=$! - -sleep 2 - -logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)" -"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --hostname="$(hostname)" 2>&1 | logger -t "Tailscale_VPN" - -logger -t "Tailscale_VPN" "Tailscale VPN is running" -logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080" -logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055" -wait $TAILSCALED_PID -logger -t "Tailscale_VPN" "tailscaled exited" diff --git a/arm/app/Tailscale_VPN_run b/arm/app/Tailscale_VPN_run new file mode 100644 index 0000000..1bad46e --- /dev/null +++ b/arm/app/Tailscale_VPN_run @@ -0,0 +1,76 @@ +#!/bin/sh +# Tailscale VPN run script — called by the param_bridge C binary. +# Config is sourced from $STATE_DIR/params.conf (written by param_bridge). +killall tailscaled 2>/dev/null || true + +APP_DIR="/usr/local/packages/Tailscale_VPN" +STATE_DIR="$APP_DIR/localdata" +TAILSCALED_PATH="$APP_DIR/lib/tailscaled" +TAILSCALE_PATH="$APP_DIR/lib/tailscale" +SOCKET_PATH="$STATE_DIR/tailscaled.sock" + +mkdir -p "$STATE_DIR" +chmod 755 $TAILSCALED_PATH +chmod 755 $TAILSCALE_PATH + +# Defaults — overridden by sourcing params.conf written by param_bridge +CUSTOM_SERVER="" +AUTH_KEY="" +CONF_HTTP="8080" +CONF_SOCKS="1080" + +if [ -f "$STATE_DIR/params.conf" ]; then + . "$STATE_DIR/params.conf" +fi + +logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}" + +# Check whether a TCP port is already bound +is_port_in_use() { + local port=$1 + local hex_port + hex_port=$(printf '%04X' "$port") + grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0 + grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0 + return 1 +} + +if is_port_in_use "$CONF_HTTP"; then + logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings." + exit 1 +fi +if is_port_in_use "$CONF_SOCKS"; then + logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings." + exit 1 +fi + +logger -t "Tailscale_VPN" "Starting tailscaled daemon" +$TAILSCALED_PATH \ + --state="$STATE_DIR/tailscaled.state" \ + --socket=$SOCKET_PATH \ + --socks5-server=localhost:$CONF_SOCKS \ + --outbound-http-proxy-listen=localhost:$CONF_HTTP \ + --tun=userspace-networking \ + >/dev/null 2>&1 & +TAILSCALED_PID=$! + +sleep 2 + +TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)" + +if [ -n "$CUSTOM_SERVER" ]; then + TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" +fi + +if [ -n "$AUTH_KEY" ]; then + TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" +fi + +eval $TAILSCALE_CMD +UP_EXIT=$? + +logger -t "Tailscale_VPN" "Tailscale VPN is running" +logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP" +logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS" + +wait $TAILSCALED_PID diff --git a/arm/app/html/index.html b/arm/app/html/index.html index e5a4419..515182e 100644 --- a/arm/app/html/index.html +++ b/arm/app/html/index.html @@ -238,6 +238,40 @@ .log-line .msg-err { color: var(--red); } .log-line .msg-ok { color: var(--green); } + /* Settings form */ + .settings-form { display: flex; flex-direction: column; gap: 12px; } + .settings-row { display: flex; flex-direction: column; gap: 4px; } + .settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); } + .settings-input { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 13px; + font-family: var(--mono); + padding: 8px 10px; + width: 100%; + outline: none; + } + .settings-input:focus { border-color: var(--accent); } + .settings-hint { font-size: 11px; color: var(--muted); } + .settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; } + .save-btn { + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + .save-btn:hover { opacity: 0.9; } + .save-btn:disabled { opacity: 0.5; cursor: default; } + .save-status { font-size: 12px; color: var(--muted); } + .save-status.ok { color: var(--green); } + .save-status.err { color: var(--red); } + /* Refresh indicator */ .refresh-bar { display: flex; @@ -364,6 +398,52 @@
+ +
+
Proxy Configuration
+
+
+
HTTP/HTTPS Proxy
+
http://127.0.0.1:8080
+
+
+
SOCKS5 Proxy
+
127.0.0.1:1080
+
+
+
+ + +
+
Settings
+
+
+ + + Leave blank to use official Tailscale servers. +
+
+ + + One-time use. Cleared automatically after first successful connection. +
+
+ + + Port for the outbound HTTP/HTTPS proxy. Default: 8080. +
+
+ + + Port for the SOCKS5 proxy. Default: 1080. +
+
+ + +
+
+
+
@@ -494,19 +574,34 @@ } } + // Parse proxy ports from log — use last match so old entries don't win + var httpPort = null; + var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g); + if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; } + var socksPort = null; + var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g); + if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; } + // Cache when found, restore from cache when missing cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version); + cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort); tsIP = tsIP || cacheGet('ip'); node = node || cacheGet('node'); tailnet = tailnet || cacheGet('tailnet'); version = version || cacheGet('version'); + httpPort = httpPort || cacheGet('http-port'); + socksPort = socksPort || cacheGet('socks-port'); var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; - var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); - // Detect running even after syslog rotation (daemon active but startup lines gone) - if (!isRunning && !latestUrl) { - isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || + var isRunning = /-> Running/.test(lastState); + + // Fallbacks only when syslog has rotated and no state transitions are visible. + // If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own + // "Tailscale VPN is running" message which stays in syslog indefinitely. + if (!isRunning && stateLines.length === 0) { + isRunning = /Tailscale VPN is running/.test(txt) || + /health\(warnable=[^)]+\): ok/.test(txt) || /derp-\d+ connected/.test(txt) || /c2n: GET/.test(txt) || /localapi:/.test(txt); @@ -515,17 +610,22 @@ // If an auth URL appears AFTER the last Running state, re-auth is needed // (handles stale Running entries in syslog after reinstall or token expiry) if (isRunning && latestUrl) { + // Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running' + // (our shell log). The shell log is written AFTER auth completes, so it correctly + // post-dates the auth URL when connection succeeds. var lastRunIdx = txt.lastIndexOf('-> Running'); + var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running'); + if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx; var urlSnippet = latestUrl.substring(0, 60); var lastUrlIdx = -1, upos = 0, uidx; while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; } if (lastUrlIdx > lastRunIdx) isRunning = false; } - if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version }; - if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version }; - if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version }; - return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version }; + if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort }; + if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; + if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; + return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; } function classifyLine(msg) { @@ -576,6 +676,10 @@ auth.style.display = 'none'; } + // Proxy card is always visible — update ports whenever known + if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort; + if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort; + if (r.state === 'connected') { document.getElementById('ts-ip').textContent = r.ip || '-'; document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim'); @@ -680,6 +784,79 @@ } return 0; } + + // Settings — load current param values and save on submit + var PARAM_URL = '/axis-cgi/param.cgi'; + var serverInput = document.getElementById('input-server'); + var authInput = document.getElementById('input-authkey'); + var httpPortInput = document.getElementById('input-http-port'); + var socksPortInput= document.getElementById('input-socks-port'); + var saveBtn = document.getElementById('save-btn'); + var saveStatus = document.getElementById('save-status'); + + 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=(.*)/); + 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(); + // 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; } + }) + .catch(function() {}); + } + + function setStatus(msg, cls) { + saveStatus.textContent = msg; + saveStatus.className = 'save-status' + (cls ? ' ' + cls : ''); + if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000); + } + + saveBtn.addEventListener('click', function() { + saveBtn.disabled = true; + setStatus('Saving...', ''); + var httpPort = httpPortInput.value.trim() || '8080'; + var socksPort = socksPortInput.value.trim() || '1080'; + var params = 'action=update' + + '&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) + + '&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) + + '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) + + '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort); + 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(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'); + } + }) + .catch(function(e) { + saveBtn.disabled = false; + setStatus('Failed to save', 'err'); + }); + }); + + loadSettings(); })(); diff --git a/arm/app/manifest.json b/arm/app/manifest.json index b54e69e..b93767f 100644 --- a/arm/app/manifest.json +++ b/arm/app/manifest.json @@ -1,5 +1,5 @@ { - "schemaVersion": "1.3", + "schemaVersion": "1.7.0", "acapPackageConf": { "setup": { "appName": "Tailscale_VPN", @@ -12,7 +12,29 @@ "architecture": "armv7hf" }, "configuration": { - "settingPage": "index.html" + "settingPage": "index.html", + "paramConfig": [ + { + "name": "CustomServer", + "default": "", + "type": "string" + }, + { + "name": "AuthKey", + "default": "", + "type": "string" + }, + { + "name": "HttpProxyPort", + "default": "8080", + "type": "string" + }, + { + "name": "Socks5Port", + "default": "1080", + "type": "string" + } + ] } } } diff --git a/arm/app/param_bridge.c b/arm/app/param_bridge.c new file mode 100644 index 0000000..315bb2a --- /dev/null +++ b/arm/app/param_bridge.c @@ -0,0 +1,251 @@ +// Copyright (C) 2024 Mo3he +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * ACAP parameter bridge for Tailscale VPN (userspace variant). + * + * Responsibilities: + * 1. Read Tailscale parameters from the ACAP parameter store (axparameter). + * 2. Write them to CONFIG_FILE so the shell script can source them. + * 3. Launch the shell script (Tailscale_VPN_run) as a child process. + * 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart + * of the child so the new config is picked up. + * Rapid changes within 300 ms are coalesced into a single restart. + * 5. Watchdog: if the child exits unexpectedly, restart it. + * + * Runs as the unprivileged 'sdk' ACAP user — no root required. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define APP_NAME "Tailscale_VPN" +#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" +#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" + +static pid_t child_pid = -1; +static guint reload_timer_id = 0; + +static char *cfg_custom_server = NULL; +static char *cfg_auth_key = NULL; +static char *cfg_http_proxy_port = NULL; +static char *cfg_socks5_port = NULL; + +static void cache_set(char **field, const char *value) { + if (!value) return; + free(*field); + *field = strdup(value); +} + +static const char *cache_get(char **field, const char *fallback) { + return (*field && **field) ? *field : fallback; +} + +/* ── child process management ──────────────────────────────────────────── */ + +static void stop_child(void) { + if (child_pid <= 0) + return; + kill(child_pid, SIGTERM); + for (int i = 0; i < 30; i++) { + int status; + if (waitpid(child_pid, &status, WNOHANG) == child_pid) { + child_pid = -1; + return; + } + usleep(100000); + } + syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL"); + kill(child_pid, SIGKILL); + waitpid(child_pid, NULL, 0); + child_pid = -1; +} + +static void start_child(void) { + stop_child(); + pid_t pid = fork(); + if (pid < 0) { + syslog(LOG_ERR, "fork failed: %s", strerror(errno)); + return; + } + if (pid == 0) { + execl(RUN_SCRIPT, RUN_SCRIPT, NULL); + syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno)); + _exit(1); + } + child_pid = pid; + syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid); +} + +/* ── watchdog ────────────────────────────────────────────────────────────── */ + +static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) { + if (child_pid > 0) { + int status; + pid_t ret = waitpid(child_pid, &status, WNOHANG); + if (ret == child_pid) { + int exit_code = WEXITSTATUS(status); + syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code); + child_pid = -1; + /* If child exited 0, auth succeeded — clear AuthKey via axparameter */ + if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) { + GError *err = NULL; + if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) { + free(cfg_auth_key); cfg_auth_key = strdup(""); + syslog(LOG_INFO, "AuthKey cleared after successful auth"); + } else { + syslog(LOG_WARNING, "failed to clear AuthKey: %s", + err ? err->message : "unknown"); + if (err) g_error_free(err); + } + } + start_child(); + } + } + return G_SOURCE_CONTINUE; +} + +/* ── config file ─────────────────────────────────────────────────────────── */ + +static void load_config_cache(AXParameter *handle) { + GError *error = NULL; + gchar *val = NULL; + +#define LOAD(name, field) \ + val = NULL; error = NULL; \ + if (ax_parameter_get(handle, name, &val, &error)) { \ + free(field); field = val ? strdup(val) : strdup(""); \ + g_free(val); val = NULL; \ + } else { \ + syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \ + error ? error->message : "unknown"); \ + if (error) { g_error_free(error); error = NULL; } \ + } + + LOAD("CustomServer", cfg_custom_server) + LOAD("AuthKey", cfg_auth_key) + LOAD("HttpProxyPort", cfg_http_proxy_port) + LOAD("Socks5Port", cfg_socks5_port) +#undef LOAD +} + +static void write_config_file(void) { + FILE *f = fopen(CONFIG_FILE, "w"); + if (!f) { + syslog(LOG_ERR, "cannot open config file %s: %s", + CONFIG_FILE, strerror(errno)); + return; + } + fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, "")); + fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, "")); + fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080")); + fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080")); + fclose(f); + chmod(CONFIG_FILE, 0600); + syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s", + cache_get(&cfg_http_proxy_port, "8080"), + cache_get(&cfg_socks5_port, "1080"), + cache_get(&cfg_custom_server, "(default)")); +} + +/* ── ACAP parameter callback ─────────────────────────────────────────────── */ + +static AXParameter *g_ax_handle = NULL; + +static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) { + reload_timer_id = 0; + if (g_ax_handle) + load_config_cache(g_ax_handle); + write_config_file(); + syslog(LOG_INFO, "restarting with new config"); + stop_child(); + start_child(); + return G_SOURCE_REMOVE; +} + +static void parameter_changed(const gchar *name, const gchar *value, + gpointer G_GNUC_UNUSED handle_void_ptr) { + const char *dot = strrchr(name, '.'); + const char *short_name = dot ? dot + 1 : name; + + syslog(LOG_INFO, "parameter changed: %s", short_name); + + if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value); + else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value); + else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value); + else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value); + + if (reload_timer_id) + g_source_remove(reload_timer_id); + reload_timer_id = g_timeout_add(300, debounced_restart, NULL); +} + +/* ── signal handler ──────────────────────────────────────────────────────── */ + +static gboolean signal_handler(gpointer loop) { + syslog(LOG_INFO, "stopping"); + stop_child(); + g_main_loop_quit((GMainLoop *)loop); + return G_SOURCE_REMOVE; +} + +/* ── main ────────────────────────────────────────────────────────────────── */ + +int main(void) { + GError *error = NULL; + + openlog(APP_NAME, LOG_PID, LOG_USER); + syslog(LOG_INFO, "starting"); + + /* Ensure localdata dir exists */ + mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); + + AXParameter *handle = ax_parameter_new(APP_NAME, &error); + if (!handle) { + syslog(LOG_ERR, "ax_parameter_new: %s", + error ? error->message : "unknown"); + if (error) g_error_free(error); + return 1; + } + g_ax_handle = handle; + + load_config_cache(handle); + write_config_file(); + start_child(); + + const char *params[] = { + "CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port" + }; + for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { + if (!ax_parameter_register_callback(handle, params[i], + parameter_changed, handle, &error)) { + syslog(LOG_WARNING, "register callback %s: %s", + params[i], error ? error->message : "unknown"); + if (error) { g_error_free(error); error = NULL; } + } + } + + GMainLoop *loop = g_main_loop_new(NULL, FALSE); + g_unix_signal_add(SIGTERM, signal_handler, loop); + g_unix_signal_add(SIGINT, signal_handler, loop); + g_timeout_add_seconds(60, watchdog_cb, NULL); + + syslog(LOG_INFO, "running — watching for parameter changes"); + g_main_loop_run(loop); + + g_main_loop_unref(loop); + ax_parameter_free(handle); + return 0; +} diff --git a/arm_ROOT/Dockerfile b/arm_ROOT/Dockerfile index 6b4f556..75b3bd6 100644 --- a/arm_ROOT/Dockerfile +++ b/arm_ROOT/Dockerfile @@ -1,5 +1,5 @@ ARG ARCH=armv7hf -ARG VERSION=1.3 +ARG VERSION=1.15.1 ARG UBUNTU_VERSION=22.04 ARG REPO=axisecp ARG SDK=acap-native-sdk @@ -10,4 +10,4 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION} COPY ./app /opt/app/ WORKDIR /opt/app RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled -RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ +RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./ diff --git a/arm_ROOT/app/Makefile b/arm_ROOT/app/Makefile index 74caba9..60ca613 100644 --- a/arm_ROOT/app/Makefile +++ b/arm_ROOT/app/Makefile @@ -1 +1,14 @@ -nop: \ No newline at end of file +PROG = Tailscale_VPN +SRCS = param_bridge.c +PKGS = axparameter glib-2.0 +CFLAGS += $(shell pkg-config --cflags $(PKGS)) +LDADD = $(shell pkg-config --libs $(PKGS)) + +all: $(PROG) + chmod +x Tailscale_VPN_run + +$(PROG): $(SRCS) + $(CC) $(CFLAGS) -o $@ $^ $(LDADD) + +clean: + rm -f $(PROG) diff --git a/arm_ROOT/app/Tailscale_VPN b/arm_ROOT/app/Tailscale_VPN deleted file mode 100755 index 54f1ee3..0000000 --- a/arm_ROOT/app/Tailscale_VPN +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh - -APP_DIR="/usr/local/packages/Tailscale_VPN" -STATE_DIR="$APP_DIR/localdata" - -logger -t "Tailscale_VPN" "Starting Tailscale VPN service (root mode)" - -mkdir -p "$STATE_DIR" -chmod 755 "$APP_DIR/lib/tailscale" -chmod 755 "$APP_DIR/lib/tailscaled" - -# Kill any leftover daemon from a previous run -killall tailscaled 2>/dev/null || true - -logger -t "Tailscale_VPN" "Starting tailscaled daemon" -"$APP_DIR/lib/tailscaled" \ - --state="$STATE_DIR/tailscaled.state" \ - --socket="$STATE_DIR/tailscaled.sock" \ - 2>&1 | logger -t "Tailscale_VPN" & -TAILSCALED_PID=$! - -sleep 2 - -logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)" -"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --accept-routes --hostname="$(hostname)" 2>&1 | logger -t "Tailscale_VPN" - -logger -t "Tailscale_VPN" "Tailscale VPN is running" -wait $TAILSCALED_PID -logger -t "Tailscale_VPN" "tailscaled exited" diff --git a/arm_ROOT/app/Tailscale_VPN_run b/arm_ROOT/app/Tailscale_VPN_run new file mode 100644 index 0000000..1bf556a --- /dev/null +++ b/arm_ROOT/app/Tailscale_VPN_run @@ -0,0 +1,48 @@ +#!/bin/sh +# Tailscale VPN run script (ROOT / kernel networking variant). +# Sources config from params.conf written by param_bridge. +killall tailscaled 2>/dev/null || true + +APP_DIR="/usr/local/packages/Tailscale_VPN" +STATE_DIR="$APP_DIR/localdata" +TAILSCALED_PATH="$APP_DIR/lib/tailscaled" +TAILSCALE_PATH="$APP_DIR/lib/tailscale" +SOCKET_PATH="$STATE_DIR/tailscaled.sock" + +mkdir -p "$STATE_DIR" +chmod 755 $TAILSCALED_PATH +chmod 755 $TAILSCALE_PATH + +CUSTOM_SERVER="" +AUTH_KEY="" + +if [ -f "$STATE_DIR/params.conf" ]; then + . "$STATE_DIR/params.conf" +fi + +logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}" + +$TAILSCALED_PATH \ + --state="$STATE_DIR/tailscaled.state" \ + --socket=$SOCKET_PATH \ + >/dev/null 2>&1 & +TAILSCALED_PID=$! + +sleep 2 + +TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)" + +if [ -n "$CUSTOM_SERVER" ]; then + TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" +fi + +if [ -n "$AUTH_KEY" ]; then + TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" +fi + +eval $TAILSCALE_CMD +UP_EXIT=$? + +logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)" + +wait $TAILSCALED_PID diff --git a/arm_ROOT/app/html/index.html b/arm_ROOT/app/html/index.html index e5a4419..515182e 100644 --- a/arm_ROOT/app/html/index.html +++ b/arm_ROOT/app/html/index.html @@ -238,6 +238,40 @@ .log-line .msg-err { color: var(--red); } .log-line .msg-ok { color: var(--green); } + /* Settings form */ + .settings-form { display: flex; flex-direction: column; gap: 12px; } + .settings-row { display: flex; flex-direction: column; gap: 4px; } + .settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); } + .settings-input { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 13px; + font-family: var(--mono); + padding: 8px 10px; + width: 100%; + outline: none; + } + .settings-input:focus { border-color: var(--accent); } + .settings-hint { font-size: 11px; color: var(--muted); } + .settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; } + .save-btn { + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + .save-btn:hover { opacity: 0.9; } + .save-btn:disabled { opacity: 0.5; cursor: default; } + .save-status { font-size: 12px; color: var(--muted); } + .save-status.ok { color: var(--green); } + .save-status.err { color: var(--red); } + /* Refresh indicator */ .refresh-bar { display: flex; @@ -364,6 +398,52 @@
+ +
+
Proxy Configuration
+
+
+
HTTP/HTTPS Proxy
+
http://127.0.0.1:8080
+
+
+
SOCKS5 Proxy
+
127.0.0.1:1080
+
+
+
+ + +
+
Settings
+
+
+ + + Leave blank to use official Tailscale servers. +
+
+ + + One-time use. Cleared automatically after first successful connection. +
+
+ + + Port for the outbound HTTP/HTTPS proxy. Default: 8080. +
+
+ + + Port for the SOCKS5 proxy. Default: 1080. +
+
+ + +
+
+
+
@@ -494,19 +574,34 @@ } } + // Parse proxy ports from log — use last match so old entries don't win + var httpPort = null; + var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g); + if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; } + var socksPort = null; + var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g); + if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; } + // Cache when found, restore from cache when missing cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version); + cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort); tsIP = tsIP || cacheGet('ip'); node = node || cacheGet('node'); tailnet = tailnet || cacheGet('tailnet'); version = version || cacheGet('version'); + httpPort = httpPort || cacheGet('http-port'); + socksPort = socksPort || cacheGet('socks-port'); var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; - var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); - // Detect running even after syslog rotation (daemon active but startup lines gone) - if (!isRunning && !latestUrl) { - isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || + var isRunning = /-> Running/.test(lastState); + + // Fallbacks only when syslog has rotated and no state transitions are visible. + // If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own + // "Tailscale VPN is running" message which stays in syslog indefinitely. + if (!isRunning && stateLines.length === 0) { + isRunning = /Tailscale VPN is running/.test(txt) || + /health\(warnable=[^)]+\): ok/.test(txt) || /derp-\d+ connected/.test(txt) || /c2n: GET/.test(txt) || /localapi:/.test(txt); @@ -515,17 +610,22 @@ // If an auth URL appears AFTER the last Running state, re-auth is needed // (handles stale Running entries in syslog after reinstall or token expiry) if (isRunning && latestUrl) { + // Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running' + // (our shell log). The shell log is written AFTER auth completes, so it correctly + // post-dates the auth URL when connection succeeds. var lastRunIdx = txt.lastIndexOf('-> Running'); + var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running'); + if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx; var urlSnippet = latestUrl.substring(0, 60); var lastUrlIdx = -1, upos = 0, uidx; while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; } if (lastUrlIdx > lastRunIdx) isRunning = false; } - if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version }; - if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version }; - if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version }; - return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version }; + if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort }; + if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; + if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; + return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort }; } function classifyLine(msg) { @@ -576,6 +676,10 @@ auth.style.display = 'none'; } + // Proxy card is always visible — update ports whenever known + if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort; + if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort; + if (r.state === 'connected') { document.getElementById('ts-ip').textContent = r.ip || '-'; document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim'); @@ -680,6 +784,79 @@ } return 0; } + + // Settings — load current param values and save on submit + var PARAM_URL = '/axis-cgi/param.cgi'; + var serverInput = document.getElementById('input-server'); + var authInput = document.getElementById('input-authkey'); + var httpPortInput = document.getElementById('input-http-port'); + var socksPortInput= document.getElementById('input-socks-port'); + var saveBtn = document.getElementById('save-btn'); + var saveStatus = document.getElementById('save-status'); + + 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=(.*)/); + 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(); + // 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; } + }) + .catch(function() {}); + } + + function setStatus(msg, cls) { + saveStatus.textContent = msg; + saveStatus.className = 'save-status' + (cls ? ' ' + cls : ''); + if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000); + } + + saveBtn.addEventListener('click', function() { + saveBtn.disabled = true; + setStatus('Saving...', ''); + var httpPort = httpPortInput.value.trim() || '8080'; + var socksPort = socksPortInput.value.trim() || '1080'; + var params = 'action=update' + + '&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) + + '&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) + + '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) + + '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort); + 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(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'); + } + }) + .catch(function(e) { + saveBtn.disabled = false; + setStatus('Failed to save', 'err'); + }); + }); + + loadSettings(); })(); diff --git a/arm_ROOT/app/manifest.json b/arm_ROOT/app/manifest.json index 673252f..6e39aa0 100644 --- a/arm_ROOT/app/manifest.json +++ b/arm_ROOT/app/manifest.json @@ -1,5 +1,5 @@ { - "schemaVersion": "1.3", + "schemaVersion": "1.7.0", "acapPackageConf": { "setup": { "appName": "Tailscale_VPN", @@ -16,7 +16,19 @@ "architecture": "armv7hf" }, "configuration": { - "settingPage": "index.html" + "settingPage": "index.html", + "paramConfig": [ + { + "name": "CustomServer", + "default": "", + "type": "string" + }, + { + "name": "AuthKey", + "default": "", + "type": "string" + } + ] } } } diff --git a/arm_ROOT/app/param_bridge.c b/arm_ROOT/app/param_bridge.c new file mode 100644 index 0000000..add6c4f --- /dev/null +++ b/arm_ROOT/app/param_bridge.c @@ -0,0 +1,215 @@ +// Copyright (C) 2024 Mo3he +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant). + * Same structure as regular param_bridge.c but without proxy port params. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define APP_NAME "Tailscale_VPN" +#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf" +#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run" + +static pid_t child_pid = -1; +static guint reload_timer_id = 0; + +static char *cfg_custom_server = NULL; +static char *cfg_auth_key = NULL; + +static void cache_set(char **field, const char *value) { + if (!value) return; + free(*field); + *field = strdup(value); +} + +static const char *cache_get(char **field, const char *fallback) { + return (*field && **field) ? *field : fallback; +} + +static void stop_child(void) { + if (child_pid <= 0) + return; + kill(child_pid, SIGTERM); + for (int i = 0; i < 30; i++) { + int status; + if (waitpid(child_pid, &status, WNOHANG) == child_pid) { + child_pid = -1; + return; + } + usleep(100000); + } + syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL"); + kill(child_pid, SIGKILL); + waitpid(child_pid, NULL, 0); + child_pid = -1; +} + +static void start_child(void) { + stop_child(); + pid_t pid = fork(); + if (pid < 0) { + syslog(LOG_ERR, "fork failed: %s", strerror(errno)); + return; + } + if (pid == 0) { + execl(RUN_SCRIPT, RUN_SCRIPT, NULL); + syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno)); + _exit(1); + } + child_pid = pid; + syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid); +} + +static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) { + if (child_pid > 0) { + int status; + pid_t ret = waitpid(child_pid, &status, WNOHANG); + if (ret == child_pid) { + int exit_code = WEXITSTATUS(status); + syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code); + child_pid = -1; + /* If child exited 0, auth succeeded — clear AuthKey via axparameter */ + if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) { + GError *err = NULL; + if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) { + free(cfg_auth_key); cfg_auth_key = strdup(""); + syslog(LOG_INFO, "AuthKey cleared after successful auth"); + } else { + syslog(LOG_WARNING, "failed to clear AuthKey: %s", + err ? err->message : "unknown"); + if (err) g_error_free(err); + } + } + start_child(); + } + } + return G_SOURCE_CONTINUE; +} + +static void load_config_cache(AXParameter *handle) { + GError *error = NULL; + gchar *val = NULL; + +#define LOAD(name, field) \ + val = NULL; error = NULL; \ + if (ax_parameter_get(handle, name, &val, &error)) { \ + free(field); field = val ? strdup(val) : strdup(""); \ + g_free(val); val = NULL; \ + } else { \ + syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \ + error ? error->message : "unknown"); \ + if (error) { g_error_free(error); error = NULL; } \ + } + + LOAD("CustomServer", cfg_custom_server) + LOAD("AuthKey", cfg_auth_key) +#undef LOAD +} + +static void write_config_file(void) { + FILE *f = fopen(CONFIG_FILE, "w"); + if (!f) { + syslog(LOG_ERR, "cannot open config file %s: %s", + CONFIG_FILE, strerror(errno)); + return; + } + fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, "")); + fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, "")); + fclose(f); + chmod(CONFIG_FILE, 0600); + syslog(LOG_INFO, "config updated: server=%s", + cache_get(&cfg_custom_server, "(default)")); +} + +static AXParameter *g_ax_handle = NULL; + +static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) { + reload_timer_id = 0; + if (g_ax_handle) + load_config_cache(g_ax_handle); + write_config_file(); + syslog(LOG_INFO, "restarting with new config"); + stop_child(); + start_child(); + return G_SOURCE_REMOVE; +} + +static void parameter_changed(const gchar *name, const gchar *value, + gpointer G_GNUC_UNUSED handle_void_ptr) { + const char *dot = strrchr(name, '.'); + const char *short_name = dot ? dot + 1 : name; + syslog(LOG_INFO, "parameter changed: %s", short_name); + + if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value); + else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value); + + if (reload_timer_id) + g_source_remove(reload_timer_id); + reload_timer_id = g_timeout_add(300, debounced_restart, NULL); +} + +static gboolean signal_handler(gpointer loop) { + syslog(LOG_INFO, "stopping"); + stop_child(); + g_main_loop_quit((GMainLoop *)loop); + return G_SOURCE_REMOVE; +} + +int main(void) { + GError *error = NULL; + + openlog(APP_NAME, LOG_PID, LOG_USER); + syslog(LOG_INFO, "starting (root mode)"); + + mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755); + + AXParameter *handle = ax_parameter_new(APP_NAME, &error); + if (!handle) { + syslog(LOG_ERR, "ax_parameter_new: %s", + error ? error->message : "unknown"); + if (error) g_error_free(error); + return 1; + } + g_ax_handle = handle; + + load_config_cache(handle); + write_config_file(); + start_child(); + + const char *params[] = { "CustomServer", "AuthKey" }; + for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { + if (!ax_parameter_register_callback(handle, params[i], + parameter_changed, handle, &error)) { + syslog(LOG_WARNING, "register callback %s: %s", + params[i], error ? error->message : "unknown"); + if (error) { g_error_free(error); error = NULL; } + } + } + + GMainLoop *loop = g_main_loop_new(NULL, FALSE); + g_unix_signal_add(SIGTERM, signal_handler, loop); + g_unix_signal_add(SIGINT, signal_handler, loop); + g_timeout_add_seconds(60, watchdog_cb, NULL); + + syslog(LOG_INFO, "running — watching for parameter changes"); + g_main_loop_run(loop); + + g_main_loop_unref(loop); + ax_parameter_free(handle); + return 0; +} diff --git a/arm_acap3/app/html/index.html b/arm_acap3/app/html/index.html index c474532..f082947 100644 --- a/arm_acap3/app/html/index.html +++ b/arm_acap3/app/html/index.html @@ -514,19 +514,21 @@ var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; - var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); - // Detect running even after syslog rotation (daemon active but startup lines gone) - if (!isRunning && !latestUrl) { - isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || + var isRunning = /-> Running/.test(lastState); + + if (!isRunning && stateLines.length === 0) { + isRunning = /Tailscale VPN is running/.test(txt) || + /health\(warnable=[^)]+\): ok/.test(txt) || /derp-\d+ connected/.test(txt) || /c2n: GET/.test(txt) || /localapi:/.test(txt); } // If an auth URL appears AFTER the last Running state, re-auth is needed - // (handles stale Running entries in syslog after reinstall or token expiry) if (isRunning && latestUrl) { var lastRunIdx = txt.lastIndexOf('-> Running'); + var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running'); + if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx; var urlSnippet = latestUrl.substring(0, 60); var lastUrlIdx = -1, upos = 0, uidx; while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; } diff --git a/aarch64_custom/app/lib/.gitkeep b/arm_acap3/app/lib/.gitkeep similarity index 100% rename from aarch64_custom/app/lib/.gitkeep rename to arm_acap3/app/lib/.gitkeep diff --git a/arm_custom/Dockerfile b/arm_custom/Dockerfile deleted file mode 100644 index 645543e..0000000 --- a/arm_custom/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -ARG ARCH=armv7hf -ARG VERSION=12.3.0 -ARG UBUNTU_VERSION=24.04 -ARG REPO=axisecp -ARG SDK=acap-native-sdk - -FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION} - -# Building the ACAP application -COPY ./app /opt/app/ -WORKDIR /opt/app -RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled -RUN . /opt/axis/acapsdk/environment-setup* && acap-build . diff --git a/arm_custom/app/LICENSE b/arm_custom/app/LICENSE deleted file mode 100644 index 9241b06..0000000 --- a/arm_custom/app/LICENSE +++ /dev/null @@ -1,29 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2020 Tailscale & AUTHORS. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/arm_custom/app/Makefile b/arm_custom/app/Makefile deleted file mode 100644 index 393a400..0000000 --- a/arm_custom/app/Makefile +++ /dev/null @@ -1,29 +0,0 @@ -PROGS = serverconfig -SRCS = config_updater.c -OBJS = $(SRCS:.c=.o) - -PKGS = glib-2.0 gio-2.0 axparameter - -CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) -LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) - -CFLAGS += -Wall \ - -Wextra \ - -Wformat=2 \ - -Wpointer-arith \ - -Wbad-function-cast \ - -Wstrict-prototypes \ - -Wmissing-prototypes \ - -Winline \ - -Wdisabled-optimization \ - -Wfloat-equal \ - -W \ - -Werror - -all: $(PROGS) - -$(PROGS): $(OBJS) - $(CC) $(LDFLAGS) $^ $(LIBS) $(LDLIBS) -o $@ - -clean: - rm -f $(PROGS) *.o *.eap* *_LICENSE.txt package.conf* param.conf tmp* \ No newline at end of file diff --git a/arm_custom/app/config_updater.c b/arm_custom/app/config_updater.c deleted file mode 100644 index 4fcfb43..0000000 --- a/arm_custom/app/config_updater.c +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Simple file-based configuration updater for Tailscale - * This avoids the AXParameter system and just writes directly to a config file - */ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define APP_NAME "serverconfig" -#define APP_DIR "/usr/local/packages/serverconfig" -#define STATE_DIR APP_DIR "/localdata" -#define CONFIG_FILE STATE_DIR "/config.txt" -#define SCRIPT_PATH "/usr/local/packages/serverconfig/start_tailscale.sh" -#define SCRIPT_SOURCE "/usr/local/packages/serverconfig/lib/start_tailscale.sh" - -static gboolean signal_handler(gpointer loop) { - g_main_loop_quit((GMainLoop*)loop); - syslog(LOG_INFO, "Configuration updater stopping."); - return G_SOURCE_REMOVE; -} - -// Create localdata directory -static void ensure_localdata_exists(void) { - struct stat st = {0}; - - if (stat(STATE_DIR, &st) == -1) { - if (mkdir(STATE_DIR, 0755) != 0) { - syslog(LOG_ERR, "Failed to create localdata directory: %s", strerror(errno)); - } else { - syslog(LOG_INFO, "Created localdata directory: %s", STATE_DIR); - } - } -} - -// Copy script from lib folder to main directory -static void copy_script_file(void) { - char buffer[4096]; - ssize_t bytes_read, bytes_written; - int source_fd, dest_fd; - - syslog(LOG_INFO, "Copying script from %s to %s", SCRIPT_SOURCE, SCRIPT_PATH); - - // Open source file - source_fd = open(SCRIPT_SOURCE, O_RDONLY); - if (source_fd < 0) { - syslog(LOG_ERR, "Failed to open source script: %s", strerror(errno)); - return; - } - - // Open destination file (create if doesn't exist, truncate if exists) - dest_fd = open(SCRIPT_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0755); - if (dest_fd < 0) { - syslog(LOG_ERR, "Failed to open destination script: %s", strerror(errno)); - close(source_fd); - return; - } - - // Copy the file - while ((bytes_read = read(source_fd, buffer, sizeof(buffer))) > 0) { - bytes_written = write(dest_fd, buffer, bytes_read); - if (bytes_written != bytes_read) { - syslog(LOG_ERR, "Error writing to destination file: %s", strerror(errno)); - close(source_fd); - close(dest_fd); - return; - } - } - - // Close file descriptors - close(source_fd); - close(dest_fd); - - // Make the script executable - if (chmod(SCRIPT_PATH, 0755) != 0) { - syslog(LOG_ERR, "Failed to make script executable: %s", strerror(errno)); - return; - } - - syslog(LOG_INFO, "Script copied and made executable successfully"); -} - -// Execute the Tailscale script -static void start_tailscale(void) { - syslog(LOG_INFO, "Starting Tailscale VPN script"); - - // Check if script exists, if not, copy it - struct stat st; - if (stat(SCRIPT_PATH, &st) != 0) { - syslog(LOG_INFO, "Script not found at %s, copying from lib folder", SCRIPT_PATH); - copy_script_file(); - } - - // Fork and execute the script - pid_t pid = fork(); - if (pid < 0) { - syslog(LOG_ERR, "Failed to fork for Tailscale script: %s", strerror(errno)); - return; - } else if (pid == 0) { - // Child process - execute the script - execl(SCRIPT_PATH, "start_tailscale.sh", NULL); - - // If we get here, execl failed - syslog(LOG_ERR, "Failed to execute Tailscale script: %s", strerror(errno)); - _exit(1); - } - - syslog(LOG_INFO, "Tailscale script started with PID: %d", pid); -} - -// Update the configuration file with current parameter values -static void update_config_file(AXParameter* handle) { - GError* error = NULL; - gchar* server_value = NULL; - gchar* key_value = NULL; - FILE* file; - - // Ensure localdata directory exists - ensure_localdata_exists(); - - // Get parameter values - if (!ax_parameter_get(handle, "CustomServer", &server_value, &error)) { - syslog(LOG_ERR, "Failed to get CustomServer: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - error = NULL; - server_value = g_strdup(""); - } - - if (!ax_parameter_get(handle, "AuthKey", &key_value, &error)) { - syslog(LOG_ERR, "Failed to get AuthKey: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - key_value = g_strdup(""); - } - - // Write to config file in localdata - file = fopen(CONFIG_FILE, "w"); - if (file) { - fprintf(file, "custom_server=%s\n", server_value ? server_value : ""); - fprintf(file, "auth_key=%s\n", key_value ? key_value : ""); - fclose(file); - - // Set permissions to ensure the file is readable - chmod(CONFIG_FILE, 0644); - - syslog(LOG_INFO, "Updated configuration file in local custom_server=%s", - server_value ? server_value : ""); - syslog(LOG_INFO, "Updated configuration file in local auth_key=%s", - key_value && strlen(key_value) > 0 ? "(set)" : "(empty)"); - } else { - syslog(LOG_ERR, "Failed to open config file for writing: %s", strerror(errno)); - } - - // Clean up - g_free(server_value); - g_free(key_value); -} - -// Handle parameter changes -static void parameter_changed(const gchar* name, const gchar* value, gpointer handle_void_ptr) { - AXParameter* handle = handle_void_ptr; - - // Extract simple parameter name from the fully qualified name - const char* simple_name = name; - const char* prefix = "root." APP_NAME "."; - if (strncmp(name, prefix, strlen(prefix)) == 0) { - simple_name = name + strlen(prefix); - } - - syslog(LOG_INFO, "Parameter changed: %s = %s", simple_name, value); - - // Update config file whenever any parameter changes - update_config_file(handle); - - // Restart Tailscale to apply the new settings - start_tailscale(); -} - -int main(void) { - GError* error = NULL; - GMainLoop* loop = NULL; - - // Open syslog for logging - openlog(APP_NAME, LOG_PID, LOG_USER); - syslog(LOG_INFO, "Config updater starting"); - - // Initialize parameter handling - AXParameter* handle = ax_parameter_new(APP_NAME, &error); - if (handle == NULL) { - syslog(LOG_ERR, "Failed to initialize parameters: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - exit(1); - } - - // Ensure localdata directory exists - ensure_localdata_exists(); - - // Ensure script is copied from lib folder - copy_script_file(); - - // Create initial config file - update_config_file(handle); - - // Start Tailscale VPN script - start_tailscale(); - - // Register for parameter changes - if (!ax_parameter_register_callback(handle, "CustomServer", parameter_changed, handle, &error)) { - syslog(LOG_ERR, "Failed to register CustomServer callback: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - error = NULL; - } - - if (!ax_parameter_register_callback(handle, "AuthKey", parameter_changed, handle, &error)) { - syslog(LOG_ERR, "Failed to register AuthKey callback: %s", - error ? error->message : "unknown error"); - if (error) g_error_free(error); - } - - // Register for parameter changes with fully qualified names as fallback - if (!ax_parameter_register_callback(handle, "root." APP_NAME ".CustomServer", parameter_changed, handle, NULL)) { - syslog(LOG_INFO, "Fallback CustomServer registration failed (this may be normal)"); - } - if (!ax_parameter_register_callback(handle, "root." APP_NAME ".AuthKey", parameter_changed, handle, NULL)) { - syslog(LOG_INFO, "Fallback AuthKey registration failed (this may be normal)"); - } - - // Set up main loop - loop = g_main_loop_new(NULL, FALSE); - g_unix_signal_add(SIGTERM, signal_handler, loop); - g_unix_signal_add(SIGINT, signal_handler, loop); - - syslog(LOG_INFO, "Config updater running. Waiting for parameter changes..."); - g_main_loop_run(loop); - - // Clean up - g_main_loop_unref(loop); - ax_parameter_free(handle); - - return 0; -} diff --git a/arm_custom/app/html/index.html b/arm_custom/app/html/index.html deleted file mode 100644 index 9358891..0000000 --- a/arm_custom/app/html/index.html +++ /dev/null @@ -1,695 +0,0 @@ - - - - - Tailscale VPN - - - - -
-
- - - - - - - - - - - - -

Tailscale VPN

-
- -
- - -
- - Checking... - -
- - -
-
Update available:
- - - Download - -
- - - - - - - - -
-
-
Service Log
-
- - -
-
-
Loading logs...
-
- -
- - Auto-refresh every 5s -
- - - - - diff --git a/arm_custom/app/lib/.gitkeep b/arm_custom/app/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/arm_custom/app/lib/start_tailscale.sh b/arm_custom/app/lib/start_tailscale.sh deleted file mode 100644 index 969dbc3..0000000 --- a/arm_custom/app/lib/start_tailscale.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/bin/sh -# Make sure this script terminates any existing Tailscale processes before starting new ones - -# Kill any existing tailscaled processes -killall tailscaled 2>/dev/null || true - -# Simple script to start Tailscale with custom configuration -APP_DIR="/usr/local/packages/serverconfig" -STATE_DIR="$APP_DIR/localdata" -CONFIG_FILE="$STATE_DIR/config.txt" -TAILSCALED_PATH="$APP_DIR/lib/tailscaled" -TAILSCALE_PATH="$APP_DIR/lib/tailscale" -SOCKET_PATH="$STATE_DIR/tailscaled.sock" - -# Create localdata directory if it doesn't exist -mkdir -p "$STATE_DIR" - -# Log to syslog -logger -t "tailscale_script" "Starting Tailscale VPN service" - -# Set execute permissions -chmod 755 $TAILSCALED_PATH -chmod 755 $TAILSCALE_PATH - -# Read configuration (if exists) -CUSTOM_SERVER="" -AUTH_KEY="" -if [ -f "$CONFIG_FILE" ]; then - logger -t "tailscale_script" "Reading configuration from $CONFIG_FILE" - # Read values from config file - while IFS='=' read -r key value; do - case "$key" in - "custom_server") CUSTOM_SERVER="$value" ;; - "auth_key") AUTH_KEY="$value" ;; - esac - done < "$CONFIG_FILE" -fi - -# Start tailscaled with state stored in localdata -logger -t "tailscale_script" "Starting tailscaled daemon" -$TAILSCALED_PATH \ - --state="$STATE_DIR/tailscaled.state" \ - --socket=$SOCKET_PATH \ - --socks5-server=localhost:1055 \ - --outbound-http-proxy-listen=localhost:8080 \ - --tun=userspace-networking \ - 2>&1 | logger -t "tailscale_script" & -TAILSCALED_PID=$! - -# Wait for tailscaled to initialize -sleep 2 - -# Build up the command based on available parameters -TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)" - -if [ -n "$CUSTOM_SERVER" ]; then - logger -t "tailscale_script" "Using custom server: $CUSTOM_SERVER" - TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER" -fi - -if [ -n "$AUTH_KEY" ]; then - logger -t "tailscale_script" "Using authentication key" - TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY" -fi - -# Connect to Tailscale network -logger -t "tailscale_script" "Running: $TAILSCALE_CMD" -eval $TAILSCALE_CMD -UP_EXIT=$? - -# Clear auth key from config after first use — Tailscale auth keys are single-use -# and the node identity is persisted in tailscaled.state, so the key is no longer needed. -if [ -n "$AUTH_KEY" ] && [ "$UP_EXIT" -eq 0 ] && [ -f "$CONFIG_FILE" ]; then - logger -t "tailscale_script" "Clearing auth key from config after successful authentication" - printf 'custom_server=%s\nauth_key=\n' "$CUSTOM_SERVER" > "$CONFIG_FILE" -fi - -# Keep the script running to maintain the tailscaled process -logger -t "tailscale_script" "Tailscale VPN is running" -logger -t "tailscale_script" "HTTP/HTTPS proxy: http://127.0.0.1:8080" -logger -t "tailscale_script" "SOCKS5 proxy: 127.0.0.1:1055" -logger -t "tailscale_script" "To change settings, modify parameters in ACAP web interface" - -# Wait for tailscaled process to exit -wait $TAILSCALED_PID diff --git a/arm_custom/app/manifest.json b/arm_custom/app/manifest.json deleted file mode 100644 index e7d9198..0000000 --- a/arm_custom/app/manifest.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "schemaVersion": "1.7.3", - "acapPackageConf": { - "setup": { - "friendlyName": "Tailscale VPN", - "appName": "serverconfig", - "vendor": "Mo3he", - "embeddedSdkVersion": "3.0", - "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", - "runMode": "respawn", - "version": "1.96.4", - "architecture": "armv7hf" - }, - "configuration": { - "settingPage": "index.html", - "paramConfig": [ - { - "name": "CustomServer", - "default": "", - "type": "string" - }, - { - "name": "AuthKey", - "default": "", - "type": "string" - } - ] - } - } -}