Compare commits

...
41 Commits
Author SHA1 Message Date
github-actions[bot] 08a2140d68 Update Tailscale to v1.98.2 2026-05-19 04:28:36 +00:00
Weston Blieden 1a5d2c1a24 docs: update README with Accept DNS and Accept Routes settings 2026-05-12 14:11:18 +02:00
Weston Blieden 4066273b3f Add accept-routes toggle to roadmap 2026-05-05 09:53:10 +02:00
Weston Blieden 924f04c44a Add DNS toggle and tiny-tailscale to roadmap 2026-05-05 08:32:08 +02:00
Weston Blieden 674f1c4853 Add Roadmap section with AXIS OS 13 preparation items 2026-05-05 08:00:09 +02:00
Weston BliedenandGitHub 93855e5762 Update README.md to remove oosmetrics badge
Removed outdated oosmetrics badge and adjusted spacing.
2026-05-02 21:07:29 +02:00
Weston BliedenandGitHub 889db273ec Update README with additional badges 2026-05-02 14:35:40 +02:00
Weston Blieden 52572fc61c Update homepage: remove custom variant, add ACAP3 card, rotating hero word, device language 2026-04-21 08:57:56 +02:00
Weston BliedenandGitHub 76138394e1 Update Buy Me A Coffee badge in README 2026-04-19 14:59:22 +02:00
Weston BliedenandGitHub db24028d61 Simplify description of Headscale compatibility 2026-04-17 22:01:28 +02:00
Weston Blieden f5a30122a3 Fix password manager prompt: change auth key input from type=password to type=text 2026-04-17 19:56:04 +02:00
Weston Blieden 768a859c1f Fix password manager prompt on auth key input: use autocomplete=new-password 2026-04-17 19:51:12 +02:00
Weston Blieden f395d91de8 Fix auto-focus on settings inputs: add autocomplete=off 2026-04-17 19:42:25 +02:00
Weston Blieden 41e2a508c0 v1.96.4-r3 - Remove icon from header, change accent color to #2e2d2d 2026-04-17 19:25:04 +02:00
Weston Blieden 256aaa9d23 Fix param_bridge.c build errors: move g_ax_handle declaration before watchdog_cb, add missing TRUE arg to ax_parameter_set 2026-04-17 18:31:19 +02:00
Mo3heandClaude Sonnet 4.6 3cc3cd4804 Add Buy Me a Coffee link to homepage nav and footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:26:08 +02:00
Weston BliedenandGitHub 2c7ce1dc52 Add 'Buy Me A Coffee' badge to README
Added a 'Buy Me A Coffee' badge to the README.
2026-04-17 16:20:58 +02:00
Weston BliedenandGitHub 6bacc9e92b Remove BuyMeACoffee entry from FUNDING.yml
Removed BuyMeACoffee funding option.
2026-04-17 16:20:44 +02:00
Weston BliedenandGitHub 281fed0aa0 Fix Buy Me a Coffee username case
Updated Buy Me a Coffee username to match GitHub format.
2026-04-17 16:18:51 +02:00
Weston BliedenandGitHub 988adfc83a Change Buy Me a Coffee username to lowercase
Updated Buy Me a Coffee username to lowercase.
2026-04-17 16:16:19 +02:00
Weston BliedenandGitHub 0761b6e959 Add Buy Me a Coffee funding option 2026-04-17 16:12:37 +02:00
Weston Blieden ab2f4c050e docs: ROOT variants only supported on 11.11-11.x, OS 12 removed root access 2026-04-17 14:29:25 +02:00
Weston Blieden c396f00d0b docs: fix min Axis OS to 11.11+ for all variants including ROOT 2026-04-17 14:28:21 +02:00
Weston Blieden ced340453c v1.96.4-r2: C param bridge, configurable proxy ports, SDK 1.15.1, custom variants folded in, UI fixes 2026-04-17 14:05:09 +02:00
Weston BliedenandGitHub 4afd79167e Remove redundant phrase in disclaimer section 2026-04-15 10:00:55 +02:00
Weston BliedenandGitHub e9919e069d Refine disclaimer wording in README.md
Updated disclaimer to remove redundant wording.
2026-04-15 09:56:24 +02:00
Weston Blieden 732dacc5ba ci: add acap3 variant support to build workflow 2026-04-15 08:36:03 +02:00
Weston Blieden 27e26c65e3 docs: remove emoji from README 2026-04-15 08:35:19 +02:00
Weston Blieden 8a070bf5bf docs: add arm_acap3 legacy variant to README compatibility table and feature list 2026-04-14 21:07:22 +02:00
Weston Blieden de58375eb3 chore(acap3): add remaining app source files (Makefile, launcher.c, package.conf) 2026-04-14 21:04:24 +02:00
Weston Blieden 86c6ecb385 fix(acap3): fetch tailscaled.log for status details, fix connecting→connected for all variants
- arm_acap3 start.sh: log IP, version and auth URL to syslog via 'tailscale ip/version'
- arm_acap3 index.html: fetch tailscaled.log (symlinked into html/) in addition to
  syslog so IP, version, tailnet and -> Running state are always available
- arm_acap3 Dockerfile: bake html/tailscaled.log symlink into .eap
- all variants index.html: upgrade 'connecting' (without auth URL) to 'connected'
  when VAPIX list.cgi confirms Status=Running (was only upgrading from 'disconnected')
2026-04-14 21:04:16 +02:00
Weston BliedenandGitHub 7bb541fa2a Format disclaimer section in README.md 2026-04-14 19:18:24 +02:00
Weston Blieden 3e87803625 fix: always check app status API so UI shows Stopped when ACAP is not running 2026-04-14 15:43:18 +02:00
Weston Blieden 8bd18ffc63 fix: use syslog header as primary node name source (prevents stale acap-tailscale_vpn) 2026-04-14 15:36:13 +02:00
Weston Blieden c2b15fb7ec fix: pass --hostname=$(hostname) to tailscale up so node name matches camera hostname 2026-04-14 15:29:20 +02:00
Weston Blieden b07e4f1f61 fix: use cross-strip in Dockerfiles to reduce .eap size (32M -> 16M) 2026-04-14 15:22:11 +02:00
Weston Blieden 75f9cfd4b7 fix: show auth URL even when stale Running entry exists in syslog (reinstall/re-login) 2026-04-14 15:12:19 +02:00
Weston Blieden 96e8a8fd97 fix: correct flag name --outbound-http-proxy-listen (remove trailing 'er') 2026-04-14 15:02:06 +02:00
Weston Blieden 8c9072b876 Fix README: proxy available on all non-ROOT variants, not just custom 2026-04-14 13:53:03 +02:00
Weston Blieden cf84c3cdb6 Add HTTP CONNECT proxy (8080) to standard aarch64 and arm variants 2026-04-14 13:49:24 +02:00
Weston Blieden db05725432 Add HTTP CONNECT proxy (8080) and SOCKS5 (1055) to custom variants 2026-04-14 13:44:31 +02:00
55 changed files with 2661 additions and 1998 deletions
+16 -7
View File
@@ -126,14 +126,21 @@ jobs:
# Detect variant suffix for .eap naming # Detect variant suffix for .eap naming
if [[ "$FOLDER_NAME" == *_ROOT ]]; then if [[ "$FOLDER_NAME" == *_ROOT ]]; then
VARIANT="_root" VARIANT="_root"
elif [[ "$FOLDER_NAME" == *_custom ]]; then elif [[ "$FOLDER_NAME" == *_acap3 ]]; then
VARIANT="_custom" VARIANT="_acap3"
else else
VARIANT="" VARIANT=""
fi fi
# Update manifest version # Update version — manifest.json for ACAP 4, package.conf for ACAP 3
sed -i "s/\"version\": \".*\"/\"version\": \"${RELEASE_VERSION}\"/" "$folder/app/manifest.json" if [[ -f "$folder/app/manifest.json" ]]; then
sed -i "s/\"version\": \".*\"/\"version\": \"${RELEASE_VERSION}\"/" "$folder/app/manifest.json"
elif [[ -f "$folder/app/package.conf" ]]; then
IFS='.' read -r MAJOR MINOR MICRO <<< "${RELEASE_VERSION}"
sed -i "s/^APPMAJORVERSION=.*/APPMAJORVERSION=${MAJOR}/" "$folder/app/package.conf"
sed -i "s/^APPMINORVERSION=.*/APPMINORVERSION=${MINOR}/" "$folder/app/package.conf"
sed -i "s/^APPMICROVERSION=.*/APPMICROVERSION=${MICRO}/" "$folder/app/package.conf"
fi
# Docker build # Docker build
TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean
@@ -143,7 +150,9 @@ jobs:
# Extract .eap files into build folder # Extract .eap files into build folder
EAP_OUTPUT="./build/${TAG_NAME}" EAP_OUTPUT="./build/${TAG_NAME}"
mkdir -p "$EAP_OUTPUT" mkdir -p "$EAP_OUTPUT"
docker cp $(docker create "$TAG_NAME"):/opt/app "$EAP_OUTPUT" CID=$(docker create "$TAG_NAME")
docker cp "$CID":/opt/app "$EAP_OUTPUT"
docker rm "$CID" >/dev/null
# Move all .eap files to releases folder, append variant if needed # Move all .eap files to releases folder, append variant if needed
find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do
@@ -165,8 +174,8 @@ jobs:
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
# Only commit manifests; do not track release artifacts # Only commit manifests and ACAP 3 package.conf; do not track release artifacts
git add */app/manifest.json git add */app/manifest.json arm_acap3/app/package.conf
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No changes to commit" echo "No changes to commit"
else else
+112 -44
View File
@@ -1,40 +1,43 @@
# Tailscale ACAP for Axis Cameras # Tailscale ACAP for Axis Cameras
🌐 **[Visit the Homepage](https://mo3he.github.io/Axis_Cam_Tailscale/)** **[Visit the Homepage](https://mo3he.github.io/Axis_Cam_Tailscale/)**
This repository provides an **ACAP package** that installs the [Tailscale VPN client](https://tailscale.com/) on Axis cameras. This repository provides an **ACAP package** that installs the [Tailscale VPN client](https://tailscale.com/) on Axis cameras.
- ✅ Secure remote access to cameras - Secure remote access to cameras
- ✅ Easy to install via EAP package - Easy to install via EAP package
- ✅ Works on **Axis OS 12+** (non-root version) - Works on **Axis OS 11.11+** (non-root version)
- ✅ Based on **WireGuard VPN** technology - Works on **legacy Axis OS 9.x / 10.x** via the ACAP 3 variant
- Based on **WireGuard VPN** technology
[![Releases](https://img.shields.io/github/v/release/Mo3he/Axis_Cam_Tailscale)](https://github.com/Mo3he/Axis_Cam_Tailscale/releases) [![Releases](https://img.shields.io/github/v/release/Mo3he/Axis_Cam_Tailscale)](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
[![License](https://img.shields.io/github/license/Mo3he/Axis_Cam_Tailscale)](LICENSE) [![License](https://img.shields.io/github/license/Mo3he/Axis_Cam_Tailscale)](LICENSE)
![Total Downloads](https://img.shields.io/github/downloads/Mo3he/Axis_Cam_Tailscale/total?style=flat&label=Downloads&color=blue) ![Total Downloads](https://img.shields.io/github/downloads/Mo3he/Axis_Cam_Tailscale/total?style=flat&label=Downloads&color=blue)
[![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-lightgrey?logo=github)](https://github.com/sponsors/Mo3he) [![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-lightgrey?logo=github)](https://github.com/sponsors/Mo3he)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-orange?style=flat&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/mo3he)
### Disclaimer: This is an independent, community-developed ACAP package and is not an official Axis Communications product. It was developed entirely on personal time and is not affiliated with, endorsed by, or supported by Axis Communications AB. Use it at your own risk. For official Axis software, visit axis.com > **Disclaimer:** This is an independent, community-developed ACAP package and is not an official Axis Communications product. It is not affiliated with, endorsed by, or supported by Axis Communications AB. Use it at your own risk. For official Axis software, visit axis.com
> **Tailscale Notice:** Tailscale is a product of Tailscale Inc. This package independently redistributes the Tailscale binaries under the [BSD 3-Clause License](LICENSE) and is not affiliated with, endorsed by, or supported by Tailscale Inc. For the official Tailscale client, visit [tailscale.com](https://tailscale.com). > **Tailscale Notice:** Tailscale is a product of Tailscale Inc. This package independently redistributes the Tailscale binaries under the [BSD 3-Clause License](LICENSE) and is not affiliated with, endorsed by, or supported by Tailscale Inc. For the official Tailscale client, visit [tailscale.com](https://tailscale.com).
--- ---
## 📑 Table of Contents ## Table of Contents
- [📥 Installation](#-installation) - [Installation](#installation)
- [🚀 Usage](#-usage) - [Usage](#usage)
- [🔄 Updating Tailscale](#-updating-tailscale) - [Settings](#settings)
- [🧪 Testers Needed](#-testers-needed) - [Proxy Support](#proxy-support)
- [🎉 Good News](#-good-news) - [Updating Tailscale](#updating-tailscale)
- [🎯 Purpose](#-purpose) - [Purpose](#purpose)
- [🔗 Useful Links](#-useful-links) - [Useful Links](#useful-links)
- [🖥️ Compatibility](#️-compatibility) - [Compatibility](#compatibility)
- [⭐ Star History](#-star-history) - [Roadmap](#roadmap)
- [💖 Support](#-support) - [Star History](#star-history)
- [Support](#support)
--- ---
## 📥 Installation ## Installation
Get the **prebuilt `.eap` file** from the [Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases). Get the **prebuilt `.eap` file** from the [Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
@@ -47,19 +50,60 @@ Once installed:
- Click **Open** to view logs and get your Tailscale authentication URL. - Click **Open** to view logs and get your Tailscale authentication URL.
- On uninstall, all changes/files are removed. - On uninstall, all changes/files are removed.
> ⚠️ You’ll need a [Tailscale account](https://tailscale.com/) to authenticate. > You'll need a [Tailscale account](https://tailscale.com/) to authenticate.
--- ---
## 🚀 Usage ## Usage
- Runs a startup script to set permissions and launch Tailscale. - Runs a C-based parameter bridge (compiled via ACAP SDK 1.15.1) that reads settings from the ACAP parameter store and launches Tailscale.
- View logs via the **Open** button in the app. - View logs and connection status via the **Open** button in the app.
- Authenticate using the provided URL. - Authenticate using the provided URL, or pre-enter an auth key in **Settings**.
- Change the **Custom Server URL** in Settings to use a self-hosted [Headscale](https://headscale.net/) control server.
- Parameter changes (ports, server URL, auth key) are applied automatically without needing to reinstall the app.
--- ---
## 🔄 Updating Tailscale ## Settings
All parameters are configurable via the web UI (**Open → Settings** card) and take effect immediately without reinstalling:
| Parameter | Default | Description |
|---|---|---|
| Custom Server URL | *(empty)* | Control server URL for [Headscale](https://headscale.net/) or other self-hosted servers. Leave blank to use Tailscale's official servers. |
| Auth Key | *(empty)* | Pre-authentication key (`tskey-auth-...`). Cleared automatically after first successful connection. Leave blank to authenticate via browser. |
| HTTP Proxy Port | `8080` | Port for the outbound HTTP/HTTPS proxy. |
| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 proxy. |
| Accept DNS | `off` | Passes `--accept-dns=true` to `tailscale up`. Allows the tailnet to push DNS settings to the camera. Off by default to avoid overriding the camera's DNS configuration. Not available on `armv7hf_acap3`. |
| Accept Routes | `off` | Passes `--accept-routes=true` to `tailscale up`. Allows the camera to use subnet routes advertised by other nodes in the tailnet. Not available on `armv7hf_acap3`. |
---
All non-ROOT variants expose two local proxy endpoints that route outbound traffic through the Tailscale tunnel. The ports are configurable via **Settings → HTTP Proxy Port / SOCKS5 Proxy Port** in the web UI.
### HTTP CONNECT Proxy — `http://127.0.0.1:8080` (default)
Routes HTTP and HTTPS traffic. Set this wherever an HTTP/HTTPS proxy field is available on the camera:
| Location | Field | Value |
|---|---|---|
| System → Network → Global proxies | HTTP proxy | `http://127.0.0.1:<port>` |
| System → Network → Global proxies | HTTPS proxy | `http://127.0.0.1:<port>` |
| System → MQTT → Broker | HTTP proxy | `http://127.0.0.1:<port>` |
| System → MQTT → Broker | HTTPS proxy | `http://127.0.0.1:<port>` |
### SOCKS5 Proxy — `127.0.0.1:1080` (default)
For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:<port>`.
> The active proxy addresses are always shown in the **Proxy Configuration** card of the web UI.
> If you change a port that is already in use by another process, the app will log an error and exit rather than silently falling back to a different port.
---
## Updating Tailscale
- New `.eap` files are auto-built and released **weekly** (if a new Tailscale version is available). - New `.eap` files are auto-built and released **weekly** (if a new Tailscale version is available).
- To update, simply install the new `.eap` over the existing one. - To update, simply install the new `.eap` over the existing one.
@@ -83,37 +127,31 @@ docker cp $(docker create <package_name>):/opt/app ./build
--- ---
## 🧪 Testers Needed ## Good News
A new **custom** version is available: Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 11.11+**.
- Allows setting a custom server and auth key (for [Headscale](https://headscale.net/)).
- Go to **Settings (⋮ → Settings)** to add your details.
Please give it a try and share your feedback!
---
## 🎉 Good News
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 12+**.
- Runs in **user space networking mode**. - Runs in **user space networking mode**.
For **full networking features**, use the **ROOT** version on Axis OS < 12. 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.
### Legacy camera support (Axis OS 9.x / 10.x)
An **ACAP 3** variant (`armv7hf_acap3`) is available for older cameras that do not support ACAP 4 / Axis OS 11+. It uses the same userspace networking mode and web UI, built against the ACAP SDK 3.5 toolchain.
--- ---
## 🎯 Purpose ## Purpose
Adding a VPN client directly to the camera enables: Adding a VPN client directly to the camera enables:
- Secure remote access without additional hardware or complex network configuration. - Secure remote access without additional hardware or complex network configuration.
- Easy setup through Tailscale’s lightweight WireGuard-based tunnel. - Easy setup through Tailscale’s lightweight WireGuard-based tunnel.
🔗 Learn more: [How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/) Learn more: [How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/)
--- ---
## 🔗 Useful Links ## Useful Links
- [Tailscale](https://tailscale.com/) - [Tailscale](https://tailscale.com/)
- [Tailscale GitHub](https://github.com/tailscale/tailscale) - [Tailscale GitHub](https://github.com/tailscale/tailscale)
@@ -122,10 +160,20 @@ Adding a VPN client directly to the camera enables:
--- ---
## 🖥️ Compatibility ## Compatibility
The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**-based SoCs. The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**-based SoCs.
| 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+ |
| `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`.
You can verify your device details using the following command: You can verify your device details using the following command:
```bash ```bash
@@ -137,13 +185,33 @@ curl --anyauth "*" -u <username>:<password> <device_ip>/axis-cgi/basicdeviceinfo
--- ---
## ⭐ Star History ## Roadmap
### AXIS OS 13 Preparation
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.
### General Improvements
- [x] **Accept DNS from tailnet toggle** - Add an opt-in setting to the settings page that passes `--accept-dns=true` to `tailscale up`. Defaults to off to prevent Tailscale from overriding `resolv.conf` on cameras that don't need MagicDNS.
- [x] **Accept routes toggle** - Add an opt-in setting that passes `--accept-routes=true` to `tailscale up`, allowing the camera to use subnet routes advertised by other nodes in the tailnet.
- [ ] **Switch to tiny-tailscale binaries** - Evaluate replacing the bundled `tailscale` and `tailscaled` binaries with [tiny-tailscale](https://github.com/iamromulan/tiny-tailscale) builds. These combine both into a single binary, strip unused features, and are significantly smaller (~43% reduction), reducing install size and memory footprint across all architectures.
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=Mo3he/Axis_Cam_Tailscale&type=Date)](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date) [![Star History Chart](https://api.star-history.com/svg?repos=Mo3he/Axis_Cam_Tailscale&type=Date)](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date)
--- ---
## 💖 Support ## Support
If you like this project and want to support my work: If you like this project and want to support my work:
👉 [Sponsor Me](https://github.com/sponsors/Mo3he) [Sponsor Me](https://github.com/sponsors/Mo3he)
+3 -2
View File
@@ -1,5 +1,5 @@
ARG ARCH=aarch64 ARG ARCH=aarch64
ARG VERSION=1.3 ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04 ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp ARG REPO=axisecp
ARG SDK=acap-native-sdk ARG SDK=acap-native-sdk
@@ -9,4 +9,5 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application # Building the ACAP application
COPY ./app /opt/app/ COPY ./app /opt/app/
WORKDIR /opt/app WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+14 -1
View File
@@ -1 +1,14 @@
nop: PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
-31
View File
@@ -1,31 +0,0 @@
#!/bin/sh
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
logger -t "Tailscale_VPN" "Starting Tailscale VPN service"
mkdir -p "$STATE_DIR"
chmod 755 "$APP_DIR/lib/tailscale"
chmod 755 "$APP_DIR/lib/tailscaled"
# Kill any leftover daemon from a previous run
killall tailscaled 2>/dev/null || true
logger -t "Tailscale_VPN" "Starting tailscaled daemon (userspace networking)"
"$APP_DIR/lib/tailscaled" \
--state="$STATE_DIR/tailscaled.state" \
--socket="$STATE_DIR/tailscaled.sock" \
--socks5-server=localhost:1055 \
--tun=userspace-networking \
2>&1 | logger -t "Tailscale_VPN" &
TAILSCALED_PID=$!
sleep 2
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up 2>&1 | logger -t "Tailscale_VPN"
logger -t "Tailscale_VPN" "Tailscale VPN is running"
wait $TAILSCALED_PID
logger -t "Tailscale_VPN" "tailscaled exited"
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
# Tailscale VPN run script — called by the param_bridge C binary.
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Defaults — overridden by sourcing params.conf written by param_bridge
CUSTOM_SERVER=""
AUTH_KEY=""
CONF_HTTP="8080"
CONF_SOCKS="1080"
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
# Check whether a TCP port is already bound
is_port_in_use() {
local port=$1
local hex_port
hex_port=$(printf '%04X' "$port")
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
return 1
}
if is_port_in_use "$CONF_HTTP"; then
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
exit 1
fi
if is_port_in_use "$CONF_SOCKS"; then
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
exit 1
fi
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
--socks5-server=localhost:$CONF_SOCKS \
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
--tun=userspace-networking \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running"
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS"
wait $TAILSCALED_PID
+222 -48
View File
@@ -11,7 +11,7 @@
--border: #262a35; --border: #262a35;
--text: #e4e6ed; --text: #e4e6ed;
--muted: #8b8fa3; --muted: #8b8fa3;
--accent: #4f8ff7; --accent: #2e2d2d;
--green: #22c55e; --green: #22c55e;
--yellow: #f59e0b; --yellow: #f59e0b;
--red: #ef4444; --red: #ef4444;
@@ -26,7 +26,7 @@
--border: #e0e3e8; --border: #e0e3e8;
--text: #1a1a2e; --text: #1a1a2e;
--muted: #6b7084; --muted: #6b7084;
--accent: #2563eb; --accent: #2e2d2d;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -231,13 +231,47 @@
word-break: break-all; word-break: break-all;
} }
.log-box .log-line { display: block; } .log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(79,143,247,0.06); } .log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; } .log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); } .log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); } .log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); } .log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); } .log-line .msg-ok { color: var(--green); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */ /* Refresh indicator */
.refresh-bar { .refresh-bar {
display: flex; display: flex;
@@ -257,8 +291,8 @@
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 14px; margin-bottom: 14px;
background: rgba(79,143,247,0.1); background: rgba(46,45,45,0.1);
border: 1px solid rgba(79,143,247,0.2); border: 1px solid rgba(46,45,45,0.2);
} }
.update-banner.visible { display: flex; } .update-banner.visible { display: flex; }
.update-banner .update-text { .update-banner .update-text {
@@ -292,18 +326,6 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
<rect width="128" height="128" rx="28" fill="#0166FF"/>
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="32" cy="64" r="13" fill="white"/>
<circle cx="64" cy="64" r="13" fill="white"/>
<circle cx="96" cy="64" r="13" fill="white"/>
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="96" r="13" fill="white"/>
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
</svg>
<h1>Tailscale VPN</h1> <h1>Tailscale VPN</h1>
</div> </div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme"> <button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
@@ -364,6 +386,52 @@
</div> </div>
</div> </div>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<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">
<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">
<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>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs --> <!-- Logs -->
<div class="card"> <div class="card">
<div class="log-controls"> <div class="log-controls">
@@ -448,17 +516,18 @@
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null; tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
} }
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g); // Primary: extract hostname from Axis syslog header (always the real device hostname)
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
var node = null; var node = null;
if (nodeMatches) { var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/); if (hostLine) node = hostLine[1];
if (nm) node = nm[1]; // Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
}
if (!node) { if (!node) {
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]" var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/); if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (hostLine) node = hostLine[1]; if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
} }
var loginMatches = txt.match(/active login:\s+\S+/g); var loginMatches = txt.match(/active login:\s+\S+/g);
@@ -493,28 +562,58 @@
} }
} }
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing // Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version); cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip'); tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node'); node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet'); tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version'); version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); var isRunning = /-> Running/.test(lastState);
// Detect running even after syslog rotation (daemon active but startup lines gone)
if (!isRunning && !latestUrl) { // Fallbacks only when syslog has rotated and no state transitions are visible.
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || // If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) || /derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) || /c2n: GET/.test(txt) ||
/localapi:/.test(txt); /localapi:/.test(txt);
} }
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version }; // If an auth URL appears AFTER the last Running state, re-auth is needed
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version }; // (handles stale Running entries in syslog after reinstall or token expiry)
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version }; if (isRunning && latestUrl) {
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version }; // Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
} }
function classifyLine(msg) { function classifyLine(msg) {
@@ -565,6 +664,10 @@
auth.style.display = 'none'; auth.style.display = 'none';
} }
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') { if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-'; document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim'); document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
@@ -603,21 +706,19 @@
.then(function(txt) { .then(function(txt) {
var result = parse(txt); var result = parse(txt);
renderLogs(txt); renderLogs(txt);
if (result.state !== 'disconnected') { // Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result); render(result);
} else { });
// Syslog may be empty/rotated - check app status API
checkAppRunning().then(function(running) {
if (running) {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
}
}) })
.catch(function() { .catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs'; document.getElementById('status-text').textContent = 'Unable to fetch logs';
@@ -671,6 +772,79 @@
} }
return 0; return 0;
} }
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})(); })();
</script> </script>
+25 -3
View File
@@ -1,5 +1,5 @@
{ {
"schemaVersion": "1.3", "schemaVersion": "1.7.0",
"acapPackageConf": { "acapPackageConf": {
"setup": { "setup": {
"appName": "Tailscale_VPN", "appName": "Tailscale_VPN",
@@ -8,11 +8,33 @@
"embeddedSdkVersion": "3.0", "embeddedSdkVersion": "3.0",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn", "runMode": "respawn",
"version": "1.96.4", "version": "1.98.2",
"architecture": "aarch64" "architecture": "aarch64"
}, },
"configuration": { "configuration": {
"settingPage": "index.html" "settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
}
]
} }
} }
} }
+250
View File
@@ -0,0 +1,250 @@
// 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 <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"
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 void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
/* ── watchdog ────────────────────────────────────────────────────────────── */
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", 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;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
cache_get(&cfg_http_proxy_port, "8080"),
cache_get(&cfg_socks5_port, "1080"),
cache_get(&cfg_custom_server, "(default)"));
}
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
/* ── main ────────────────────────────────────────────────────────────────── */
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting");
/* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+3 -2
View File
@@ -1,5 +1,5 @@
ARG ARCH=aarch64 ARG ARCH=aarch64
ARG VERSION=1.3 ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04 ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp ARG REPO=axisecp
ARG SDK=acap-native-sdk ARG SDK=acap-native-sdk
@@ -9,4 +9,5 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application # Building the ACAP application
COPY ./app /opt/app/ COPY ./app /opt/app/
WORKDIR /opt/app WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+14 -1
View File
@@ -1 +1,14 @@
nop: PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
-29
View File
@@ -1,29 +0,0 @@
#!/bin/sh
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
logger -t "Tailscale_VPN" "Starting Tailscale VPN service (root mode)"
mkdir -p "$STATE_DIR"
chmod 755 "$APP_DIR/lib/tailscale"
chmod 755 "$APP_DIR/lib/tailscaled"
# Kill any leftover daemon from a previous run
killall tailscaled 2>/dev/null || true
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
"$APP_DIR/lib/tailscaled" \
--state="$STATE_DIR/tailscaled.state" \
--socket="$STATE_DIR/tailscaled.sock" \
2>&1 | logger -t "Tailscale_VPN" &
TAILSCALED_PID=$!
sleep 2
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --accept-routes 2>&1 | logger -t "Tailscale_VPN"
logger -t "Tailscale_VPN" "Tailscale VPN is running"
wait $TAILSCALED_PID
logger -t "Tailscale_VPN" "tailscaled exited"
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
# Tailscale VPN run script (ROOT / kernel networking variant).
# Sources config from params.conf written by param_bridge.
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
CUSTOM_SERVER=""
AUTH_KEY=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
wait $TAILSCALED_PID
+222 -47
View File
@@ -11,7 +11,7 @@
--border: #262a35; --border: #262a35;
--text: #e4e6ed; --text: #e4e6ed;
--muted: #8b8fa3; --muted: #8b8fa3;
--accent: #4f8ff7; --accent: #2e2d2d;
--green: #22c55e; --green: #22c55e;
--yellow: #f59e0b; --yellow: #f59e0b;
--red: #ef4444; --red: #ef4444;
@@ -26,7 +26,7 @@
--border: #e0e3e8; --border: #e0e3e8;
--text: #1a1a2e; --text: #1a1a2e;
--muted: #6b7084; --muted: #6b7084;
--accent: #2563eb; --accent: #2e2d2d;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -231,13 +231,47 @@
word-break: break-all; word-break: break-all;
} }
.log-box .log-line { display: block; } .log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(79,143,247,0.06); } .log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; } .log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); } .log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); } .log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); } .log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); } .log-line .msg-ok { color: var(--green); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */ /* Refresh indicator */
.refresh-bar { .refresh-bar {
display: flex; display: flex;
@@ -257,8 +291,8 @@
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 14px; margin-bottom: 14px;
background: rgba(79,143,247,0.1); background: rgba(46,45,45,0.1);
border: 1px solid rgba(79,143,247,0.2); border: 1px solid rgba(46,45,45,0.2);
} }
.update-banner.visible { display: flex; } .update-banner.visible { display: flex; }
.update-banner .update-text { .update-banner .update-text {
@@ -292,18 +326,6 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
<rect width="128" height="128" rx="28" fill="#0166FF"/>
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="32" cy="64" r="13" fill="white"/>
<circle cx="64" cy="64" r="13" fill="white"/>
<circle cx="96" cy="64" r="13" fill="white"/>
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="96" r="13" fill="white"/>
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
</svg>
<h1>Tailscale VPN</h1> <h1>Tailscale VPN</h1>
</div> </div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme"> <button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
@@ -364,6 +386,52 @@
</div> </div>
</div> </div>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<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">
<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">
<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>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs --> <!-- Logs -->
<div class="card"> <div class="card">
<div class="log-controls"> <div class="log-controls">
@@ -448,17 +516,18 @@
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null; tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
} }
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g); // Primary: extract hostname from Axis syslog header (always the real device hostname)
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
var node = null; var node = null;
if (nodeMatches) { var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/); if (hostLine) node = hostLine[1];
if (nm) node = nm[1]; // Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
}
if (!node) { if (!node) {
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]" var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/); if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (hostLine) node = hostLine[1]; if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
} }
var loginMatches = txt.match(/active login:\s+\S+/g); var loginMatches = txt.match(/active login:\s+\S+/g);
@@ -493,28 +562,58 @@
} }
} }
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing // Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version); cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip'); tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node'); node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet'); tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version'); version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); var isRunning = /-> Running/.test(lastState);
// Detect running even after syslog rotation (daemon active but startup lines gone)
if (!isRunning && !latestUrl) { // Fallbacks only when syslog has rotated and no state transitions are visible.
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || // If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) || /derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) || /c2n: GET/.test(txt) ||
/localapi:/.test(txt); /localapi:/.test(txt);
} }
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version }; // If an auth URL appears AFTER the last Running state, re-auth is needed
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version }; // (handles stale Running entries in syslog after reinstall or token expiry)
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version }; if (isRunning && latestUrl) {
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version }; // Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
} }
function classifyLine(msg) { function classifyLine(msg) {
@@ -565,6 +664,10 @@
auth.style.display = 'none'; auth.style.display = 'none';
} }
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') { if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-'; document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim'); document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
@@ -603,20 +706,19 @@
.then(function(txt) { .then(function(txt) {
var result = parse(txt); var result = parse(txt);
renderLogs(txt); renderLogs(txt);
if (result.state !== 'disconnected') { // Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result); render(result);
} else { });
checkAppRunning().then(function(running) {
if (running) {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
}
}) })
.catch(function() { .catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs'; document.getElementById('status-text').textContent = 'Unable to fetch logs';
@@ -670,6 +772,79 @@
} }
return 0; return 0;
} }
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})(); })();
</script> </script>
+15 -3
View File
@@ -1,5 +1,5 @@
{ {
"schemaVersion": "1.3", "schemaVersion": "1.7.0",
"acapPackageConf": { "acapPackageConf": {
"setup": { "setup": {
"appName": "Tailscale_VPN", "appName": "Tailscale_VPN",
@@ -12,11 +12,23 @@
}, },
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn", "runMode": "respawn",
"version": "1.96.4", "version": "1.98.2",
"architecture": "aarch64" "architecture": "aarch64"
}, },
"configuration": { "configuration": {
"settingPage": "index.html" "settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
}
]
} }
} }
} }
+214
View File
@@ -0,0 +1,214 @@
// 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 <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"
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 void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", 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;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
}
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting (root mode)");
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
-12
View File
@@ -1,12 +0,0 @@
ARG ARCH=aarch64
ARG VERSION=12.3.0
ARG UBUNTU_VERSION=24.04
ARG REPO=axisecp
ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY ./app /opt/app/
WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build .
-5
View File
@@ -1,5 +0,0 @@
To build, from main directory
docker build --tag aarch64 .
docker cp $(docker create aarch64):/opt/app ./build
-29
View File
@@ -1,29 +0,0 @@
PROGS = serverconfig
SRCS = config_updater.c
OBJS = $(SRCS:.c=.o)
PKGS = glib-2.0 gio-2.0 axparameter
CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS))
LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS))
CFLAGS += -Wall \
-Wextra \
-Wformat=2 \
-Wpointer-arith \
-Wbad-function-cast \
-Wstrict-prototypes \
-Wmissing-prototypes \
-Winline \
-Wdisabled-optimization \
-Wfloat-equal \
-W \
-Werror
all: $(PROGS)
$(PROGS): $(OBJS)
$(CC) $(LDFLAGS) $^ $(LIBS) $(LDLIBS) -o $@
clean:
rm -f $(PROGS) *.o *.eap* *_LICENSE.txt package.conf* param.conf tmp*
-253
View File
@@ -1,253 +0,0 @@
/**
* Simple file-based configuration updater for Tailscale
* This avoids the AXParameter system and just writes directly to a config file
*/
#include <axsdk/axparameter.h>
#include <glib-unix.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>
#define APP_NAME "serverconfig"
#define APP_DIR "/usr/local/packages/serverconfig"
#define STATE_DIR APP_DIR "/localdata"
#define CONFIG_FILE STATE_DIR "/config.txt"
#define SCRIPT_PATH "/usr/local/packages/serverconfig/start_tailscale.sh"
#define SCRIPT_SOURCE "/usr/local/packages/serverconfig/lib/start_tailscale.sh"
static gboolean signal_handler(gpointer loop) {
g_main_loop_quit((GMainLoop*)loop);
syslog(LOG_INFO, "Configuration updater stopping.");
return G_SOURCE_REMOVE;
}
// Create localdata directory
static void ensure_localdata_exists(void) {
struct stat st = {0};
if (stat(STATE_DIR, &st) == -1) {
if (mkdir(STATE_DIR, 0755) != 0) {
syslog(LOG_ERR, "Failed to create localdata directory: %s", strerror(errno));
} else {
syslog(LOG_INFO, "Created localdata directory: %s", STATE_DIR);
}
}
}
// Copy script from lib folder to main directory
static void copy_script_file(void) {
char buffer[4096];
ssize_t bytes_read, bytes_written;
int source_fd, dest_fd;
syslog(LOG_INFO, "Copying script from %s to %s", SCRIPT_SOURCE, SCRIPT_PATH);
// Open source file
source_fd = open(SCRIPT_SOURCE, O_RDONLY);
if (source_fd < 0) {
syslog(LOG_ERR, "Failed to open source script: %s", strerror(errno));
return;
}
// Open destination file (create if doesn't exist, truncate if exists)
dest_fd = open(SCRIPT_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0755);
if (dest_fd < 0) {
syslog(LOG_ERR, "Failed to open destination script: %s", strerror(errno));
close(source_fd);
return;
}
// Copy the file
while ((bytes_read = read(source_fd, buffer, sizeof(buffer))) > 0) {
bytes_written = write(dest_fd, buffer, bytes_read);
if (bytes_written != bytes_read) {
syslog(LOG_ERR, "Error writing to destination file: %s", strerror(errno));
close(source_fd);
close(dest_fd);
return;
}
}
// Close file descriptors
close(source_fd);
close(dest_fd);
// Make the script executable
if (chmod(SCRIPT_PATH, 0755) != 0) {
syslog(LOG_ERR, "Failed to make script executable: %s", strerror(errno));
return;
}
syslog(LOG_INFO, "Script copied and made executable successfully");
}
// Execute the Tailscale script
static void start_tailscale(void) {
syslog(LOG_INFO, "Starting Tailscale VPN script");
// Check if script exists, if not, copy it
struct stat st;
if (stat(SCRIPT_PATH, &st) != 0) {
syslog(LOG_INFO, "Script not found at %s, copying from lib folder", SCRIPT_PATH);
copy_script_file();
}
// Fork and execute the script
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "Failed to fork for Tailscale script: %s", strerror(errno));
return;
} else if (pid == 0) {
// Child process - execute the script
execl(SCRIPT_PATH, "start_tailscale.sh", NULL);
// If we get here, execl failed
syslog(LOG_ERR, "Failed to execute Tailscale script: %s", strerror(errno));
_exit(1);
}
syslog(LOG_INFO, "Tailscale script started with PID: %d", pid);
}
// Update the configuration file with current parameter values
static void update_config_file(AXParameter* handle) {
GError* error = NULL;
gchar* server_value = NULL;
gchar* key_value = NULL;
FILE* file;
// Ensure localdata directory exists
ensure_localdata_exists();
// Get parameter values
if (!ax_parameter_get(handle, "CustomServer", &server_value, &error)) {
syslog(LOG_ERR, "Failed to get CustomServer: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
error = NULL;
server_value = g_strdup("");
}
if (!ax_parameter_get(handle, "AuthKey", &key_value, &error)) {
syslog(LOG_ERR, "Failed to get AuthKey: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
key_value = g_strdup("");
}
// Write to config file in localdata
file = fopen(CONFIG_FILE, "w");
if (file) {
fprintf(file, "custom_server=%s\n", server_value ? server_value : "");
fprintf(file, "auth_key=%s\n", key_value ? key_value : "");
fclose(file);
// Set permissions to ensure the file is readable
chmod(CONFIG_FILE, 0644);
syslog(LOG_INFO, "Updated configuration file in local custom_server=%s",
server_value ? server_value : "");
syslog(LOG_INFO, "Updated configuration file in local auth_key=%s",
key_value && strlen(key_value) > 0 ? "(set)" : "(empty)");
} else {
syslog(LOG_ERR, "Failed to open config file for writing: %s", strerror(errno));
}
// Clean up
g_free(server_value);
g_free(key_value);
}
// Handle parameter changes
static void parameter_changed(const gchar* name, const gchar* value, gpointer handle_void_ptr) {
AXParameter* handle = handle_void_ptr;
// Extract simple parameter name from the fully qualified name
const char* simple_name = name;
const char* prefix = "root." APP_NAME ".";
if (strncmp(name, prefix, strlen(prefix)) == 0) {
simple_name = name + strlen(prefix);
}
syslog(LOG_INFO, "Parameter changed: %s = %s", simple_name, value);
// Update config file whenever any parameter changes
update_config_file(handle);
// Restart Tailscale to apply the new settings
start_tailscale();
}
int main(void) {
GError* error = NULL;
GMainLoop* loop = NULL;
// Open syslog for logging
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "Config updater starting");
// Initialize parameter handling
AXParameter* handle = ax_parameter_new(APP_NAME, &error);
if (handle == NULL) {
syslog(LOG_ERR, "Failed to initialize parameters: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
exit(1);
}
// Ensure localdata directory exists
ensure_localdata_exists();
// Ensure script is copied from lib folder
copy_script_file();
// Create initial config file
update_config_file(handle);
// Start Tailscale VPN script
start_tailscale();
// Register for parameter changes
if (!ax_parameter_register_callback(handle, "CustomServer", parameter_changed, handle, &error)) {
syslog(LOG_ERR, "Failed to register CustomServer callback: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
error = NULL;
}
if (!ax_parameter_register_callback(handle, "AuthKey", parameter_changed, handle, &error)) {
syslog(LOG_ERR, "Failed to register AuthKey callback: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
}
// Register for parameter changes with fully qualified names as fallback
if (!ax_parameter_register_callback(handle, "root." APP_NAME ".CustomServer", parameter_changed, handle, NULL)) {
syslog(LOG_INFO, "Fallback CustomServer registration failed (this may be normal)");
}
if (!ax_parameter_register_callback(handle, "root." APP_NAME ".AuthKey", parameter_changed, handle, NULL)) {
syslog(LOG_INFO, "Fallback AuthKey registration failed (this may be normal)");
}
// Set up main loop
loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
syslog(LOG_INFO, "Config updater running. Waiting for parameter changes...");
g_main_loop_run(loop);
// Clean up
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
-82
View File
@@ -1,82 +0,0 @@
#!/bin/sh
# Make sure this script terminates any existing Tailscale processes before starting new ones
# Kill any existing tailscaled processes
killall tailscaled 2>/dev/null || true
# Simple script to start Tailscale with custom configuration
APP_DIR="/usr/local/packages/serverconfig"
STATE_DIR="$APP_DIR/localdata"
CONFIG_FILE="$STATE_DIR/config.txt"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
# Create localdata directory if it doesn't exist
mkdir -p "$STATE_DIR"
# Log to syslog
logger -t "tailscale_script" "Starting Tailscale VPN service"
# Set execute permissions
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Read configuration (if exists)
CUSTOM_SERVER=""
AUTH_KEY=""
if [ -f "$CONFIG_FILE" ]; then
logger -t "tailscale_script" "Reading configuration from $CONFIG_FILE"
# Read values from config file
while IFS='=' read -r key value; do
case "$key" in
"custom_server") CUSTOM_SERVER="$value" ;;
"auth_key") AUTH_KEY="$value" ;;
esac
done < "$CONFIG_FILE"
fi
# Start tailscaled with state stored in localdata
logger -t "tailscale_script" "Starting tailscaled daemon"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
--socks5-server=localhost:1055 \
--tun=userspace-networking \
2>&1 | logger -t "tailscale_script" &
TAILSCALED_PID=$!
# Wait for tailscaled to initialize
sleep 2
# Build up the command based on available parameters
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up"
if [ -n "$CUSTOM_SERVER" ]; then
logger -t "tailscale_script" "Using custom server: $CUSTOM_SERVER"
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
logger -t "tailscale_script" "Using authentication key"
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
# Connect to Tailscale network
logger -t "tailscale_script" "Running: $TAILSCALE_CMD"
eval $TAILSCALE_CMD
UP_EXIT=$?
# Clear auth key from config after first use — Tailscale auth keys are single-use
# and the node identity is persisted in tailscaled.state, so the key is no longer needed.
if [ -n "$AUTH_KEY" ] && [ "$UP_EXIT" -eq 0 ] && [ -f "$CONFIG_FILE" ]; then
logger -t "tailscale_script" "Clearing auth key from config after successful authentication"
printf 'custom_server=%s\nauth_key=\n' "$CUSTOM_SERVER" > "$CONFIG_FILE"
fi
# Keep the script running to maintain the tailscaled process
logger -t "tailscale_script" "Tailscale VPN is running"
logger -t "tailscale_script" "To change settings, modify parameters in ACAP web interface"
# Wait for tailscaled process to exit
wait $TAILSCALED_PID
Binary file not shown.
Binary file not shown.
-29
View File
@@ -1,29 +0,0 @@
{
"schemaVersion": "1.7.3",
"acapPackageConf": {
"setup": {
"friendlyName": "Tailscale VPN",
"appName": "serverconfig",
"vendor": "Mo3he",
"embeddedSdkVersion": "3.0",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.96.4"
},
"configuration": {
"settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
}
]
}
}
}
+3 -2
View File
@@ -1,5 +1,5 @@
ARG ARCH=armv7hf ARG ARCH=armv7hf
ARG VERSION=1.3 ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04 ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp ARG REPO=axisecp
ARG SDK=acap-native-sdk ARG SDK=acap-native-sdk
@@ -9,4 +9,5 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application # Building the ACAP application
COPY ./app /opt/app/ COPY ./app /opt/app/
WORKDIR /opt/app WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+14 -1
View File
@@ -1 +1,14 @@
nop: PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
-31
View File
@@ -1,31 +0,0 @@
#!/bin/sh
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
logger -t "Tailscale_VPN" "Starting Tailscale VPN service"
mkdir -p "$STATE_DIR"
chmod 755 "$APP_DIR/lib/tailscale"
chmod 755 "$APP_DIR/lib/tailscaled"
# Kill any leftover daemon from a previous run
killall tailscaled 2>/dev/null || true
logger -t "Tailscale_VPN" "Starting tailscaled daemon (userspace networking)"
"$APP_DIR/lib/tailscaled" \
--state="$STATE_DIR/tailscaled.state" \
--socket="$STATE_DIR/tailscaled.sock" \
--socks5-server=localhost:1055 \
--tun=userspace-networking \
2>&1 | logger -t "Tailscale_VPN" &
TAILSCALED_PID=$!
sleep 2
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up 2>&1 | logger -t "Tailscale_VPN"
logger -t "Tailscale_VPN" "Tailscale VPN is running"
wait $TAILSCALED_PID
logger -t "Tailscale_VPN" "tailscaled exited"
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
# Tailscale VPN run script — called by the param_bridge C binary.
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Defaults — overridden by sourcing params.conf written by param_bridge
CUSTOM_SERVER=""
AUTH_KEY=""
CONF_HTTP="8080"
CONF_SOCKS="1080"
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
# Check whether a TCP port is already bound
is_port_in_use() {
local port=$1
local hex_port
hex_port=$(printf '%04X' "$port")
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
return 1
}
if is_port_in_use "$CONF_HTTP"; then
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
exit 1
fi
if is_port_in_use "$CONF_SOCKS"; then
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
exit 1
fi
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
--socks5-server=localhost:$CONF_SOCKS \
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
--tun=userspace-networking \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running"
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS"
wait $TAILSCALED_PID
+222 -47
View File
@@ -11,7 +11,7 @@
--border: #262a35; --border: #262a35;
--text: #e4e6ed; --text: #e4e6ed;
--muted: #8b8fa3; --muted: #8b8fa3;
--accent: #4f8ff7; --accent: #2e2d2d;
--green: #22c55e; --green: #22c55e;
--yellow: #f59e0b; --yellow: #f59e0b;
--red: #ef4444; --red: #ef4444;
@@ -26,7 +26,7 @@
--border: #e0e3e8; --border: #e0e3e8;
--text: #1a1a2e; --text: #1a1a2e;
--muted: #6b7084; --muted: #6b7084;
--accent: #2563eb; --accent: #2e2d2d;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -231,13 +231,47 @@
word-break: break-all; word-break: break-all;
} }
.log-box .log-line { display: block; } .log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(79,143,247,0.06); } .log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; } .log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); } .log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); } .log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); } .log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); } .log-line .msg-ok { color: var(--green); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */ /* Refresh indicator */
.refresh-bar { .refresh-bar {
display: flex; display: flex;
@@ -257,8 +291,8 @@
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 14px; margin-bottom: 14px;
background: rgba(79,143,247,0.1); background: rgba(46,45,45,0.1);
border: 1px solid rgba(79,143,247,0.2); border: 1px solid rgba(46,45,45,0.2);
} }
.update-banner.visible { display: flex; } .update-banner.visible { display: flex; }
.update-banner .update-text { .update-banner .update-text {
@@ -292,18 +326,6 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
<rect width="128" height="128" rx="28" fill="#0166FF"/>
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="32" cy="64" r="13" fill="white"/>
<circle cx="64" cy="64" r="13" fill="white"/>
<circle cx="96" cy="64" r="13" fill="white"/>
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="96" r="13" fill="white"/>
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
</svg>
<h1>Tailscale VPN</h1> <h1>Tailscale VPN</h1>
</div> </div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme"> <button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
@@ -364,6 +386,52 @@
</div> </div>
</div> </div>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<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">
<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">
<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>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs --> <!-- Logs -->
<div class="card"> <div class="card">
<div class="log-controls"> <div class="log-controls">
@@ -448,17 +516,18 @@
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null; tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
} }
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g); // Primary: extract hostname from Axis syslog header (always the real device hostname)
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
var node = null; var node = null;
if (nodeMatches) { var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/); if (hostLine) node = hostLine[1];
if (nm) node = nm[1]; // Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
}
if (!node) { if (!node) {
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]" var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/); if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (hostLine) node = hostLine[1]; if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
} }
var loginMatches = txt.match(/active login:\s+\S+/g); var loginMatches = txt.match(/active login:\s+\S+/g);
@@ -493,28 +562,58 @@
} }
} }
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing // Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version); cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip'); tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node'); node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet'); tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version'); version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); var isRunning = /-> Running/.test(lastState);
// Detect running even after syslog rotation (daemon active but startup lines gone)
if (!isRunning && !latestUrl) { // Fallbacks only when syslog has rotated and no state transitions are visible.
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || // If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) || /derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) || /c2n: GET/.test(txt) ||
/localapi:/.test(txt); /localapi:/.test(txt);
} }
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version }; // If an auth URL appears AFTER the last Running state, re-auth is needed
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version }; // (handles stale Running entries in syslog after reinstall or token expiry)
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version }; if (isRunning && latestUrl) {
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version }; // Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
} }
function classifyLine(msg) { function classifyLine(msg) {
@@ -565,6 +664,10 @@
auth.style.display = 'none'; auth.style.display = 'none';
} }
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') { if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-'; document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim'); document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
@@ -603,20 +706,19 @@
.then(function(txt) { .then(function(txt) {
var result = parse(txt); var result = parse(txt);
renderLogs(txt); renderLogs(txt);
if (result.state !== 'disconnected') { // Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result); render(result);
} else { });
checkAppRunning().then(function(running) {
if (running) {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
}
}) })
.catch(function() { .catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs'; document.getElementById('status-text').textContent = 'Unable to fetch logs';
@@ -670,6 +772,79 @@
} }
return 0; return 0;
} }
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})(); })();
</script> </script>
+25 -3
View File
@@ -1,5 +1,5 @@
{ {
"schemaVersion": "1.3", "schemaVersion": "1.7.0",
"acapPackageConf": { "acapPackageConf": {
"setup": { "setup": {
"appName": "Tailscale_VPN", "appName": "Tailscale_VPN",
@@ -8,11 +8,33 @@
"embeddedSdkVersion": "3.0", "embeddedSdkVersion": "3.0",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn", "runMode": "respawn",
"version": "1.96.4", "version": "1.98.2",
"architecture": "armv7hf" "architecture": "armv7hf"
}, },
"configuration": { "configuration": {
"settingPage": "index.html" "settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
}
]
} }
} }
} }
+250
View File
@@ -0,0 +1,250 @@
// 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 <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"
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static AXParameter *g_ax_handle = NULL;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
/* ── watchdog ────────────────────────────────────────────────────────────── */
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", 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;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
cache_get(&cfg_http_proxy_port, "8080"),
cache_get(&cfg_socks5_port, "1080"),
cache_get(&cfg_custom_server, "(default)"));
}
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
/* ── main ────────────────────────────────────────────────────────────────── */
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting");
/* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+3 -2
View File
@@ -1,5 +1,5 @@
ARG ARCH=armv7hf ARG ARCH=armv7hf
ARG VERSION=1.3 ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04 ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp ARG REPO=axisecp
ARG SDK=acap-native-sdk ARG SDK=acap-native-sdk
@@ -9,4 +9,5 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application # Building the ACAP application
COPY ./app /opt/app/ COPY ./app /opt/app/
WORKDIR /opt/app WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+14 -1
View File
@@ -1 +1,14 @@
nop: PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
-29
View File
@@ -1,29 +0,0 @@
#!/bin/sh
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
logger -t "Tailscale_VPN" "Starting Tailscale VPN service (root mode)"
mkdir -p "$STATE_DIR"
chmod 755 "$APP_DIR/lib/tailscale"
chmod 755 "$APP_DIR/lib/tailscaled"
# Kill any leftover daemon from a previous run
killall tailscaled 2>/dev/null || true
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
"$APP_DIR/lib/tailscaled" \
--state="$STATE_DIR/tailscaled.state" \
--socket="$STATE_DIR/tailscaled.sock" \
2>&1 | logger -t "Tailscale_VPN" &
TAILSCALED_PID=$!
sleep 2
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --accept-routes 2>&1 | logger -t "Tailscale_VPN"
logger -t "Tailscale_VPN" "Tailscale VPN is running"
wait $TAILSCALED_PID
logger -t "Tailscale_VPN" "tailscaled exited"
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
# Tailscale VPN run script (ROOT / kernel networking variant).
# Sources config from params.conf written by param_bridge.
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
CUSTOM_SERVER=""
AUTH_KEY=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
wait $TAILSCALED_PID
+222 -47
View File
@@ -11,7 +11,7 @@
--border: #262a35; --border: #262a35;
--text: #e4e6ed; --text: #e4e6ed;
--muted: #8b8fa3; --muted: #8b8fa3;
--accent: #4f8ff7; --accent: #2e2d2d;
--green: #22c55e; --green: #22c55e;
--yellow: #f59e0b; --yellow: #f59e0b;
--red: #ef4444; --red: #ef4444;
@@ -26,7 +26,7 @@
--border: #e0e3e8; --border: #e0e3e8;
--text: #1a1a2e; --text: #1a1a2e;
--muted: #6b7084; --muted: #6b7084;
--accent: #2563eb; --accent: #2e2d2d;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -231,13 +231,47 @@
word-break: break-all; word-break: break-all;
} }
.log-box .log-line { display: block; } .log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(79,143,247,0.06); } .log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; } .log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); } .log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); } .log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); } .log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); } .log-line .msg-ok { color: var(--green); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */ /* Refresh indicator */
.refresh-bar { .refresh-bar {
display: flex; display: flex;
@@ -257,8 +291,8 @@
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 14px; margin-bottom: 14px;
background: rgba(79,143,247,0.1); background: rgba(46,45,45,0.1);
border: 1px solid rgba(79,143,247,0.2); border: 1px solid rgba(46,45,45,0.2);
} }
.update-banner.visible { display: flex; } .update-banner.visible { display: flex; }
.update-banner .update-text { .update-banner .update-text {
@@ -292,18 +326,6 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
<rect width="128" height="128" rx="28" fill="#0166FF"/>
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="32" cy="64" r="13" fill="white"/>
<circle cx="64" cy="64" r="13" fill="white"/>
<circle cx="96" cy="64" r="13" fill="white"/>
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="96" r="13" fill="white"/>
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
</svg>
<h1>Tailscale VPN</h1> <h1>Tailscale VPN</h1>
</div> </div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme"> <button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
@@ -364,6 +386,52 @@
</div> </div>
</div> </div>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<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">
<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">
<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>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs --> <!-- Logs -->
<div class="card"> <div class="card">
<div class="log-controls"> <div class="log-controls">
@@ -448,17 +516,18 @@
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null; tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
} }
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g); // Primary: extract hostname from Axis syslog header (always the real device hostname)
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
var node = null; var node = null;
if (nodeMatches) { var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/); if (hostLine) node = hostLine[1];
if (nm) node = nm[1]; // Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
}
if (!node) { if (!node) {
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]" var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/); if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (hostLine) node = hostLine[1]; if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
} }
var loginMatches = txt.match(/active login:\s+\S+/g); var loginMatches = txt.match(/active login:\s+\S+/g);
@@ -493,28 +562,58 @@
} }
} }
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing // Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version); cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip'); tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node'); node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet'); tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version'); version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); var isRunning = /-> Running/.test(lastState);
// Detect running even after syslog rotation (daemon active but startup lines gone)
if (!isRunning && !latestUrl) { // Fallbacks only when syslog has rotated and no state transitions are visible.
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || // If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) || /derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) || /c2n: GET/.test(txt) ||
/localapi:/.test(txt); /localapi:/.test(txt);
} }
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version }; // If an auth URL appears AFTER the last Running state, re-auth is needed
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version }; // (handles stale Running entries in syslog after reinstall or token expiry)
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version }; if (isRunning && latestUrl) {
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version }; // Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
} }
function classifyLine(msg) { function classifyLine(msg) {
@@ -565,6 +664,10 @@
auth.style.display = 'none'; auth.style.display = 'none';
} }
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') { if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-'; document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim'); document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
@@ -603,20 +706,19 @@
.then(function(txt) { .then(function(txt) {
var result = parse(txt); var result = parse(txt);
renderLogs(txt); renderLogs(txt);
if (result.state !== 'disconnected') { // Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result); render(result);
} else { });
checkAppRunning().then(function(running) {
if (running) {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
}
}) })
.catch(function() { .catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs'; document.getElementById('status-text').textContent = 'Unable to fetch logs';
@@ -670,6 +772,79 @@
} }
return 0; return 0;
} }
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})(); })();
</script> </script>
+15 -3
View File
@@ -1,5 +1,5 @@
{ {
"schemaVersion": "1.3", "schemaVersion": "1.7.0",
"acapPackageConf": { "acapPackageConf": {
"setup": { "setup": {
"appName": "Tailscale_VPN", "appName": "Tailscale_VPN",
@@ -12,11 +12,23 @@
}, },
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn", "runMode": "respawn",
"version": "1.96.4", "version": "1.98.2",
"architecture": "armv7hf" "architecture": "armv7hf"
}, },
"configuration": { "configuration": {
"settingPage": "index.html" "settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
}
]
} }
} }
} }
+214
View File
@@ -0,0 +1,214 @@
// 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 <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"
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static AXParameter *g_ax_handle = NULL;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", 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;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
}
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting (root mode)");
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+44
View File
@@ -0,0 +1,44 @@
ARG UBUNTU_VERSION=20.04
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/
WORKDIR /opt/app
# Rename the shell startup script (the ELF launcher will take the Tailscale_VPN name)
RUN mv Tailscale_VPN start.sh && chmod +x start.sh
# Compile a minimal ELF launcher as APPNAME.
# ACAP 3 elflibcheck requires an ELF binary and uses pidof(APPNAME) for status.
# We fork+exec start.sh so the parent "Tailscale_VPN" process stays resident,
# making pidof find it and the camera UI correctly show Running/Stopped.
RUN . /opt/axis/acapsdk/environment-setup* && \
${CC} -o Tailscale_VPN launcher.c && \
${STRIP} -s Tailscale_VPN
# Strip then UPX-compress the Tailscale binaries so they fit on flash
RUN . /opt/axis/acapsdk/environment-setup* && \
${STRIP} -s lib/tailscale lib/tailscaled 2>/dev/null || true && \
upx --best lib/tailscale lib/tailscaled
# ACAP 3 firmware expects the settings page at the app root, not in html/
RUN cp html/index.html index.html
# Symlink tailscaled.log into html/ so the web UI can fetch it via HTTP.
# The log is written at runtime to localdata/ (resolved path at runtime).
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log
# Build and package
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
# Patch STARTMODE: create-package.sh hardcodes "never" unless RESTRICTION_STARTMODE is set,
# but the ACAP 3 SDK does not honour our package.conf's STARTMODE=respawn without it.
# Repack the .eap with the corrected value.
RUN for eap in *.eap; do \
tmpdir=$(mktemp -d) && tar xf "$eap" -C "$tmpdir" && \
sed -i 's/STARTMODE="never"/STARTMODE="respawn"/' "$tmpdir/package.conf" && \
(cd "$tmpdir" && tar czf "/opt/app/$eap" .) && \
rm -rf "$tmpdir"; \
done
+1
View File
@@ -0,0 +1 @@
nop:
+65
View File
@@ -0,0 +1,65 @@
#!/bin/sh
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
logger -t "Tailscale_VPN" "Starting Tailscale VPN service (userspace networking)"
mkdir -p "$STATE_DIR"
chmod 755 "$APP_DIR/lib/tailscale"
chmod 755 "$APP_DIR/lib/tailscaled"
# Kill any leftover daemon from a previous run
killall tailscaled 2>/dev/null || true
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
# Log to file (not piped through logger) -- avoids extra logger process holding
# tailscaled stdout open, which prevents our wait loop from detecting exit
"$APP_DIR/lib/tailscaled" \
--state="$STATE_DIR/tailscaled.state" \
--socket="$STATE_DIR/tailscaled.sock" \
--socks5-server=localhost:1055 \
--outbound-http-proxy-listen=localhost:8080 \
--tun=userspace-networking \
>> "$STATE_DIR/tailscaled.log" 2>&1 &
TAILSCALED_PID=$!
# Wait for socket to appear (up to 15 seconds)
i=0
while [ $i -lt 15 ] && [ ! -S "$STATE_DIR/tailscaled.sock" ]; do
sleep 1
i=$((i + 1))
done
logger -t "Tailscale_VPN" "Connecting to Tailscale network"
# --timeout=10s: tailscale up exits promptly after connecting (or giving up),
# preventing two large Go binaries running simultaneously and causing OOM on
# cameras with limited RAM (e.g. 222 MB).
# Capture output so we can extract auth URL and log it to syslog for the web UI.
UP_OUT=$("$APP_DIR/lib/tailscale" \
--socket="$STATE_DIR/tailscaled.sock" \
up --hostname="$(hostname)" --timeout=10s 2>&1) || true
echo "$UP_OUT" >> "$STATE_DIR/tailscaled.log"
# If an auth URL was returned, log it so the web UI can show it
AUTH_URL=$(echo "$UP_OUT" | grep -o 'https://login\.tailscale\.com/[^ ]*' | head -1)
[ -n "$AUTH_URL" ] && logger -t "Tailscale_VPN" "Auth required: $AUTH_URL"
# Log IP and version into syslog so the web UI details panel can populate
TS_IP=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" ip -4 2>/dev/null | head -1)
TS_VER=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" version 2>/dev/null | head -1)
[ -n "$TS_IP" ] && logger -t "Tailscale_VPN" "Tailscale IP: $TS_IP"
[ -n "$TS_VER" ] && logger -t "Tailscale_VPN" "Tailscale version: $TS_VER"
logger -t "Tailscale_VPN" "Tailscale VPN is running"
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
# Monitoring loop: stay alive while tailscaled is running.
# This keeps the parent Tailscale_VPN (C launcher) in the process table
# so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
while kill -0 "$TAILSCALED_PID" 2>/dev/null; do
sleep 5
done
logger -t "Tailscale_VPN" "tailscaled exited"
@@ -11,7 +11,7 @@
--border: #262a35; --border: #262a35;
--text: #e4e6ed; --text: #e4e6ed;
--muted: #8b8fa3; --muted: #8b8fa3;
--accent: #4f8ff7; --accent: #2e2d2d;
--green: #22c55e; --green: #22c55e;
--yellow: #f59e0b; --yellow: #f59e0b;
--red: #ef4444; --red: #ef4444;
@@ -26,7 +26,7 @@
--border: #e0e3e8; --border: #e0e3e8;
--text: #1a1a2e; --text: #1a1a2e;
--muted: #6b7084; --muted: #6b7084;
--accent: #2563eb; --accent: #2e2d2d;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -231,7 +231,7 @@
word-break: break-all; word-break: break-all;
} }
.log-box .log-line { display: block; } .log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(79,143,247,0.06); } .log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; } .log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); } .log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); } .log-line .msg-warn { color: var(--yellow); }
@@ -257,8 +257,8 @@
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 14px; margin-bottom: 14px;
background: rgba(79,143,247,0.1); background: rgba(46,45,45,0.1);
border: 1px solid rgba(79,143,247,0.2); border: 1px solid rgba(46,45,45,0.2);
} }
.update-banner.visible { display: flex; } .update-banner.visible { display: flex; }
.update-banner .update-text { .update-banner .update-text {
@@ -292,18 +292,6 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
<rect width="128" height="128" rx="28" fill="#0166FF"/>
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="32" cy="64" r="13" fill="white"/>
<circle cx="64" cy="64" r="13" fill="white"/>
<circle cx="96" cy="64" r="13" fill="white"/>
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="96" r="13" fill="white"/>
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
</svg>
<h1>Tailscale VPN</h1> <h1>Tailscale VPN</h1>
</div> </div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme"> <button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
@@ -383,7 +371,7 @@
<script> <script>
(function() { (function() {
var APP = 'serverconfig'; var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP; var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box'); var logBox = document.getElementById('log-box');
var autoScroll = true; var autoScroll = true;
@@ -448,17 +436,18 @@
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null; tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
} }
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g); // Primary: extract hostname from Axis syslog header (always the real device hostname)
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
var node = null; var node = null;
if (nodeMatches) { var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/); if (hostLine) node = hostLine[1];
if (nm) node = nm[1]; // Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
}
if (!node) { if (!node) {
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]" var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/); if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (hostLine) node = hostLine[1]; if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
} }
var loginMatches = txt.match(/active login:\s+\S+/g); var loginMatches = txt.match(/active login:\s+\S+/g);
@@ -492,6 +481,17 @@
if (pm) version = pm[1]; if (pm) version = pm[1];
} }
} }
if (!version) {
// ACAP3: logged by start.sh as "Tailscale version: 1.96.4"
var acap3ver = txt.match(/Tailscale version: (\d+\.\d+\.\d+)/);
if (acap3ver) version = acap3ver[1];
}
// ACAP3: extract IP from "Tailscale IP: 100.x.x.x" logged by start.sh
if (!tsIP) {
var acap3ip = txt.match(/Tailscale IP: (100\.[\d.]+)/);
if (acap3ip) tsIP = acap3ip[1];
}
// Cache when found, restore from cache when missing // Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version); cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
@@ -502,15 +502,27 @@
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || []; var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : ''; var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt); var isRunning = /-> Running/.test(lastState);
// Detect running even after syslog rotation (daemon active but startup lines gone)
if (!isRunning && !latestUrl) { if (!isRunning && stateLines.length === 0) {
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) || isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) || /derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) || /c2n: GET/.test(txt) ||
/localapi:/.test(txt); /localapi:/.test(txt);
} }
// If an auth URL appears AFTER the last Running state, re-auth is needed
if (isRunning && latestUrl) {
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version }; if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version }; if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version }; if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
@@ -597,30 +609,38 @@
.catch(function() { return false; }); .catch(function() { return false; });
} }
// ACAP3: also fetch the raw tailscaled.log (symlinked into html/) so the parser
// can find IP, version, tailnet and Running state from tailscaled's own output.
var DAEMON_LOG_URL = 'tailscaled.log';
function refresh() { function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' }) var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); }) .then(function(r) { return r.text(); }).catch(function() { return ''; });
.then(function(txt) { var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
var result = parse(txt); .then(function(r) { return r.text(); }).catch(function() { return ''; });
renderLogs(txt);
if (result.state !== 'disconnected') { Promise.all([syslogFetch, daemonFetch]).then(function(res) {
// Syslog provides Axis timestamp headers (node name) and start/stop events.
// tailscaled.log provides IP, version, tailnet, and -> Running state.
var txt = res[0] + '\n' + res[1];
var result = parse(txt);
renderLogs(res[0]); // show syslog in log panel; daemon log is too verbose
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result); render(result);
} else { });
checkAppRunning().then(function(running) { }).catch(function() {
if (running) { document.getElementById('status-text').textContent = 'Unable to fetch logs';
result.state = 'connected'; });
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
}
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
} }
refresh(); refresh();
+67
View File
@@ -0,0 +1,67 @@
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
#include <signal.h>
/*
* ACAP 3 supervisor launcher for Tailscale_VPN.
*
* elflibcheck.sh requires APPNAME to be an ELF binary.
* acap-startstop / respawnd / list.cgi all use pidof(APPNAME) for status.
*
* This binary NEVER exits voluntarily — it loops restarting start.sh if it
* dies, so:
* - pidof Tailscale_VPN always finds this process → UI shows "Running"
* - respawnd never triggers (it only fires when APPNAME exits)
* - If tailscaled OOMs and start.sh exits, we cleanly restart it
*
* To stop the app, acap-startstop calls stop_daemon which sends SIGTERM here.
* We forward SIGTERM/SIGINT to the child and then exit.
*/
static volatile int g_stop = 0;
static volatile pid_t g_child = -1;
static void sig_forward(int sig) {
g_stop = 1;
if (g_child > 0)
kill(g_child, sig);
}
int main(void)
{
signal(SIGTERM, sig_forward);
signal(SIGINT, sig_forward);
signal(SIGCHLD, SIG_DFL);
while (!g_stop) {
pid_t pid = fork();
if (pid == 0) {
/* child: reset signals and exec start.sh */
signal(SIGTERM, SIG_DFL);
signal(SIGINT, SIG_DFL);
execl("/usr/local/packages/Tailscale_VPN/start.sh",
"/usr/local/packages/Tailscale_VPN/start.sh", (char *)0);
_exit(127);
}
if (pid < 0) {
sleep(5);
continue;
}
g_child = pid;
int status;
pid_t ret;
do {
ret = waitpid(pid, &status, 0);
} while (ret == -1 && errno == EINTR && !g_stop);
g_child = -1;
if (!g_stop) {
/* start.sh died unexpectedly — wait before restarting */
sleep(3);
}
}
return 0;
}
+14
View File
@@ -0,0 +1,14 @@
PACKAGENAME=Tailscale_VPN
MENUNAME="Tailscale VPN"
VENDOR="Mo3he"
APPMAJORVERSION=1
APPMINORVERSION=98
APPMICROVERSION=2
APPTYPE=armv7hf
APPNAME=Tailscale_VPN
APPOPTS=""
STARTMODE=respawn
OTHERFILES="lib html index.html LICENSE start.sh"
SETTINGSPAGEFILE=index.html
GRPNAME=
USERNAME=root
-12
View File
@@ -1,12 +0,0 @@
ARG ARCH=armv7hf
ARG VERSION=12.3.0
ARG UBUNTU_VERSION=24.04
ARG REPO=axisecp
ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY ./app /opt/app/
WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build .
-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.
-29
View File
@@ -1,29 +0,0 @@
PROGS = serverconfig
SRCS = config_updater.c
OBJS = $(SRCS:.c=.o)
PKGS = glib-2.0 gio-2.0 axparameter
CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS))
LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS))
CFLAGS += -Wall \
-Wextra \
-Wformat=2 \
-Wpointer-arith \
-Wbad-function-cast \
-Wstrict-prototypes \
-Wmissing-prototypes \
-Winline \
-Wdisabled-optimization \
-Wfloat-equal \
-W \
-Werror
all: $(PROGS)
$(PROGS): $(OBJS)
$(CC) $(LDFLAGS) $^ $(LIBS) $(LDLIBS) -o $@
clean:
rm -f $(PROGS) *.o *.eap* *_LICENSE.txt package.conf* param.conf tmp*
-253
View File
@@ -1,253 +0,0 @@
/**
* Simple file-based configuration updater for Tailscale
* This avoids the AXParameter system and just writes directly to a config file
*/
#include <axsdk/axparameter.h>
#include <glib-unix.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>
#define APP_NAME "serverconfig"
#define APP_DIR "/usr/local/packages/serverconfig"
#define STATE_DIR APP_DIR "/localdata"
#define CONFIG_FILE STATE_DIR "/config.txt"
#define SCRIPT_PATH "/usr/local/packages/serverconfig/start_tailscale.sh"
#define SCRIPT_SOURCE "/usr/local/packages/serverconfig/lib/start_tailscale.sh"
static gboolean signal_handler(gpointer loop) {
g_main_loop_quit((GMainLoop*)loop);
syslog(LOG_INFO, "Configuration updater stopping.");
return G_SOURCE_REMOVE;
}
// Create localdata directory
static void ensure_localdata_exists(void) {
struct stat st = {0};
if (stat(STATE_DIR, &st) == -1) {
if (mkdir(STATE_DIR, 0755) != 0) {
syslog(LOG_ERR, "Failed to create localdata directory: %s", strerror(errno));
} else {
syslog(LOG_INFO, "Created localdata directory: %s", STATE_DIR);
}
}
}
// Copy script from lib folder to main directory
static void copy_script_file(void) {
char buffer[4096];
ssize_t bytes_read, bytes_written;
int source_fd, dest_fd;
syslog(LOG_INFO, "Copying script from %s to %s", SCRIPT_SOURCE, SCRIPT_PATH);
// Open source file
source_fd = open(SCRIPT_SOURCE, O_RDONLY);
if (source_fd < 0) {
syslog(LOG_ERR, "Failed to open source script: %s", strerror(errno));
return;
}
// Open destination file (create if doesn't exist, truncate if exists)
dest_fd = open(SCRIPT_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0755);
if (dest_fd < 0) {
syslog(LOG_ERR, "Failed to open destination script: %s", strerror(errno));
close(source_fd);
return;
}
// Copy the file
while ((bytes_read = read(source_fd, buffer, sizeof(buffer))) > 0) {
bytes_written = write(dest_fd, buffer, bytes_read);
if (bytes_written != bytes_read) {
syslog(LOG_ERR, "Error writing to destination file: %s", strerror(errno));
close(source_fd);
close(dest_fd);
return;
}
}
// Close file descriptors
close(source_fd);
close(dest_fd);
// Make the script executable
if (chmod(SCRIPT_PATH, 0755) != 0) {
syslog(LOG_ERR, "Failed to make script executable: %s", strerror(errno));
return;
}
syslog(LOG_INFO, "Script copied and made executable successfully");
}
// Execute the Tailscale script
static void start_tailscale(void) {
syslog(LOG_INFO, "Starting Tailscale VPN script");
// Check if script exists, if not, copy it
struct stat st;
if (stat(SCRIPT_PATH, &st) != 0) {
syslog(LOG_INFO, "Script not found at %s, copying from lib folder", SCRIPT_PATH);
copy_script_file();
}
// Fork and execute the script
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "Failed to fork for Tailscale script: %s", strerror(errno));
return;
} else if (pid == 0) {
// Child process - execute the script
execl(SCRIPT_PATH, "start_tailscale.sh", NULL);
// If we get here, execl failed
syslog(LOG_ERR, "Failed to execute Tailscale script: %s", strerror(errno));
_exit(1);
}
syslog(LOG_INFO, "Tailscale script started with PID: %d", pid);
}
// Update the configuration file with current parameter values
static void update_config_file(AXParameter* handle) {
GError* error = NULL;
gchar* server_value = NULL;
gchar* key_value = NULL;
FILE* file;
// Ensure localdata directory exists
ensure_localdata_exists();
// Get parameter values
if (!ax_parameter_get(handle, "CustomServer", &server_value, &error)) {
syslog(LOG_ERR, "Failed to get CustomServer: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
error = NULL;
server_value = g_strdup("");
}
if (!ax_parameter_get(handle, "AuthKey", &key_value, &error)) {
syslog(LOG_ERR, "Failed to get AuthKey: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
key_value = g_strdup("");
}
// Write to config file in localdata
file = fopen(CONFIG_FILE, "w");
if (file) {
fprintf(file, "custom_server=%s\n", server_value ? server_value : "");
fprintf(file, "auth_key=%s\n", key_value ? key_value : "");
fclose(file);
// Set permissions to ensure the file is readable
chmod(CONFIG_FILE, 0644);
syslog(LOG_INFO, "Updated configuration file in local custom_server=%s",
server_value ? server_value : "");
syslog(LOG_INFO, "Updated configuration file in local auth_key=%s",
key_value && strlen(key_value) > 0 ? "(set)" : "(empty)");
} else {
syslog(LOG_ERR, "Failed to open config file for writing: %s", strerror(errno));
}
// Clean up
g_free(server_value);
g_free(key_value);
}
// Handle parameter changes
static void parameter_changed(const gchar* name, const gchar* value, gpointer handle_void_ptr) {
AXParameter* handle = handle_void_ptr;
// Extract simple parameter name from the fully qualified name
const char* simple_name = name;
const char* prefix = "root." APP_NAME ".";
if (strncmp(name, prefix, strlen(prefix)) == 0) {
simple_name = name + strlen(prefix);
}
syslog(LOG_INFO, "Parameter changed: %s = %s", simple_name, value);
// Update config file whenever any parameter changes
update_config_file(handle);
// Restart Tailscale to apply the new settings
start_tailscale();
}
int main(void) {
GError* error = NULL;
GMainLoop* loop = NULL;
// Open syslog for logging
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "Config updater starting");
// Initialize parameter handling
AXParameter* handle = ax_parameter_new(APP_NAME, &error);
if (handle == NULL) {
syslog(LOG_ERR, "Failed to initialize parameters: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
exit(1);
}
// Ensure localdata directory exists
ensure_localdata_exists();
// Ensure script is copied from lib folder
copy_script_file();
// Create initial config file
update_config_file(handle);
// Start Tailscale VPN script
start_tailscale();
// Register for parameter changes
if (!ax_parameter_register_callback(handle, "CustomServer", parameter_changed, handle, &error)) {
syslog(LOG_ERR, "Failed to register CustomServer callback: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
error = NULL;
}
if (!ax_parameter_register_callback(handle, "AuthKey", parameter_changed, handle, &error)) {
syslog(LOG_ERR, "Failed to register AuthKey callback: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
}
// Register for parameter changes with fully qualified names as fallback
if (!ax_parameter_register_callback(handle, "root." APP_NAME ".CustomServer", parameter_changed, handle, NULL)) {
syslog(LOG_INFO, "Fallback CustomServer registration failed (this may be normal)");
}
if (!ax_parameter_register_callback(handle, "root." APP_NAME ".AuthKey", parameter_changed, handle, NULL)) {
syslog(LOG_INFO, "Fallback AuthKey registration failed (this may be normal)");
}
// Set up main loop
loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
syslog(LOG_INFO, "Config updater running. Waiting for parameter changes...");
g_main_loop_run(loop);
// Clean up
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
-677
View File
@@ -1,677 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tailscale VPN</title>
<style>
:root {
--bg: #0f1117;
--surface: #181b23;
--surface2: #1e2230;
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #4f8ff7;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
--radius: 10px;
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
}
[data-theme="light"] {
--bg: #f5f6f8;
--surface: #ffffff;
--surface2: #f0f1f4;
--border: #e0e3e8;
--text: #1a1a2e;
--muted: #6b7084;
--accent: #2563eb;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 20px;
font-size: 14px;
max-width: 720px;
margin: 0 auto;
line-height: 1.5;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
}
.theme-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
border-radius: 8px;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
.theme-btn svg { width: 16px; height: 16px; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 14px;
}
.card-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 14px;
}
/* Status */
.status-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 14px;
font-weight: 600;
}
.status-banner.connected .status-text { color: var(--green); }
.status-banner.connecting .status-text { color: var(--yellow); }
.status-banner.disconnected .status-text { color: var(--red); }
.status-time {
margin-left: auto;
font-size: 12px;
color: var(--muted);
font-family: var(--mono);
}
/* Auth block */
.auth-block {
background: rgba(245,158,11,0.08);
border: 1px solid rgba(245,158,11,0.2);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.auth-block p {
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
.auth-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 13px;
padding: 8px 18px;
border-radius: 6px;
margin-bottom: 8px;
}
.auth-btn:hover { opacity: 0.9; }
.auth-url {
display: block;
font-size: 11px;
color: var(--muted);
word-break: break-all;
font-family: var(--mono);
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
background: var(--surface2);
border-radius: 8px;
padding: 12px 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--muted);
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
font-weight: 600;
font-family: var(--mono);
word-break: break-all;
}
.info-value.dim { color: var(--muted); font-weight: 400; }
/* Log viewer */
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.log-badge {
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.log-toggle {
font-size: 12px;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
font-weight: 600;
}
.log-toggle:hover { text-decoration: underline; }
.log-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
max-height: 400px;
overflow-y: auto;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--muted);
white-space: pre-wrap;
word-break: break-all;
}
.log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(79,143,247,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); }
/* Refresh indicator */
.refresh-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(79,143,247,0.1);
border: 1px solid rgba(79,143,247,0.2);
}
.update-banner.visible { display: flex; }
.update-banner .update-text {
flex: 1;
font-size: 13px;
color: var(--text);
}
.update-banner .update-text strong { color: var(--accent); }
.update-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 12px;
padding: 6px 14px;
border-radius: 6px;
white-space: nowrap;
}
.update-btn:hover { opacity: 0.9; }
@media (max-width: 480px) {
body { padding: 14px; }
.info-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
<rect width="128" height="128" rx="28" fill="#0166FF"/>
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
<circle cx="32" cy="64" r="13" fill="white"/>
<circle cx="64" cy="64" r="13" fill="white"/>
<circle cx="96" cy="64" r="13" fill="white"/>
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
<circle cx="64" cy="96" r="13" fill="white"/>
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
</svg>
<h1>Tailscale VPN</h1>
</div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
<!-- Status -->
<div id="status-banner" class="status-banner connecting">
<span class="dot"></span>
<span id="status-text" class="status-text">Checking...</span>
<span id="status-time" class="status-time"></span>
</div>
<!-- Update available -->
<div id="update-banner" class="update-banner">
<div class="update-text">Update available: <strong id="update-version"></strong></div>
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</a>
</div>
<!-- Auth (hidden by default) -->
<div id="auth-block" class="auth-block" style="display:none;">
<p>Authenticate this device to connect to your Tailscale network:</p>
<a id="auth-link" class="auth-btn" href="#" target="_blank">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open Login Page
</a>
<span id="auth-url-text" class="auth-url"></span>
</div>
<!-- Connection Info -->
<div class="card" id="info-card" style="display:none;">
<div class="card-title">Connection Details</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Tailscale IP</div>
<div class="info-value" id="ts-ip">-</div>
</div>
<div class="info-item">
<div class="info-label">Node Name</div>
<div class="info-value" id="ts-node">-</div>
</div>
<div class="info-item">
<div class="info-label">Account</div>
<div class="info-value" id="ts-tailnet">-</div>
</div>
<div class="info-item">
<div class="info-label">Version</div>
<div class="info-value" id="ts-version">-</div>
</div>
</div>
<div style="margin-top:14px;text-align:right;">
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = 'serverconfig';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box');
var autoScroll = true;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
var node = null;
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
if (!node) {
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]"
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
// Detect running even after syslog rotation (daemon active but startup lines gone)
if (!isRunning && !latestUrl) {
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
if (result.state !== 'disconnected') {
render(result);
} else {
checkAppRunning().then(function(running) {
if (running) {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
}
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
}
refresh();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
})();
</script>
</body>
</html>
View File
-82
View File
@@ -1,82 +0,0 @@
#!/bin/sh
# Make sure this script terminates any existing Tailscale processes before starting new ones
# Kill any existing tailscaled processes
killall tailscaled 2>/dev/null || true
# Simple script to start Tailscale with custom configuration
APP_DIR="/usr/local/packages/serverconfig"
STATE_DIR="$APP_DIR/localdata"
CONFIG_FILE="$STATE_DIR/config.txt"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
# Create localdata directory if it doesn't exist
mkdir -p "$STATE_DIR"
# Log to syslog
logger -t "tailscale_script" "Starting Tailscale VPN service"
# Set execute permissions
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Read configuration (if exists)
CUSTOM_SERVER=""
AUTH_KEY=""
if [ -f "$CONFIG_FILE" ]; then
logger -t "tailscale_script" "Reading configuration from $CONFIG_FILE"
# Read values from config file
while IFS='=' read -r key value; do
case "$key" in
"custom_server") CUSTOM_SERVER="$value" ;;
"auth_key") AUTH_KEY="$value" ;;
esac
done < "$CONFIG_FILE"
fi
# Start tailscaled with state stored in localdata
logger -t "tailscale_script" "Starting tailscaled daemon"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
--socks5-server=localhost:1055 \
--tun=userspace-networking \
2>&1 | logger -t "tailscale_script" &
TAILSCALED_PID=$!
# Wait for tailscaled to initialize
sleep 2
# Build up the command based on available parameters
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up"
if [ -n "$CUSTOM_SERVER" ]; then
logger -t "tailscale_script" "Using custom server: $CUSTOM_SERVER"
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
logger -t "tailscale_script" "Using authentication key"
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
# Connect to Tailscale network
logger -t "tailscale_script" "Running: $TAILSCALE_CMD"
eval $TAILSCALE_CMD
UP_EXIT=$?
# Clear auth key from config after first use — Tailscale auth keys are single-use
# and the node identity is persisted in tailscaled.state, so the key is no longer needed.
if [ -n "$AUTH_KEY" ] && [ "$UP_EXIT" -eq 0 ] && [ -f "$CONFIG_FILE" ]; then
logger -t "tailscale_script" "Clearing auth key from config after successful authentication"
printf 'custom_server=%s\nauth_key=\n' "$CUSTOM_SERVER" > "$CONFIG_FILE"
fi
# Keep the script running to maintain the tailscaled process
logger -t "tailscale_script" "Tailscale VPN is running"
logger -t "tailscale_script" "To change settings, modify parameters in ACAP web interface"
# Wait for tailscaled process to exit
wait $TAILSCALED_PID
-30
View File
@@ -1,30 +0,0 @@
{
"schemaVersion": "1.7.3",
"acapPackageConf": {
"setup": {
"friendlyName": "Tailscale VPN",
"appName": "serverconfig",
"vendor": "Mo3he",
"embeddedSdkVersion": "3.0",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.96.4",
"architecture": "armv7hf"
},
"configuration": {
"settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
}
]
}
}
}
+57 -39
View File
@@ -3,22 +3,22 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tailscale VPN for Axis Cameras</title> <title>Tailscale VPN for Axis Devices</title>
<meta name="description" content="Install Tailscale VPN directly on your Axis camera. Secure remote access with WireGuard, no extra hardware needed."> <meta name="description" content="Install Tailscale VPN directly on your Axis device. Secure remote access with WireGuard, no extra hardware needed.">
<!-- Open Graph / LinkedIn --> <!-- Open Graph / LinkedIn -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://mo3he.github.io/Axis_Cam_Tailscale/"> <meta property="og:url" content="https://mo3he.github.io/Axis_Cam_Tailscale/">
<meta property="og:title" content="Tailscale VPN for Axis Cameras"> <meta property="og:title" content="Tailscale VPN for Axis Devices">
<meta property="og:description" content="Secure remote access to your Axis cameras over WireGuard. No extra hardware, no complex network config - just install and connect."> <meta property="og:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
<meta property="og:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png"> <meta property="og:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
<meta property="og:image:width" content="1340"> <meta property="og:image:width" content="1340">
<meta property="og:image:height" content="724"> <meta property="og:image:height" content="724">
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Tailscale VPN for Axis Cameras"> <meta name="twitter:title" content="Tailscale VPN for Axis Devices">
<meta name="twitter:description" content="Secure remote access to your Axis cameras over WireGuard. No extra hardware, no complex network config - just install and connect."> <meta name="twitter:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
<meta name="twitter:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png"> <meta name="twitter:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
<style> <style>
@@ -117,6 +117,11 @@
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.hero h1 span { color: var(--accent); } .hero h1 span { color: var(--accent); }
.hero-word {
display: inline-block;
color: var(--accent);
transition: opacity 0.25s, transform 0.25s;
}
.hero p { .hero p {
font-size: 1.2rem; font-size: 1.2rem;
color: var(--muted); color: var(--muted);
@@ -223,7 +228,7 @@
width: fit-content; width: fit-content;
} }
.tag-recommended { background: rgba(52, 211, 153, 0.15); color: var(--green); } .tag-recommended { background: rgba(52, 211, 153, 0.15); color: var(--green); }
.tag-custom { background: rgba(251, 191, 36, 0.15); color: #fbbf24; } .tag-acap3 { background: rgba(168, 85, 247, 0.15); color: #a855f7; }
.tag-root { background: rgba(239, 68, 68, 0.15); color: #ef4444; } .tag-root { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
.download-card h3 { .download-card h3 {
font-size: 1.1rem; font-size: 1.1rem;
@@ -344,8 +349,8 @@
<!-- Hero --> <!-- Hero -->
<section class="hero"> <section class="hero">
<div class="badge">Open Source &middot; ACAP Package</div> <div class="badge">Open Source &middot; ACAP Package</div>
<h1>Tailscale VPN for <span>Axis Cameras</span></h1> <h1>Tailscale VPN for<br>Axis <span class="hero-word" id="heroWord">Cameras</span></h1>
<p>Secure remote access to your Axis cameras over WireGuard. No extra hardware, no complex network config - just install and connect.</p> <p>Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.</p>
<div class="hero-buttons"> <div class="hero-buttons">
<a href="#downloads" class="btn btn-primary"> <a href="#downloads" class="btn btn-primary">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
@@ -363,7 +368,7 @@
<div style="background:rgba(79,143,247,0.07);border:1px solid rgba(79,143,247,0.18);border-radius:10px;padding:12px 18px;font-size:12.5px;color:var(--muted);line-height:1.6;"> <div style="background:rgba(79,143,247,0.07);border:1px solid rgba(79,143,247,0.18);border-radius:10px;padding:12px 18px;font-size:12.5px;color:var(--muted);line-height:1.6;">
<strong style="color:var(--text);">Disclaimer:</strong> <strong style="color:var(--text);">Disclaimer:</strong>
This is an independent, community-developed ACAP package and is <strong style="color:var(--text);">not an official Axis Communications product</strong>. This is an independent, community-developed ACAP package and is <strong style="color:var(--text);">not an official Axis Communications product</strong>.
It was developed entirely on personal time and is not affiliated with, endorsed by, or supported by Axis Communications AB. It is not affiliated with, endorsed by, or supported by Axis Communications AB.
Use at your own risk. For official Axis software, visit <a href="https://www.axis.com" target="_blank" rel="noopener">axis.com</a>. Use at your own risk. For official Axis software, visit <a href="https://www.axis.com" target="_blank" rel="noopener">axis.com</a>.
</div> </div>
</div> </div>
@@ -378,12 +383,12 @@
<div class="feature-card"> <div class="feature-card">
<div class="icon">⚡</div> <div class="icon">⚡</div>
<h3>No Root Required</h3> <h3>No Root Required</h3>
<p>Runs in user-space networking mode on Axis OS 12+. No need to enable root access on your camera.</p> <p>Runs in user-space networking mode on Axis OS 12+. No need to enable root access on your device.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="icon">📦</div> <div class="icon">📦</div>
<h3>Simple EAP Install</h3> <h3>Simple EAP Install</h3>
<p>Upload the .eap file through your camera's web interface. Start the app and authenticate - done.</p> <p>Upload the .eap file through your device's web interface. Start the app and authenticate - done.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="icon">🔄</div> <div class="icon">🔄</div>
@@ -393,19 +398,19 @@
<div class="feature-card"> <div class="feature-card">
<div class="icon">🌐</div> <div class="icon">🌐</div>
<h3>Headscale Compatible</h3> <h3>Headscale Compatible</h3>
<p>The Custom variant supports self-hosted Headscale servers with configurable server URL and auth key.</p> <p>Supports self-hosted Headscale servers with configurable server URL and auth key, built into every variant.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="icon">🏗️</div> <div class="icon">🔀</div>
<h3>ARM &amp; AARCH64</h3> <h3>Outbound Proxy</h3>
<p>Supports both ARM (armv7hf) and AARCH64 architectures, covering a wide range of Axis camera models.</p> <p>Allows the device to route its own outbound traffic through Tailscale via a local HTTP/HTTPS and SOCKS5 proxy.</p>
</div> </div>
</section> </section>
<!-- Downloads --> <!-- Downloads -->
<section id="downloads" class="downloads"> <section id="downloads" class="downloads">
<h2>Download</h2> <h2>Download</h2>
<p class="subtitle">Pick the right variant for your camera and Axis OS version.</p> <p class="subtitle">Pick the right variant for your device and Axis OS version.</p>
<div class="download-grid"> <div class="download-grid">
<!-- Standard --> <!-- Standard -->
<div class="download-card"> <div class="download-card">
@@ -423,22 +428,6 @@
</a> </a>
</div> </div>
</div> </div>
<!-- Custom -->
<div class="download-card">
<div class="tag tag-custom">Custom / Headscale</div>
<h3>Custom Server</h3>
<p>Supports custom Tailscale control servers and auth keys. Ideal for self-hosted Headscale setups.</p>
<div class="arch-buttons">
<a class="arch-btn" data-asset="custom" data-arch="aarch64" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
AARCH64
</a>
<a class="arch-btn" data-asset="custom" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
ARM
</a>
</div>
</div>
<!-- ROOT --> <!-- ROOT -->
<div class="download-card"> <div class="download-card">
<div class="tag tag-root">Root &middot; Legacy</div> <div class="tag tag-root">Root &middot; Legacy</div>
@@ -455,6 +444,18 @@
</a> </a>
</div> </div>
</div> </div>
<!-- ACAP3 -->
<div class="download-card">
<div class="tag tag-acap3">ACAP3 &middot; Legacy</div>
<h3>ACAP3 (Older Axis OS)</h3>
<p>For devices running Axis OS versions that do not support ACAP4. ARM only.</p>
<div class="arch-buttons">
<a class="arch-btn" data-asset="acap3" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
ARM
</a>
</div>
</div>
</div> </div>
</section> </section>
@@ -466,14 +467,14 @@
<div class="step-num">1</div> <div class="step-num">1</div>
<div class="step-text"> <div class="step-text">
<strong>Download the EAP</strong> <strong>Download the EAP</strong>
<span>Grab the right .eap file for your camera architecture from the releases page above.</span> <span>Grab the right .eap file for your device architecture from the releases page above.</span>
</div> </div>
</div> </div>
<div class="step"> <div class="step">
<div class="step-num">2</div> <div class="step-num">2</div>
<div class="step-text"> <div class="step-text">
<strong>Upload to your camera</strong> <strong>Upload to your device</strong>
<span>Log into your Axis camera web interface and go to <strong>Apps &rarr; Add App</strong>. Upload the .eap file.</span> <span>Log into your Axis device web interface and go to <strong>Apps &rarr; Add App</strong>. Upload the .eap file.</span>
</div> </div>
</div> </div>
<div class="step"> <div class="step">
@@ -492,6 +493,7 @@
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a> <a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases" target="_blank" rel="noopener">Releases</a> <a href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases" target="_blank" rel="noopener">Releases</a>
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener">Sponsor</a> <a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener">Sponsor</a>
<a href="https://buymeacoffee.com/mo3he" target="_blank" rel="noopener">Buy Me a Coffee</a>
<a href="https://tailscale.com/" target="_blank" rel="noopener">Tailscale</a> <a href="https://tailscale.com/" target="_blank" rel="noopener">Tailscale</a>
<a href="https://www.axis.com/" target="_blank" rel="noopener">Axis</a> <a href="https://www.axis.com/" target="_blank" rel="noopener">Axis</a>
</div> </div>
@@ -538,6 +540,22 @@
} }
}); });
// Rotate hero word
var heroWord = document.getElementById('heroWord');
var devices = ['Cameras', 'Door Stations', 'Intercoms', 'Speakers', 'Radars', 'Encoders'];
var wordIdx = 0;
function cycleWord() {
wordIdx = (wordIdx + 1) % devices.length;
heroWord.style.opacity = '0';
heroWord.style.transform = 'translateY(8px)';
setTimeout(function() {
heroWord.textContent = devices[wordIdx];
heroWord.style.opacity = '1';
heroWord.style.transform = 'translateY(0)';
}, 250);
}
setInterval(cycleWord, 3000);
// Rewrite download links to point directly to latest release assets // Rewrite download links to point directly to latest release assets
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest') fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@@ -553,9 +571,9 @@
var archMatch = name.indexOf(arch) !== -1; var archMatch = name.indexOf(arch) !== -1;
var typeMatch = false; var typeMatch = false;
if (type === 'Tailscale_VPN') { if (type === 'Tailscale_VPN') {
typeMatch = name.indexOf('custom') === -1 && name.indexOf('root') === -1; typeMatch = name.indexOf('root') === -1 && name.indexOf('acap3') === -1;
} else if (type === 'custom') { } else if (type === 'acap3') {
typeMatch = name.indexOf('custom') !== -1; typeMatch = name.indexOf('acap3') !== -1;
} else if (type === 'ROOT') { } else if (type === 'ROOT') {
typeMatch = name.indexOf('root') !== -1; typeMatch = name.indexOf('root') !== -1;
} }