Compare commits

..
Author SHA1 Message Date
Weston Blieden 47c3894002 Consolidate variants into common/app and prepare for AXIS OS 13
Much of this work is AXIS OS 13 preparation. Of the OS 13 breaking
changes, all are now addressed except one: recompiled against the
updated SDK for 64-bit time (Y2038), migrated to Manifest Schema v2
with declared OS compatibility, audited all binaries for executable
stack (all clean, GNU_STACK rw-), and verified the web UI end to end
over HTTPS. The only outstanding item is signing through the Axis
ACAP Portal, pending a registered vendorId.

The four ACAP 4 variants (aarch64, armv7hf, and their ROOT versions)
carried byte-identical copies of the C bridge, run script, web UI, and
Makefile per architecture, diverging only between standard and ROOT.
Merge them into a single common/app/ tree:

- param_bridge.c: proxy-port parameters gated behind -DHAS_PROXY_PORTS
  (set via EXTRA_CFLAGS in the standard Dockerfiles); ROOT builds omit
  them as before
- Tailscale_VPN_run: variant passed as $1 ("standard"/"root") selects
  userspace vs kernel networking, port-collision checks, and IP
  forwarding for advertised routes
- index.html: detects proxy support at runtime from the settings
  response, hiding the proxy card and keeping the params out of save
  requests on ROOT builds (fixes ROOT UI always showing proxy fields
  and falsely reporting save errors)

Standard variants move to ACAP Native SDK 12.10.0 and Manifest Schema
v2 (vendorId, compatibleOsVersions); verified installable and working
on OS 10.12, 11.11, and 12.10, so OS 13 readiness costs no backward
compatibility. ROOT variants intentionally stay on SDK 1.15.1 since
OS 12+ never runs root apps.

All builds (including arm_acap3) now use the repository root as build
context with -f <variant>/Dockerfile; CI updated accordingly and a
.dockerignore added to keep the context lean. Tailscale binaries are
no longer tracked in git; *.eap outputs are now gitignored.

README: correct the standard variant's floor to OS 10.12+ and ROOT to
10.12-11.x (both live-verified), update build/update instructions for
the shared tree, and check off completed OS 13 readiness items.
2026-07-03 10:38:14 +02:00
43 changed files with 211 additions and 5221 deletions
+12
View File
@@ -0,0 +1,12 @@
# All variants build from the repository root (docker build -f <variant>/Dockerfile .)
# so keep the context lean. Do NOT exclude common/app/ or <variant>/app/ — the
# Dockerfiles COPY those, including the Tailscale binaries placed in app/lib/.
.git
.github
.DS_Store
*.eap
*.tgz
build
releases
tailscale_bins
README.md
+16 -6
View File
@@ -110,17 +110,27 @@ jobs:
mkdir -p releases
for folder in */ ; do
[[ ! -d "$folder/app" ]] && continue
FOLDER_NAME="${folder%/}" # remove trailing slash
[[ "$FOLDER_NAME" == "common" ]] && continue
[[ ! -d "$folder/app" ]] && continue
echo "Processing folder $FOLDER_NAME"
# aarch64/arm/aarch64_ROOT/arm_ROOT share their C source, run script,
# HTML, and Makefile via common/app/ (see Dockerfile COPY layers);
# only arm_acap3 still carries its own self-contained app/ tree.
case "$FOLDER_NAME" in
aarch64|arm|aarch64_ROOT|arm_ROOT) APP_LIB_DIR="common/app/lib" ;;
*) APP_LIB_DIR="$folder/app/lib" ;;
esac
mkdir -p "$APP_LIB_DIR"
# Detect architecture
if [[ "$FOLDER_NAME" == arm* ]]; then
cp tailscale_bins/tailscale_arm "$folder/app/lib/tailscale"
cp tailscale_bins/tailscaled_arm "$folder/app/lib/tailscaled"
cp tailscale_bins/tailscale_arm "$APP_LIB_DIR/tailscale"
cp tailscale_bins/tailscaled_arm "$APP_LIB_DIR/tailscaled"
else
cp tailscale_bins/tailscale_arm64 "$folder/app/lib/tailscale"
cp tailscale_bins/tailscaled_arm64 "$folder/app/lib/tailscaled"
cp tailscale_bins/tailscale_arm64 "$APP_LIB_DIR/tailscale"
cp tailscale_bins/tailscaled_arm64 "$APP_LIB_DIR/tailscaled"
fi
# Detect variant suffix for .eap naming
@@ -145,7 +155,7 @@ jobs:
# Docker build
TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean
echo "Building $TAG_NAME"
docker build -f "$folder/Dockerfile" --tag "$TAG_NAME" "$folder"
docker build -f "$folder/Dockerfile" --tag "$TAG_NAME" .
# Extract .eap files into build folder
EAP_OUTPUT="./build/${TAG_NAME}"
+2 -1
View File
@@ -1,9 +1,10 @@
**/.DS_Store
**/build
# Do not track release artifacts
# Do not track release artifacts (local .eap build outputs stay untracked anywhere in the tree)
releases/
build/
*.eap
# Do not track downloaded Tailscale tarballs and temp bins
tailscale_bins/
+22 -16
View File
@@ -6,7 +6,7 @@ This repository provides an **ACAP package** that installs the [Tailscale VPN cl
- Secure remote access to cameras
- Easy to install via EAP package
- Works on **Axis OS 11.11+** (non-root version)
- Works on **Axis OS 10.12+** (non-root version, verified across 10.12–12.10)
- Works on **legacy Axis OS 9.x / 10.x** via the ACAP 3 variant
- Based on **WireGuard VPN** technology
@@ -140,7 +140,7 @@ Then, in the camera's **System → Storage → Add network share** dialog, use `
### Manual update (advanced)
Replace the binaries in the `lib/` folder:
Replace the binaries in `common/app/lib/` (shared by `aarch64`, `armv7hf`, and their ROOT variants) or `arm_acap3/app/lib/` (legacy variant, kept separate):
- `tailscale`
- `tailscaled`
@@ -148,22 +148,26 @@ Download the latest versions: [Tailscale static builds](https://pkgs.tailscale.c
#### Build locally
From the main directory of the version you want (`arm` / `aarch64`):
The Tailscale binaries are not stored in git, so first download them (see [Manual update](#manual-update-advanced) above) and place them in `common/app/lib/` — or `arm_acap3/app/lib/` for the legacy variant.
All variants build from the **repository root**, pointing at the variant's own `Dockerfile`:
```bash
docker build --tag <package_name> .
docker build -f aarch64/Dockerfile --tag <package_name> .
docker cp $(docker create <package_name>):/opt/app ./build
```
(Same for the others — just swap in `arm/Dockerfile`, `aarch64_ROOT/Dockerfile`, `arm_ROOT/Dockerfile`, or `arm_acap3/Dockerfile`.)
---
## Good News
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 11.11+**.
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 10.12+** — verified working across Axis OS 10.12, 11.11, and 12.10.
- Runs in **user space networking mode**.
For **full kernel networking**, use the **ROOT** version. Note: ROOT mode requires Axis OS 11.11–11.x — Axis OS 12 and later removed root access for third-party applications.
For **full kernel networking**, use the **ROOT** version. Note: ROOT mode requires Axis OS 10.12–11.x — Axis OS 12 and later removed root access for third-party applications.
### Legacy camera support (Axis OS 9.x / 10.x)
@@ -196,13 +200,15 @@ The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**-
| Variant | Architecture | Axis OS | Notes |
|---|---|---|---|
| `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 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
| `armv7hf_root` | ARMv7 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
| `aarch64` | AArch64 | 10.12 – 13 (ACAP 4) | Standard, userspace networking, configurable proxy ports |
| `armv7hf` | ARMv7 | 10.12 – 13 (ACAP 4) | Standard, userspace networking, configurable proxy ports |
| `aarch64_root` | AArch64 | 10.12 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
| `armv7hf_root` | ARMv7 | 10.12 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
| `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 12+ → use the standard variant (`aarch64` or `armv7hf`). Axis OS 11.11–11.x → standard variant, or ROOT if you need kernel networking. Axis OS 9.x/10.x on ARMv7 → use `armv7hf_acap3`.
> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 12+ → use the standard variant (`aarch64` or `armv7hf`). Axis OS 10.12–11.x → standard variant works too, or ROOT if you need kernel networking. Axis OS 9.x → use `armv7hf_acap3`.
>
> The standard variant's floor was verified by live-testing the same build on Axis OS 10.12.300, 11.11.212, and 12.10.68 — it is not limited to 11.11+ as earlier releases implied. The ROOT variant was also verified on Axis OS 10.12.300 with genuine kernel networking confirmed over SSH (processes running as `root`, a real `tailscale0` kernel interface present, and `ip_forward` correctly toggling on when subnet routes are advertised) — Axis OS 10.x ran third-party apps as root by default, before the privilege sandboxing introduced later, so ROOT was never actually limited to 11.11+.
You can verify your device details using the following command:
@@ -221,11 +227,11 @@ curl --anyauth "*" -u <username>:<password> <device_ip>/axis-cgi/basicdeviceinfo
AXIS OS 13 (scheduled for September 2026) introduces several breaking changes that affect all ACAP applications. The following items are required to maintain compatibility. See the full [AXIS OS 13 breaking changes](https://www.axis.com/for-developers/news/AXIS-OS-13-breaking-changes) announcement for details.
- [ ] **Recompile for 64-bit time (Y2038)** - AXIS OS 13 switches to a 64-bit time interface. All ACAP apps must be recompiled against the updated SDK. Cameras with incompatible apps installed will roll back the OS upgrade rather than proceed.
- [ ] **Sign the ACAP via the Axis ACAP Portal** - AXIS OS 13 removes the ability to install unsigned applications in production environments. The app must be submitted and signed through the official Axis ACAP Portal to remain installable.
- [ ] **Migrate to Manifest Schema v2** - The `manifest.json` must use Manifest Schema v2, including an explicit declaration of compatible AXIS OS versions, to satisfy the new signing and compatibility requirements.
- [ ] **Audit for executable stack usage** - Any ACAP compiled with an executable stack must be recompiled to comply with the new security restrictions in AXIS OS 13.
- [ ] **Verify web UI works over HTTPS** - AXIS OS 13 enforces HTTPS-only connections by default. The bundled web UI must be tested to confirm it functions correctly under this constraint.
- [x] **Recompile for 64-bit time (Y2038)** - AXIS OS 13 switches to a 64-bit time interface. All ACAP apps must be recompiled against the updated SDK. Cameras with incompatible apps installed will roll back the OS upgrade rather than proceed. Done for the standard `aarch64`/`armv7hf` builds (now built against ACAP Native SDK 12.10.0); the ROOT variants intentionally stay on the older SDK since Axis OS 12+ never supports root third-party apps, so they can never reach OS 13 regardless.
- [x] **Migrate to Manifest Schema v2** - The `manifest.json` must use Manifest Schema v2, including an explicit declaration of compatible AXIS OS versions, to satisfy the new signing and compatibility requirements. Done for `aarch64`/`armv7hf` (schema 2.0.0, `compatibleOsVersions` declared); verified this does not break installability on older firmware (OS 10.12–12.10 all tested and working) before promoting it as the standard build.
- [x] **Audit for executable stack usage** - Any ACAP compiled with an executable stack must be recompiled to comply with the new security restrictions in AXIS OS 13. Checked all compiled binaries (`param_bridge` for `aarch64`/`armv7hf`, both standard and ROOT, plus the bundled `tailscale`/`tailscaled` Go binaries) via `objdump`'s `GNU_STACK` program header — all report `flags rw-` (no executable stack) on every architecture and variant.
- [x] **Verify web UI works over HTTPS** - AXIS OS 13 enforces HTTPS-only connections by default. The bundled web UI must be tested to confirm it functions correctly under this constraint. Verified live: the page and every endpoint it calls (`param.cgi` GET/update, the `reverseProxy` settings API GET/POST, `applications/list.cgi`, `systemlog.cgi`, the restart trigger) all work correctly over HTTPS. The UI only ever issues relative-path requests (no hardcoded `http://` fetch targets), so it inherits the page's own protocol with no mixed-content risk.
- [ ] **Sign the ACAP via the Axis ACAP Portal** - AXIS OS 13 removes the ability to install unsigned applications in production environments. The app must be submitted and signed through the official Axis ACAP Portal to remain installable. Deferred for now — the manifest's `vendorId` is a placeholder value, not yet a portal-registered one.
### General Improvements
+5 -3
View File
@@ -1,13 +1,15 @@
ARG ARCH=aarch64
ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04
ARG VERSION=12.10.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/
COPY common/app /opt/app/
COPY aarch64/app/manifest.json /opt/app/manifest.json
WORKDIR /opt/app
ENV EXTRA_CFLAGS=-DHAS_PROXY_PORTS
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
+9 -4
View File
@@ -1,15 +1,20 @@
{
"schemaVersion": "1.7.0",
"schemaVersion": "2.0.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "Mo3he",
"embeddedSdkVersion": "3.0",
"vendorId": "5741c1fb91",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.98.8",
"architecture": "aarch64"
"architecture": "aarch64",
"runMode": "respawn",
"compatibleOsVersions": [
{
"max": "13"
}
]
},
"configuration": {
"settingPage": "index.html",
+2 -1
View File
@@ -7,7 +7,8 @@ ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY ./app /opt/app/
COPY common/app /opt/app/
COPY aarch64_ROOT/app/manifest.json /opt/app/manifest.json
WORKDIR /opt/app
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
-29
View File
@@ -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.
-14
View File
@@ -1,14 +0,0 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0 gio-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)
-123
View File
@@ -1,123 +0,0 @@
#!/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=""
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
ADVERTISE_ROUTES=""
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 --reset --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
if [ "$ACCEPT_DNS" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
fi
if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In kernel-networking (root) mode the
# host must forward packets between the tailnet and the LAN, so enable IP
# forwarding. Routes must still be approved in the Tailscale admin console.
if [ -n "$ADVERTISE_ROUTES" ]; then
echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
echo 1 > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
fi
# Run `tailscale up` in the background and act on its outcome. If the node needs
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
# ensures the status publisher below keeps running so the UI can surface the
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
# code is captured directly. We must NOT background `up` separately and `wait`
# for it from here, because in POSIX sh `wait` only works on children of the
# current shell — a subshell waiting on the parent's child returns 127.
{
eval "$TAILSCALE_CMD"
up_exit=$?
if [ "$up_exit" -eq 0 ]; then
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
if [ -n "$AUTH_KEY" ]; then
: > "$STATE_DIR/authkey_clear"
fi
else
logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)"
fi
} &
TAILSCALE_UP_PID=$!
# Publish tailscale's real backend state as JSON for the web UI to consume.
# This is the authoritative connection signal (BackendState / Self.Online /
# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at
# /local/Tailscale_VPN/status.json.
STATUS_FILE="$APP_DIR/html/status.json"
publish_status() {
if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
chmod 644 "$STATUS_FILE" 2>/dev/null
else
rm -f "$STATUS_FILE.tmp" 2>/dev/null
fi
}
status_loop() {
while true; do
publish_status
sleep 5
done
}
status_loop &
STATUS_LOOP_PID=$!
# Clean up the status writer, up watcher, daemon and published status on
# stop/restart so param_bridge (which signals this script) leaves no orphans or
# stale state.
cleanup() {
[ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null
[ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
rm -f "$STATUS_FILE" 2>/dev/null
exit 0
}
trap cleanup TERM INT
wait $TAILSCALED_PID
View File
Binary file not shown.
Binary file not shown.
-507
View File
@@ -1,507 +0,0 @@
// 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 <axsdk/axparameter.h>
#include <glib-unix.h>
#include <gio/gio.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static char *cfg_advertise_routes = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
* upgrades a newly introduced manifest parameter is not always auto-registered,
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
GError *err = NULL;
if (!ax_parameter_add(handle, name, def, "string", &err)) {
if (err) g_error_free(err);
}
}
static void stop_child(void) {
if (child_pid <= 0)
return;
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", "", TRUE, &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;
}
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
* disappears from the settings UI. This replaces the old exit-code-0 path,
* which never fired because tailscaled keeps the child alive indefinitely. */
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
if (access(SENTINEL_FILE, F_OK) != 0)
return G_SOURCE_CONTINUE;
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
unlink(SENTINEL_FILE);
return G_SOURCE_CONTINUE;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#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("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
LOAD("AdvertiseRoutes", cfg_advertise_routes)
#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, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
}
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, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
* settings through it. This tiny HTTP server, reached through the manifest
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
* fall back to reading and writing the parameters directly. */
#define HTTP_PORT 2201
static const char *http_param_names[] = {
"CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
static void cache_set_by_name(const char *name, const char *value) {
if (strcmp(name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
}
static int http_is_known_param(const char *name) {
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++)
if (strcmp(name, http_param_names[i]) == 0) return 1;
return 0;
}
static void http_json_append_escaped(GString *out, const char *s) {
for (const char *p = s; *p; p++) {
switch (*p) {
case '"': g_string_append(out, "\\\""); break;
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
case '\t': g_string_append(out, "\\t"); break;
default:
if ((unsigned char)*p < 0x20)
g_string_append_printf(out, "\\u%04x", (unsigned char)*p);
else
g_string_append_c(out, *p);
}
}
}
static gchar *http_build_settings_json(AXParameter *handle) {
GString *out = g_string_new("{");
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++) {
gchar *val = NULL;
GError *err = NULL;
if (!ax_parameter_get(handle, http_param_names[i], &val, &err)) {
if (err) g_error_free(err);
val = g_strdup("");
}
if (i) g_string_append_c(out, ',');
g_string_append_printf(out, "\"%s\":\"", http_param_names[i]);
http_json_append_escaped(out, val ? val : "");
g_string_append_c(out, '"');
g_free(val);
}
g_string_append_c(out, '}');
return g_string_free(out, FALSE);
}
static gchar *http_url_decode(const char *s, size_t len) {
GString *out = g_string_new(NULL);
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (c == '+') {
g_string_append_c(out, ' ');
} else if (c == '%' && i + 2 < len &&
g_ascii_isxdigit(s[i + 1]) && g_ascii_isxdigit(s[i + 2])) {
int hi = g_ascii_xdigit_value(s[i + 1]);
int lo = g_ascii_xdigit_value(s[i + 2]);
g_string_append_c(out, (char)((hi << 4) | lo));
i += 2;
} else {
g_string_append_c(out, c);
}
}
return g_string_free(out, FALSE);
}
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
* the parameter store. Returns the number of parameters successfully set. */
static int http_apply_settings(AXParameter *handle, const char *body, size_t len) {
int applied = 0;
size_t start = 0;
for (size_t i = 0; i <= len; i++) {
if (i == len || body[i] == '&') {
size_t seg_len = i - start;
if (seg_len > 0) {
const char *seg = body + start;
const char *eq = memchr(seg, '=', seg_len);
if (eq) {
size_t nlen = (size_t)(eq - seg);
gchar *name = g_strndup(seg, nlen);
gchar *value = http_url_decode(eq + 1, seg_len - nlen - 1);
if (http_is_known_param(name)) {
GError *err = NULL;
if (ax_parameter_set(handle, name, value, TRUE, &err)) {
cache_set_by_name(name, value);
applied++;
} else {
syslog(LOG_WARNING, "http set %s failed: %s",
name, err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
g_free(name);
g_free(value);
}
}
start = i + 1;
}
}
return applied;
}
static size_t http_parse_content_length(const char *hdr, size_t hlen) {
const char *key = "content-length:";
size_t klen = strlen(key);
for (size_t i = 0; i + klen <= hlen; i++) {
if (g_ascii_strncasecmp(hdr + i, key, klen) == 0) {
i += klen;
while (i < hlen && (hdr[i] == ' ' || hdr[i] == '\t')) i++;
return (size_t)strtoul(hdr + i, NULL, 10);
}
}
return 0;
}
static void http_send(GOutputStream *out, const char *status,
const char *ctype, const char *body) {
gchar *resp = g_strdup_printf(
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
status, ctype, strlen(body), body);
g_output_stream_write_all(out, resp, strlen(resp), NULL, NULL, NULL);
g_free(resp);
}
static gboolean http_on_incoming(GSocketService *service G_GNUC_UNUSED,
GSocketConnection *connection,
GObject *source G_GNUC_UNUSED,
gpointer user_data) {
AXParameter *handle = (AXParameter *)user_data;
GInputStream *in = g_io_stream_get_input_stream(G_IO_STREAM(connection));
GOutputStream *out = g_io_stream_get_output_stream(G_IO_STREAM(connection));
GString *req = g_string_new(NULL);
char buf[2048];
int have_headers = 0;
size_t header_end = 0;
size_t content_length = 0;
while (1) {
gssize n = g_input_stream_read(in, buf, sizeof(buf), NULL, NULL);
if (n <= 0) break;
g_string_append_len(req, buf, n);
if (!have_headers) {
char *p = g_strstr_len(req->str, req->len, "\r\n\r\n");
if (p) {
have_headers = 1;
header_end = (size_t)(p - req->str) + 4;
content_length = http_parse_content_length(req->str, header_end);
}
}
if (have_headers && req->len - header_end >= content_length) break;
if (req->len > 262144) break; /* safety cap */
}
int is_get = 0, is_post = 0, is_settings = 0;
if (have_headers) {
if (g_str_has_prefix(req->str, "GET ")) is_get = 1;
if (g_str_has_prefix(req->str, "POST ")) is_post = 1;
const char *sp1 = strchr(req->str, ' ');
if (sp1) {
const char *path = sp1 + 1;
const char *sp2 = strchr(path, ' ');
size_t plen = sp2 ? (size_t)(sp2 - path) : strlen(path);
const char *q = memchr(path, '?', plen);
size_t match_len = q ? (size_t)(q - path) : plen;
if (match_len >= 8 &&
g_ascii_strncasecmp(path + match_len - 8, "settings", 8) == 0)
is_settings = 1;
}
}
if (is_settings && is_get) {
gchar *json = http_build_settings_json(handle);
http_send(out, "200 OK", "application/json", json);
g_free(json);
} else if (is_settings && is_post) {
const char *body = req->str + header_end;
size_t body_len = req->len - header_end;
if (body_len > content_length) body_len = content_length;
int applied = http_apply_settings(handle, body, body_len);
syslog(LOG_INFO, "settings http: applied %d parameter(s)", applied);
if (reload_timer_id) g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
http_send(out, "200 OK", "text/plain", "OK");
} else {
http_send(out, "404 Not Found", "text/plain", "Not found");
}
g_string_free(req, TRUE);
g_io_stream_close(G_IO_STREAM(connection), NULL, NULL);
return TRUE;
}
static void http_server_start(AXParameter *handle) {
GError *err = NULL;
GSocketService *service = g_socket_service_new();
GInetAddress *addr = g_inet_address_new_from_string("127.0.0.1");
GSocketAddress *saddr = g_inet_socket_address_new(addr, HTTP_PORT);
if (!g_socket_listener_add_address(G_SOCKET_LISTENER(service), saddr,
G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP,
NULL, NULL, &err)) {
syslog(LOG_WARNING, "settings http: bind 127.0.0.1:%d failed: %s",
HTTP_PORT, err ? err->message : "unknown");
if (err) g_error_free(err);
g_object_unref(service);
} else {
g_signal_connect(service, "incoming", G_CALLBACK(http_on_incoming), handle);
g_socket_service_start(service);
syslog(LOG_INFO, "settings http server listening on 127.0.0.1:%d", HTTP_PORT);
}
g_object_unref(addr);
g_object_unref(saddr);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
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);
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
* freshly configured key before it has been used. */
unlink(SENTINEL_FILE);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
ensure_param(handle, "AdvertiseRoutes", "");
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes", "AdvertiseRoutes" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
http_server_start(handle);
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+5 -3
View File
@@ -1,13 +1,15 @@
ARG ARCH=armv7hf
ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04
ARG VERSION=12.10.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/
COPY common/app /opt/app/
COPY arm/app/manifest.json /opt/app/manifest.json
WORKDIR /opt/app
ENV EXTRA_CFLAGS=-DHAS_PROXY_PORTS
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
-29
View File
@@ -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.
-14
View File
@@ -1,14 +0,0 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0 gio-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)
-151
View File
@@ -1,151 +0,0 @@
#!/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"
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
ADVERTISE_ROUTES=""
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 --reset --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
if [ "$ACCEPT_DNS" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
fi
if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In userspace-networking mode the
# tailscaled netstack forwards tailnet traffic to these subnets, so no kernel IP
# forwarding is required. Routes must still be approved in the Tailscale admin
# console before peers can use them.
if [ -n "$ADVERTISE_ROUTES" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
fi
# Run `tailscale up` in the background and act on its outcome. If the node needs
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
# ensures the status publisher below keeps running so the UI can surface the
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
# code is captured directly. We must NOT background `up` separately and `wait`
# for it from here, because in POSIX sh `wait` only works on children of the
# current shell — a subshell waiting on the parent's child returns 127.
{
eval "$TAILSCALE_CMD"
up_exit=$?
if [ "$up_exit" -eq 0 ]; then
logger -t "Tailscale_VPN" "Tailscale VPN is running"
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
if [ -n "$AUTH_KEY" ]; then
: > "$STATE_DIR/authkey_clear"
fi
else
logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)"
fi
} &
TAILSCALE_UP_PID=$!
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS"
# Publish tailscale's real backend state as JSON for the web UI to consume.
# This is the authoritative connection signal (BackendState / Self.Online /
# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at
# /local/Tailscale_VPN/status.json.
STATUS_FILE="$APP_DIR/html/status.json"
publish_status() {
if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
chmod 644 "$STATUS_FILE" 2>/dev/null
else
rm -f "$STATUS_FILE.tmp" 2>/dev/null
fi
}
status_loop() {
while true; do
publish_status
sleep 5
done
}
status_loop &
STATUS_LOOP_PID=$!
# Clean up the status writer, up watcher, daemon and published status on
# stop/restart so param_bridge (which signals this script) leaves no orphans or
# stale state.
cleanup() {
[ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null
[ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
rm -f "$STATUS_FILE" 2>/dev/null
exit 0
}
trap cleanup TERM INT
wait $TAILSCALED_PID
File diff suppressed because it is too large Load Diff
View File
Binary file not shown.
Binary file not shown.
+9 -4
View File
@@ -1,15 +1,20 @@
{
"schemaVersion": "1.7.0",
"schemaVersion": "2.0.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "Mo3he",
"embeddedSdkVersion": "3.0",
"vendorId": "5741c1fb91",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.98.8",
"architecture": "armv7hf"
"architecture": "armv7hf",
"runMode": "respawn",
"compatibleOsVersions": [
{
"max": "13"
}
]
},
"configuration": {
"settingPage": "index.html",
-549
View File
@@ -1,549 +0,0 @@
// 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 <axsdk/axparameter.h>
#include <glib-unix.h>
#include <gio/gio.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static 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 char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static char *cfg_advertise_routes = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
* upgrades a newly introduced manifest parameter is not always auto-registered,
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
GError *err = NULL;
if (!ax_parameter_add(handle, name, def, "string", &err)) {
if (err) g_error_free(err);
}
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
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", "", TRUE, &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;
}
/* ── auth-key sentinel ───────────────────────────────────────────────────── */
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
* disappears from the settings UI. This replaces the old exit-code-0 path,
* which never fired because tailscaled keeps the child alive indefinitely. */
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
if (access(SENTINEL_FILE, F_OK) != 0)
return G_SOURCE_CONTINUE;
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
unlink(SENTINEL_FILE);
return G_SOURCE_CONTINUE;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
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)
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
LOAD("AdvertiseRoutes", cfg_advertise_routes)
#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"));
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
cache_get(&cfg_http_proxy_port, "8080"),
cache_get(&cfg_socks5_port, "1080"),
cache_get(&cfg_custom_server, "(default)"));
}
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
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);
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
* settings through it. This tiny HTTP server, reached through the manifest
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
* fall back to reading and writing the parameters directly. */
#define HTTP_PORT 2201
static const char *http_param_names[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
static void cache_set_by_name(const char *name, const char *value) {
if (strcmp(name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
else if (strcmp(name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
}
static int http_is_known_param(const char *name) {
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++)
if (strcmp(name, http_param_names[i]) == 0) return 1;
return 0;
}
static void http_json_append_escaped(GString *out, const char *s) {
for (const char *p = s; *p; p++) {
switch (*p) {
case '"': g_string_append(out, "\\\""); break;
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
case '\t': g_string_append(out, "\\t"); break;
default:
if ((unsigned char)*p < 0x20)
g_string_append_printf(out, "\\u%04x", (unsigned char)*p);
else
g_string_append_c(out, *p);
}
}
}
static gchar *http_build_settings_json(AXParameter *handle) {
GString *out = g_string_new("{");
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++) {
gchar *val = NULL;
GError *err = NULL;
if (!ax_parameter_get(handle, http_param_names[i], &val, &err)) {
if (err) g_error_free(err);
val = g_strdup("");
}
if (i) g_string_append_c(out, ',');
g_string_append_printf(out, "\"%s\":\"", http_param_names[i]);
http_json_append_escaped(out, val ? val : "");
g_string_append_c(out, '"');
g_free(val);
}
g_string_append_c(out, '}');
return g_string_free(out, FALSE);
}
static gchar *http_url_decode(const char *s, size_t len) {
GString *out = g_string_new(NULL);
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (c == '+') {
g_string_append_c(out, ' ');
} else if (c == '%' && i + 2 < len &&
g_ascii_isxdigit(s[i + 1]) && g_ascii_isxdigit(s[i + 2])) {
int hi = g_ascii_xdigit_value(s[i + 1]);
int lo = g_ascii_xdigit_value(s[i + 2]);
g_string_append_c(out, (char)((hi << 4) | lo));
i += 2;
} else {
g_string_append_c(out, c);
}
}
return g_string_free(out, FALSE);
}
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
* the parameter store. Returns the number of parameters successfully set. */
static int http_apply_settings(AXParameter *handle, const char *body, size_t len) {
int applied = 0;
size_t start = 0;
for (size_t i = 0; i <= len; i++) {
if (i == len || body[i] == '&') {
size_t seg_len = i - start;
if (seg_len > 0) {
const char *seg = body + start;
const char *eq = memchr(seg, '=', seg_len);
if (eq) {
size_t nlen = (size_t)(eq - seg);
gchar *name = g_strndup(seg, nlen);
gchar *value = http_url_decode(eq + 1, seg_len - nlen - 1);
if (http_is_known_param(name)) {
GError *err = NULL;
if (ax_parameter_set(handle, name, value, TRUE, &err)) {
cache_set_by_name(name, value);
applied++;
} else {
syslog(LOG_WARNING, "http set %s failed: %s",
name, err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
g_free(name);
g_free(value);
}
}
start = i + 1;
}
}
return applied;
}
static size_t http_parse_content_length(const char *hdr, size_t hlen) {
const char *key = "content-length:";
size_t klen = strlen(key);
for (size_t i = 0; i + klen <= hlen; i++) {
if (g_ascii_strncasecmp(hdr + i, key, klen) == 0) {
i += klen;
while (i < hlen && (hdr[i] == ' ' || hdr[i] == '\t')) i++;
return (size_t)strtoul(hdr + i, NULL, 10);
}
}
return 0;
}
static void http_send(GOutputStream *out, const char *status,
const char *ctype, const char *body) {
gchar *resp = g_strdup_printf(
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
status, ctype, strlen(body), body);
g_output_stream_write_all(out, resp, strlen(resp), NULL, NULL, NULL);
g_free(resp);
}
static gboolean http_on_incoming(GSocketService *service G_GNUC_UNUSED,
GSocketConnection *connection,
GObject *source G_GNUC_UNUSED,
gpointer user_data) {
AXParameter *handle = (AXParameter *)user_data;
GInputStream *in = g_io_stream_get_input_stream(G_IO_STREAM(connection));
GOutputStream *out = g_io_stream_get_output_stream(G_IO_STREAM(connection));
GString *req = g_string_new(NULL);
char buf[2048];
int have_headers = 0;
size_t header_end = 0;
size_t content_length = 0;
while (1) {
gssize n = g_input_stream_read(in, buf, sizeof(buf), NULL, NULL);
if (n <= 0) break;
g_string_append_len(req, buf, n);
if (!have_headers) {
char *p = g_strstr_len(req->str, req->len, "\r\n\r\n");
if (p) {
have_headers = 1;
header_end = (size_t)(p - req->str) + 4;
content_length = http_parse_content_length(req->str, header_end);
}
}
if (have_headers && req->len - header_end >= content_length) break;
if (req->len > 262144) break; /* safety cap */
}
int is_get = 0, is_post = 0, is_settings = 0;
if (have_headers) {
if (g_str_has_prefix(req->str, "GET ")) is_get = 1;
if (g_str_has_prefix(req->str, "POST ")) is_post = 1;
const char *sp1 = strchr(req->str, ' ');
if (sp1) {
const char *path = sp1 + 1;
const char *sp2 = strchr(path, ' ');
size_t plen = sp2 ? (size_t)(sp2 - path) : strlen(path);
const char *q = memchr(path, '?', plen);
size_t match_len = q ? (size_t)(q - path) : plen;
if (match_len >= 8 &&
g_ascii_strncasecmp(path + match_len - 8, "settings", 8) == 0)
is_settings = 1;
}
}
if (is_settings && is_get) {
gchar *json = http_build_settings_json(handle);
http_send(out, "200 OK", "application/json", json);
g_free(json);
} else if (is_settings && is_post) {
const char *body = req->str + header_end;
size_t body_len = req->len - header_end;
if (body_len > content_length) body_len = content_length;
int applied = http_apply_settings(handle, body, body_len);
syslog(LOG_INFO, "settings http: applied %d parameter(s)", applied);
if (reload_timer_id) g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
http_send(out, "200 OK", "text/plain", "OK");
} else {
http_send(out, "404 Not Found", "text/plain", "Not found");
}
g_string_free(req, TRUE);
g_io_stream_close(G_IO_STREAM(connection), NULL, NULL);
return TRUE;
}
static void http_server_start(AXParameter *handle) {
GError *err = NULL;
GSocketService *service = g_socket_service_new();
GInetAddress *addr = g_inet_address_new_from_string("127.0.0.1");
GSocketAddress *saddr = g_inet_socket_address_new(addr, HTTP_PORT);
if (!g_socket_listener_add_address(G_SOCKET_LISTENER(service), saddr,
G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP,
NULL, NULL, &err)) {
syslog(LOG_WARNING, "settings http: bind 127.0.0.1:%d failed: %s",
HTTP_PORT, err ? err->message : "unknown");
if (err) g_error_free(err);
g_object_unref(service);
} else {
g_signal_connect(service, "incoming", G_CALLBACK(http_on_incoming), handle);
g_socket_service_start(service);
syslog(LOG_INFO, "settings http server listening on 127.0.0.1:%d", HTTP_PORT);
}
g_object_unref(addr);
g_object_unref(saddr);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
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);
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
* freshly configured key before it has been used. */
unlink(SENTINEL_FILE);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
ensure_param(handle, "AdvertiseRoutes", "");
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
http_server_start(handle);
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+2 -1
View File
@@ -7,7 +7,8 @@ ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY ./app /opt/app/
COPY common/app /opt/app/
COPY arm_ROOT/app/manifest.json /opt/app/manifest.json
WORKDIR /opt/app
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
-29
View File
@@ -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.
-14
View File
@@ -1,14 +0,0 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0 gio-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)
-123
View File
@@ -1,123 +0,0 @@
#!/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=""
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
ADVERTISE_ROUTES=""
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 --reset --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
if [ "$ACCEPT_DNS" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
fi
if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In kernel-networking (root) mode the
# host must forward packets between the tailnet and the LAN, so enable IP
# forwarding. Routes must still be approved in the Tailscale admin console.
if [ -n "$ADVERTISE_ROUTES" ]; then
echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
echo 1 > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
fi
# Run `tailscale up` in the background and act on its outcome. If the node needs
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
# ensures the status publisher below keeps running so the UI can surface the
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
# code is captured directly. We must NOT background `up` separately and `wait`
# for it from here, because in POSIX sh `wait` only works on children of the
# current shell — a subshell waiting on the parent's child returns 127.
{
eval "$TAILSCALE_CMD"
up_exit=$?
if [ "$up_exit" -eq 0 ]; then
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
if [ -n "$AUTH_KEY" ]; then
: > "$STATE_DIR/authkey_clear"
fi
else
logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)"
fi
} &
TAILSCALE_UP_PID=$!
# Publish tailscale's real backend state as JSON for the web UI to consume.
# This is the authoritative connection signal (BackendState / Self.Online /
# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at
# /local/Tailscale_VPN/status.json.
STATUS_FILE="$APP_DIR/html/status.json"
publish_status() {
if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
chmod 644 "$STATUS_FILE" 2>/dev/null
else
rm -f "$STATUS_FILE.tmp" 2>/dev/null
fi
}
status_loop() {
while true; do
publish_status
sleep 5
done
}
status_loop &
STATUS_LOOP_PID=$!
# Clean up the status writer, up watcher, daemon and published status on
# stop/restart so param_bridge (which signals this script) leaves no orphans or
# stale state.
cleanup() {
[ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null
[ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
rm -f "$STATUS_FILE" 2>/dev/null
exit 0
}
trap cleanup TERM INT
wait $TAILSCALED_PID
File diff suppressed because it is too large Load Diff
View File
Binary file not shown.
Binary file not shown.
-507
View File
@@ -1,507 +0,0 @@
// 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 <axsdk/axparameter.h>
#include <glib-unix.h>
#include <gio/gio.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static char *cfg_advertise_routes = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
* upgrades a newly introduced manifest parameter is not always auto-registered,
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
GError *err = NULL;
if (!ax_parameter_add(handle, name, def, "string", &err)) {
if (err) g_error_free(err);
}
}
static void stop_child(void) {
if (child_pid <= 0)
return;
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", "", TRUE, &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;
}
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
* disappears from the settings UI. This replaces the old exit-code-0 path,
* which never fired because tailscaled keeps the child alive indefinitely. */
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
if (access(SENTINEL_FILE, F_OK) != 0)
return G_SOURCE_CONTINUE;
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
unlink(SENTINEL_FILE);
return G_SOURCE_CONTINUE;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#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("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
LOAD("AdvertiseRoutes", cfg_advertise_routes)
#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, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
}
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, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
* settings through it. This tiny HTTP server, reached through the manifest
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
* fall back to reading and writing the parameters directly. */
#define HTTP_PORT 2201
static const char *http_param_names[] = {
"CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
static void cache_set_by_name(const char *name, const char *value) {
if (strcmp(name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
}
static int http_is_known_param(const char *name) {
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++)
if (strcmp(name, http_param_names[i]) == 0) return 1;
return 0;
}
static void http_json_append_escaped(GString *out, const char *s) {
for (const char *p = s; *p; p++) {
switch (*p) {
case '"': g_string_append(out, "\\\""); break;
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
case '\t': g_string_append(out, "\\t"); break;
default:
if ((unsigned char)*p < 0x20)
g_string_append_printf(out, "\\u%04x", (unsigned char)*p);
else
g_string_append_c(out, *p);
}
}
}
static gchar *http_build_settings_json(AXParameter *handle) {
GString *out = g_string_new("{");
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++) {
gchar *val = NULL;
GError *err = NULL;
if (!ax_parameter_get(handle, http_param_names[i], &val, &err)) {
if (err) g_error_free(err);
val = g_strdup("");
}
if (i) g_string_append_c(out, ',');
g_string_append_printf(out, "\"%s\":\"", http_param_names[i]);
http_json_append_escaped(out, val ? val : "");
g_string_append_c(out, '"');
g_free(val);
}
g_string_append_c(out, '}');
return g_string_free(out, FALSE);
}
static gchar *http_url_decode(const char *s, size_t len) {
GString *out = g_string_new(NULL);
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (c == '+') {
g_string_append_c(out, ' ');
} else if (c == '%' && i + 2 < len &&
g_ascii_isxdigit(s[i + 1]) && g_ascii_isxdigit(s[i + 2])) {
int hi = g_ascii_xdigit_value(s[i + 1]);
int lo = g_ascii_xdigit_value(s[i + 2]);
g_string_append_c(out, (char)((hi << 4) | lo));
i += 2;
} else {
g_string_append_c(out, c);
}
}
return g_string_free(out, FALSE);
}
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
* the parameter store. Returns the number of parameters successfully set. */
static int http_apply_settings(AXParameter *handle, const char *body, size_t len) {
int applied = 0;
size_t start = 0;
for (size_t i = 0; i <= len; i++) {
if (i == len || body[i] == '&') {
size_t seg_len = i - start;
if (seg_len > 0) {
const char *seg = body + start;
const char *eq = memchr(seg, '=', seg_len);
if (eq) {
size_t nlen = (size_t)(eq - seg);
gchar *name = g_strndup(seg, nlen);
gchar *value = http_url_decode(eq + 1, seg_len - nlen - 1);
if (http_is_known_param(name)) {
GError *err = NULL;
if (ax_parameter_set(handle, name, value, TRUE, &err)) {
cache_set_by_name(name, value);
applied++;
} else {
syslog(LOG_WARNING, "http set %s failed: %s",
name, err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
g_free(name);
g_free(value);
}
}
start = i + 1;
}
}
return applied;
}
static size_t http_parse_content_length(const char *hdr, size_t hlen) {
const char *key = "content-length:";
size_t klen = strlen(key);
for (size_t i = 0; i + klen <= hlen; i++) {
if (g_ascii_strncasecmp(hdr + i, key, klen) == 0) {
i += klen;
while (i < hlen && (hdr[i] == ' ' || hdr[i] == '\t')) i++;
return (size_t)strtoul(hdr + i, NULL, 10);
}
}
return 0;
}
static void http_send(GOutputStream *out, const char *status,
const char *ctype, const char *body) {
gchar *resp = g_strdup_printf(
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
status, ctype, strlen(body), body);
g_output_stream_write_all(out, resp, strlen(resp), NULL, NULL, NULL);
g_free(resp);
}
static gboolean http_on_incoming(GSocketService *service G_GNUC_UNUSED,
GSocketConnection *connection,
GObject *source G_GNUC_UNUSED,
gpointer user_data) {
AXParameter *handle = (AXParameter *)user_data;
GInputStream *in = g_io_stream_get_input_stream(G_IO_STREAM(connection));
GOutputStream *out = g_io_stream_get_output_stream(G_IO_STREAM(connection));
GString *req = g_string_new(NULL);
char buf[2048];
int have_headers = 0;
size_t header_end = 0;
size_t content_length = 0;
while (1) {
gssize n = g_input_stream_read(in, buf, sizeof(buf), NULL, NULL);
if (n <= 0) break;
g_string_append_len(req, buf, n);
if (!have_headers) {
char *p = g_strstr_len(req->str, req->len, "\r\n\r\n");
if (p) {
have_headers = 1;
header_end = (size_t)(p - req->str) + 4;
content_length = http_parse_content_length(req->str, header_end);
}
}
if (have_headers && req->len - header_end >= content_length) break;
if (req->len > 262144) break; /* safety cap */
}
int is_get = 0, is_post = 0, is_settings = 0;
if (have_headers) {
if (g_str_has_prefix(req->str, "GET ")) is_get = 1;
if (g_str_has_prefix(req->str, "POST ")) is_post = 1;
const char *sp1 = strchr(req->str, ' ');
if (sp1) {
const char *path = sp1 + 1;
const char *sp2 = strchr(path, ' ');
size_t plen = sp2 ? (size_t)(sp2 - path) : strlen(path);
const char *q = memchr(path, '?', plen);
size_t match_len = q ? (size_t)(q - path) : plen;
if (match_len >= 8 &&
g_ascii_strncasecmp(path + match_len - 8, "settings", 8) == 0)
is_settings = 1;
}
}
if (is_settings && is_get) {
gchar *json = http_build_settings_json(handle);
http_send(out, "200 OK", "application/json", json);
g_free(json);
} else if (is_settings && is_post) {
const char *body = req->str + header_end;
size_t body_len = req->len - header_end;
if (body_len > content_length) body_len = content_length;
int applied = http_apply_settings(handle, body, body_len);
syslog(LOG_INFO, "settings http: applied %d parameter(s)", applied);
if (reload_timer_id) g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
http_send(out, "200 OK", "text/plain", "OK");
} else {
http_send(out, "404 Not Found", "text/plain", "Not found");
}
g_string_free(req, TRUE);
g_io_stream_close(G_IO_STREAM(connection), NULL, NULL);
return TRUE;
}
static void http_server_start(AXParameter *handle) {
GError *err = NULL;
GSocketService *service = g_socket_service_new();
GInetAddress *addr = g_inet_address_new_from_string("127.0.0.1");
GSocketAddress *saddr = g_inet_socket_address_new(addr, HTTP_PORT);
if (!g_socket_listener_add_address(G_SOCKET_LISTENER(service), saddr,
G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP,
NULL, NULL, &err)) {
syslog(LOG_WARNING, "settings http: bind 127.0.0.1:%d failed: %s",
HTTP_PORT, err ? err->message : "unknown");
if (err) g_error_free(err);
g_object_unref(service);
} else {
g_signal_connect(service, "incoming", G_CALLBACK(http_on_incoming), handle);
g_socket_service_start(service);
syslog(LOG_INFO, "settings http server listening on 127.0.0.1:%d", HTTP_PORT);
}
g_object_unref(addr);
g_object_unref(saddr);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
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);
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
* freshly configured key before it has been used. */
unlink(SENTINEL_FILE);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
ensure_param(handle, "AdvertiseRoutes", "");
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes", "AdvertiseRoutes" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
http_server_start(handle);
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+1 -1
View File
@@ -4,7 +4,7 @@ FROM axisecp/acap-sdk:3.5-armv7hf-ubuntu${UBUNTU_VERSION}
RUN apt-get update -qq && apt-get install -y --no-install-recommends upx-ucl && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY ./app /opt/app/
COPY arm_acap3/app /opt/app/
WORKDIR /opt/app
# Rename the shell startup script (the ELF launcher will take the Tailscale_VPN name)
@@ -2,6 +2,7 @@ PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0 gio-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
CFLAGS += $(EXTRA_CFLAGS)
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
@@ -1,6 +1,10 @@
#!/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).
# $1 selects the variant: "standard" (userspace networking + local proxies)
# or "root" (kernel networking, no local proxy). Defaults to "standard".
VARIANT="${1:-standard}"
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
@@ -26,35 +30,46 @@ 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)}"
if [ "$VARIANT" = "root" ]; then
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
else
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
}
# 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
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
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 &
if [ "$VARIANT" = "root" ]; then
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
>/dev/null 2>&1 &
else
$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 &
fi
TAILSCALED_PID=$!
sleep 2
@@ -80,9 +95,14 @@ fi
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In userspace-networking mode the
# tailscaled netstack forwards tailnet traffic to these subnets, so no kernel IP
# forwarding is required. Routes must still be approved in the Tailscale admin
# console before peers can use them.
# forwarding is required. In kernel-networking (root) mode the host must
# forward packets between the tailnet and the LAN, so enable IP forwarding.
# Routes must still be approved in the Tailscale admin console either way.
if [ -n "$ADVERTISE_ROUTES" ]; then
if [ "$VARIANT" = "root" ]; then
echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
echo 1 > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
fi
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
fi
@@ -98,7 +118,11 @@ fi
eval "$TAILSCALE_CMD"
up_exit=$?
if [ "$up_exit" -eq 0 ]; then
logger -t "Tailscale_VPN" "Tailscale VPN is running"
if [ "$VARIANT" = "root" ]; then
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
else
logger -t "Tailscale_VPN" "Tailscale VPN is running"
fi
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
if [ -n "$AUTH_KEY" ]; then
: > "$STATE_DIR/authkey_clear"
@@ -109,8 +133,10 @@ fi
} &
TAILSCALE_UP_PID=$!
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"
if [ "$VARIANT" != "root" ]; then
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"
fi
# Publish tailscale's real backend state as JSON for the web UI to consume.
# This is the authoritative connection signal (BackendState / Self.Online /
@@ -403,8 +403,8 @@
</div>
</div>
<!-- Proxy Info (always visible) -->
<div class="card">
<!-- Proxy Info (hidden on ROOT builds, which have no local proxy) -->
<div class="card" id="proxy-info-card" style="display:none;">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
@@ -432,12 +432,12 @@
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row">
<div class="settings-row" id="http-port-row" style="display:none;">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row">
<div class="settings-row" id="socks-port-row" style="display:none;">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
@@ -886,6 +886,22 @@
// exposed through the manifest reverseProxy mapping at API_URL.
var API_URL = '/local/' + APP + '/api/settings';
// Whether this build exposes local HTTP/SOCKS5 proxies (absent on ROOT
// builds, which use kernel networking directly). Detected from whichever
// settings response actually comes back — set once and used to hide the
// proxy card/fields and to keep them out of the save request, since
// param.cgi errors the whole call's status line if asked to set a
// parameter name the manifest never registered.
var hasProxyPorts = false;
function toggleProxyUi(visible) {
hasProxyPorts = visible;
var display = visible ? '' : 'none';
document.getElementById('proxy-info-card').style.display = display;
document.getElementById('http-port-row').style.display = display;
document.getElementById('socks-port-row').style.display = display;
}
function updateProxyDisplay(httpPort, socksPort) {
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
@@ -909,6 +925,7 @@
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
if (avm) advertiseRoutesInput.value = avm[1].trim();
toggleProxyUi(!!hm && !!km);
updateProxyDisplay(hm ? hm[1].trim() : null, km ? km[1].trim() : null);
return true;
}
@@ -921,6 +938,7 @@
if (typeof obj.AcceptDNS === 'string') acceptDnsInput.checked = obj.AcceptDNS === 'true';
if (typeof obj.AcceptRoutes === 'string') acceptRoutesInput.checked = obj.AcceptRoutes === 'true';
if (typeof obj.AdvertiseRoutes === 'string') advertiseRoutesInput.value = obj.AdvertiseRoutes;
toggleProxyUi(typeof obj.HttpProxyPort === 'string' && typeof obj.Socks5Port === 'string');
updateProxyDisplay(obj.HttpProxyPort, obj.Socks5Port);
}
@@ -947,8 +965,8 @@
function saveViaFallback(httpPort, socksPort) {
var body = 'CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&HttpProxyPort=' + encodeURIComponent(httpPort) +
'&Socks5Port=' + encodeURIComponent(socksPort) +
(hasProxyPorts ? '&HttpProxyPort=' + encodeURIComponent(httpPort) : '') +
(hasProxyPorts ? '&Socks5Port=' + encodeURIComponent(socksPort) : '') +
'&AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
@@ -978,8 +996,8 @@
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) +
(hasProxyPorts ? '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) : '') +
(hasProxyPorts ? '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) : '') +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN (userspace variant).
* ACAP parameter bridge for Tailscale VPN.
*
* Responsibilities:
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
@@ -13,7 +13,10 @@
* 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.
* Shared across the userspace-networking variants (unprivileged 'sdk' ACAP
* user) and the ROOT / kernel-networking variant. Build with -DHAS_PROXY_PORTS
* for the userspace variants, which exposes the HTTP/SOCKS5 proxy port
* parameters; the ROOT variant omits them since it has no local proxy.
*/
#include <axsdk/axparameter.h>
@@ -37,14 +40,22 @@
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
#ifdef HAS_PROXY_PORTS
#define RUN_SCRIPT_VARIANT "standard"
#else
#define RUN_SCRIPT_VARIANT "root"
#endif
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
#ifdef HAS_PROXY_PORTS
static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL;
#endif
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static char *cfg_advertise_routes = NULL;
@@ -98,7 +109,7 @@ static void start_child(void) {
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
execl(RUN_SCRIPT, RUN_SCRIPT, RUN_SCRIPT_VARIANT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
@@ -178,8 +189,10 @@ static void load_config_cache(AXParameter *handle) {
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
#ifdef HAS_PROXY_PORTS
LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port)
#endif
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
LOAD("AdvertiseRoutes", cfg_advertise_routes)
@@ -195,17 +208,24 @@ static void write_config_file(void) {
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
#ifdef HAS_PROXY_PORTS
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"));
#endif
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
#ifdef HAS_PROXY_PORTS
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)"));
#else
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
#endif
}
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
@@ -230,8 +250,10 @@ static void parameter_changed(const gchar *name, const gchar *value,
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
#ifdef HAS_PROXY_PORTS
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);
#endif
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
@@ -251,15 +273,20 @@ static void parameter_changed(const gchar *name, const gchar *value,
#define HTTP_PORT 2201
static const char *http_param_names[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"CustomServer", "AuthKey",
#ifdef HAS_PROXY_PORTS
"HttpProxyPort", "Socks5Port",
#endif
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
static void cache_set_by_name(const char *name, const char *value) {
if (strcmp(name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
#ifdef HAS_PROXY_PORTS
else if (strcmp(name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
#endif
else if (strcmp(name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
@@ -304,7 +331,12 @@ static gchar *http_build_settings_json(AXParameter *handle) {
g_free(val);
}
g_string_append_c(out, '}');
return g_string_free(out, FALSE);
/* g_string_free(out, FALSE) is inlined by glib >= 2.76 headers into a call
* to g_string_free_and_steal(), which doesn't exist in older glib runtimes
* (e.g. AXIS OS 11.x). Copy out and fully free instead to stay portable. */
gchar *json_result = g_strdup(out->str);
g_string_free(out, TRUE);
return json_result;
}
static gchar *http_url_decode(const char *s, size_t len) {
@@ -323,7 +355,9 @@ static gchar *http_url_decode(const char *s, size_t len) {
g_string_append_c(out, c);
}
}
return g_string_free(out, FALSE);
gchar *decoded_result = g_strdup(out->str);
g_string_free(out, TRUE);
return decoded_result;
}
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
@@ -520,7 +554,10 @@ int main(void) {
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
"CustomServer", "AuthKey",
#ifdef HAS_PROXY_PORTS
"HttpProxyPort", "Socks5Port",
#endif
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {