mirror of
https://github.com/Mo3he/Axis_Cam_Tailscale.git
synced 2026-10-01 03:55:40 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd598ec8c6 | ||
|
|
732dacc5ba | ||
|
|
27e26c65e3 | ||
|
|
8a070bf5bf | ||
|
|
de58375eb3 | ||
|
|
86c6ecb385 | ||
|
|
7bb541fa2a | ||
|
|
3e87803625 | ||
|
|
8bd18ffc63 | ||
|
|
c2b15fb7ec | ||
|
|
b07e4f1f61 | ||
|
|
75f9cfd4b7 | ||
|
|
96e8a8fd97 | ||
|
|
8c9072b876 | ||
|
|
cf84c3cdb6 | ||
|
|
db05725432 |
@@ -126,14 +126,21 @@ jobs:
|
||||
# Detect variant suffix for .eap naming
|
||||
if [[ "$FOLDER_NAME" == *_ROOT ]]; then
|
||||
VARIANT="_root"
|
||||
elif [[ "$FOLDER_NAME" == *_custom ]]; then
|
||||
VARIANT="_custom"
|
||||
elif [[ "$FOLDER_NAME" == *_acap3 ]]; then
|
||||
VARIANT="_acap3"
|
||||
else
|
||||
VARIANT=""
|
||||
fi
|
||||
|
||||
# Update manifest version
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"${RELEASE_VERSION}\"/" "$folder/app/manifest.json"
|
||||
# Update version — manifest.json for ACAP 4, package.conf for ACAP 3
|
||||
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
|
||||
TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean
|
||||
@@ -143,7 +150,9 @@ jobs:
|
||||
# Extract .eap files into build folder
|
||||
EAP_OUTPUT="./build/${TAG_NAME}"
|
||||
mkdir -p "$EAP_OUTPUT"
|
||||
docker cp $(docker create "$TAG_NAME"):/opt/app "$EAP_OUTPUT"
|
||||
CID=$(docker create "$TAG_NAME")
|
||||
docker cp "$CID":/opt/app "$EAP_OUTPUT"
|
||||
docker rm "$CID" >/dev/null
|
||||
|
||||
# Move all .eap files to releases folder, append variant if needed
|
||||
find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do
|
||||
@@ -165,8 +174,8 @@ jobs:
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
# Only commit manifests; do not track release artifacts
|
||||
git add */app/manifest.json
|
||||
# Only commit manifests and ACAP 3 package.conf; do not track release artifacts
|
||||
git add */app/manifest.json arm_acap3/app/package.conf
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
# 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.
|
||||
|
||||
- ✅ Secure remote access to cameras
|
||||
- ✅ Easy to install via EAP package
|
||||
- ✅ Works on **Axis OS 12+** (non-root version)
|
||||
- ✅ Based on **WireGuard VPN** technology
|
||||
- Secure remote access to cameras
|
||||
- Easy to install via EAP package
|
||||
- Works on **Axis OS 12+** (non-root version)
|
||||
- Works on **legacy Axis OS 9.x / 10.x** via the ACAP 3 variant
|
||||
- Based on **WireGuard VPN** technology
|
||||
|
||||
[](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
|
||||
[](LICENSE)
|
||||

|
||||
[](https://github.com/sponsors/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 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
|
||||
|
||||
> **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)
|
||||
- [🚀 Usage](#-usage)
|
||||
- [🔄 Updating Tailscale](#-updating-tailscale)
|
||||
- [🧪 Testers Needed](#-testers-needed)
|
||||
- [🎉 Good News](#-good-news)
|
||||
- [🎯 Purpose](#-purpose)
|
||||
- [🔗 Useful Links](#-useful-links)
|
||||
- [🖥️ Compatibility](#️-compatibility)
|
||||
- [⭐ Star History](#-star-history)
|
||||
- [💖 Support](#-support)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Settings](#settings)
|
||||
- [Proxy Support](#proxy-support)
|
||||
- [Updating Tailscale](#updating-tailscale)
|
||||
- [Purpose](#purpose)
|
||||
- [Useful Links](#useful-links)
|
||||
- [Compatibility](#compatibility)
|
||||
- [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).
|
||||
|
||||
@@ -47,19 +48,58 @@ Once installed:
|
||||
- Click **Open** to view logs and get your Tailscale authentication URL.
|
||||
- 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.
|
||||
- View logs via the **Open** button in the app.
|
||||
- Authenticate using the provided URL.
|
||||
- Runs a C-based parameter bridge (compiled via ACAP SDK 1.15.1) that reads settings from the ACAP parameter store and launches Tailscale.
|
||||
- View logs and connection status via the **Open** button in the app.
|
||||
- Authenticate using the provided URL, or pre-enter an auth key in **Settings**.
|
||||
- Change the **Custom Server URL** in Settings to use a self-hosted [Headscale](https://headscale.net/) control server.
|
||||
- Parameter changes (ports, server URL, auth key) are applied automatically without needing to reinstall the app.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
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).
|
||||
- To update, simply install the new `.eap` over the existing one.
|
||||
@@ -83,17 +123,7 @@ docker cp $(docker create <package_name>):/opt/app ./build
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testers Needed
|
||||
|
||||
A new **custom** version is available:
|
||||
- Allows setting a custom server and auth key (for [Headscale](https://headscale.net/)).
|
||||
- Go to **Settings (⋮ → Settings)** to add your details.
|
||||
|
||||
Please give it a try and share your feedback!
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Good News
|
||||
## Good News
|
||||
|
||||
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 12+**.
|
||||
|
||||
@@ -101,19 +131,23 @@ Tailscale ACAP can now run **without root privileges**, making it compatible wit
|
||||
|
||||
For **full networking features**, use the **ROOT** version on Axis OS < 12.
|
||||
|
||||
### 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:
|
||||
- Secure remote access without additional hardware or complex network configuration.
|
||||
- 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 GitHub](https://github.com/tailscale/tailscale)
|
||||
@@ -122,10 +156,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.
|
||||
|
||||
| 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 | 10 or earlier | Full kernel networking (root) |
|
||||
| `armv7hf_root` | ARMv7 | 10 or earlier | Full kernel networking (root) |
|
||||
| `armv7hf_acap3` | ARMv7 | **9.x – 10.x** | Legacy cameras, ACAP SDK 3 |
|
||||
|
||||
> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 11.11+ → use the standard variant (`aarch64` or `armv7hf`). Axis OS 9/10 on ARMv7 → use `armv7hf_acap3`. Axis OS 10 or earlier on AArch64 → use `aarch64_root`.
|
||||
|
||||
You can verify your device details using the following command:
|
||||
|
||||
```bash
|
||||
@@ -137,13 +181,13 @@ curl --anyauth "*" -u <username>:<password> <device_ip>/axis-cgi/basicdeviceinfo
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star History
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date)
|
||||
|
||||
---
|
||||
|
||||
## 💖 Support
|
||||
## Support
|
||||
|
||||
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
@@ -1,5 +1,5 @@
|
||||
ARG ARCH=aarch64
|
||||
ARG VERSION=1.3
|
||||
ARG VERSION=1.15.1
|
||||
ARG UBUNTU_VERSION=22.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
@@ -9,4 +9,5 @@ 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 ./
|
||||
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
+14
-1
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+217
-31
@@ -238,6 +238,40 @@
|
||||
.log-line .msg-err { color: var(--red); }
|
||||
.log-line .msg-ok { color: var(--green); }
|
||||
|
||||
/* Settings form */
|
||||
.settings-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.settings-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
|
||||
.settings-input {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
padding: 8px 10px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.settings-input:focus { border-color: var(--accent); }
|
||||
.settings-hint { font-size: 11px; color: var(--muted); }
|
||||
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
|
||||
.save-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.9; }
|
||||
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.save-status { font-size: 12px; color: var(--muted); }
|
||||
.save-status.ok { color: var(--green); }
|
||||
.save-status.err { color: var(--red); }
|
||||
|
||||
/* Refresh indicator */
|
||||
.refresh-bar {
|
||||
display: flex;
|
||||
@@ -364,6 +398,52 @@
|
||||
</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" 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="password" 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" 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" 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 & Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="card">
|
||||
<div class="log-controls">
|
||||
@@ -448,17 +528,18 @@
|
||||
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
// Primary: extract hostname from Axis syslog header (always the real device hostname)
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[1];
|
||||
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
|
||||
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 nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
}
|
||||
|
||||
var loginMatches = txt.match(/active login:\s+\S+/g);
|
||||
@@ -493,28 +574,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
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
||||
tsIP = tsIP || cacheGet('ip');
|
||||
node = node || cacheGet('node');
|
||||
tailnet = tailnet || cacheGet('tailnet');
|
||||
version = version || cacheGet('version');
|
||||
httpPort = httpPort || cacheGet('http-port');
|
||||
socksPort = socksPort || cacheGet('socks-port');
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
// Fallbacks only when syslog has rotated and no state transitions are visible.
|
||||
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
|
||||
// "Tailscale VPN is running" message which stays in syslog indefinitely.
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
|
||||
// (our shell log). The shell log is written AFTER auth completes, so it correctly
|
||||
// post-dates the auth URL when connection succeeds.
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, 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) {
|
||||
@@ -565,6 +676,10 @@
|
||||
auth.style.display = 'none';
|
||||
}
|
||||
|
||||
// Proxy card is always visible — update ports whenever known
|
||||
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
|
||||
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
|
||||
|
||||
if (r.state === 'connected') {
|
||||
document.getElementById('ts-ip').textContent = r.ip || '-';
|
||||
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
|
||||
@@ -603,21 +718,19 @@
|
||||
.then(function(txt) {
|
||||
var result = parse(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);
|
||||
} 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() {
|
||||
document.getElementById('status-text').textContent = 'Unable to fetch logs';
|
||||
@@ -671,6 +784,79 @@
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Settings — load current param values and save on submit
|
||||
var PARAM_URL = '/axis-cgi/param.cgi';
|
||||
var serverInput = document.getElementById('input-server');
|
||||
var authInput = document.getElementById('input-authkey');
|
||||
var httpPortInput = document.getElementById('input-http-port');
|
||||
var socksPortInput= document.getElementById('input-socks-port');
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
var saveStatus = document.getElementById('save-status');
|
||||
|
||||
function loadSettings() {
|
||||
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(txt) {
|
||||
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
|
||||
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
|
||||
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
|
||||
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
|
||||
if (sm) serverInput.value = sm[1].trim();
|
||||
if (am) authInput.value = am[1].trim();
|
||||
if (hm) httpPortInput.value = hm[1].trim();
|
||||
if (km) socksPortInput.value = km[1].trim();
|
||||
// Update proxy display card with authoritative param values
|
||||
// and overwrite the localStorage cache so stale ports don't win on next render
|
||||
var httpPort = hm ? hm[1].trim() : null;
|
||||
var socksPort = km ? km[1].trim() : null;
|
||||
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
|
||||
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function setStatus(msg, cls) {
|
||||
saveStatus.textContent = msg;
|
||||
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
|
||||
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', function() {
|
||||
saveBtn.disabled = true;
|
||||
setStatus('Saving...', '');
|
||||
var httpPort = httpPortInput.value.trim() || '8080';
|
||||
var socksPort = socksPortInput.value.trim() || '1080';
|
||||
var params = 'action=update' +
|
||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
|
||||
fetch(PARAM_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
})
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(txt) {
|
||||
saveBtn.disabled = false;
|
||||
if (/^OK/.test(txt.trim())) {
|
||||
setStatus('Saved. Restarting...', 'ok');
|
||||
// Restart the app so new settings take effect
|
||||
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
|
||||
{ method: 'POST', credentials: 'same-origin' });
|
||||
} else {
|
||||
setStatus('Error: ' + txt.trim(), 'err');
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
saveBtn.disabled = false;
|
||||
setStatus('Failed to save', 'err');
|
||||
});
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"schemaVersion": "1.7.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
@@ -12,7 +12,29 @@
|
||||
"architecture": "aarch64"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
"settingPage": "index.html",
|
||||
"paramConfig": [
|
||||
{
|
||||
"name": "CustomServer",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AuthKey",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "HttpProxyPort",
|
||||
"default": "8080",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "Socks5Port",
|
||||
"default": "1080",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (userspace variant).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
|
||||
* 2. Write them to CONFIG_FILE so the shell script can source them.
|
||||
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
|
||||
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
|
||||
* of the child so the new config is picked up.
|
||||
* Rapid changes within 300 ms are coalesced into a single restart.
|
||||
* 5. Watchdog: if the child exits unexpectedly, restart it.
|
||||
*
|
||||
* Runs as the unprivileged 'sdk' ACAP user — no root required.
|
||||
*/
|
||||
|
||||
#include <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 char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_http_proxy_port = NULL;
|
||||
static char *cfg_socks5_port = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
/* ── child process management ──────────────────────────────────────────── */
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
/* ── watchdog ────────────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── config file ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("HttpProxyPort", cfg_http_proxy_port)
|
||||
LOAD("Socks5Port", cfg_socks5_port)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
||||
cache_get(&cfg_http_proxy_port, "8080"),
|
||||
cache_get(&cfg_socks5_port, "1080"),
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
|
||||
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
/* ── signal handler ──────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── main ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting");
|
||||
|
||||
/* Ensure localdata dir exists */
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
syslog(LOG_WARNING, "register callback %s: %s",
|
||||
params[i], error ? error->message : "unknown");
|
||||
if (error) { g_error_free(error); error = NULL; }
|
||||
}
|
||||
}
|
||||
|
||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
g_main_loop_unref(loop);
|
||||
ax_parameter_free(handle);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG ARCH=aarch64
|
||||
ARG VERSION=1.3
|
||||
ARG VERSION=1.15.1
|
||||
ARG UBUNTU_VERSION=22.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
@@ -9,4 +9,5 @@ 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 ./
|
||||
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -238,6 +238,40 @@
|
||||
.log-line .msg-err { color: var(--red); }
|
||||
.log-line .msg-ok { color: var(--green); }
|
||||
|
||||
/* Settings form */
|
||||
.settings-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.settings-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
|
||||
.settings-input {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
padding: 8px 10px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.settings-input:focus { border-color: var(--accent); }
|
||||
.settings-hint { font-size: 11px; color: var(--muted); }
|
||||
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
|
||||
.save-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.9; }
|
||||
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.save-status { font-size: 12px; color: var(--muted); }
|
||||
.save-status.ok { color: var(--green); }
|
||||
.save-status.err { color: var(--red); }
|
||||
|
||||
/* Refresh indicator */
|
||||
.refresh-bar {
|
||||
display: flex;
|
||||
@@ -364,6 +398,52 @@
|
||||
</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" 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="password" 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" 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" 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 & Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="card">
|
||||
<div class="log-controls">
|
||||
@@ -448,17 +528,18 @@
|
||||
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
// Primary: extract hostname from Axis syslog header (always the real device hostname)
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[1];
|
||||
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
|
||||
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 nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
}
|
||||
|
||||
var loginMatches = txt.match(/active login:\s+\S+/g);
|
||||
@@ -493,28 +574,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
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
||||
tsIP = tsIP || cacheGet('ip');
|
||||
node = node || cacheGet('node');
|
||||
tailnet = tailnet || cacheGet('tailnet');
|
||||
version = version || cacheGet('version');
|
||||
httpPort = httpPort || cacheGet('http-port');
|
||||
socksPort = socksPort || cacheGet('socks-port');
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
// Fallbacks only when syslog has rotated and no state transitions are visible.
|
||||
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
|
||||
// "Tailscale VPN is running" message which stays in syslog indefinitely.
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
|
||||
// (our shell log). The shell log is written AFTER auth completes, so it correctly
|
||||
// post-dates the auth URL when connection succeeds.
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, 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) {
|
||||
@@ -565,6 +676,10 @@
|
||||
auth.style.display = 'none';
|
||||
}
|
||||
|
||||
// Proxy card is always visible — update ports whenever known
|
||||
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
|
||||
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
|
||||
|
||||
if (r.state === 'connected') {
|
||||
document.getElementById('ts-ip').textContent = r.ip || '-';
|
||||
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
|
||||
@@ -603,20 +718,19 @@
|
||||
.then(function(txt) {
|
||||
var result = parse(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);
|
||||
} 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';
|
||||
@@ -670,6 +784,79 @@
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Settings — load current param values and save on submit
|
||||
var PARAM_URL = '/axis-cgi/param.cgi';
|
||||
var serverInput = document.getElementById('input-server');
|
||||
var authInput = document.getElementById('input-authkey');
|
||||
var httpPortInput = document.getElementById('input-http-port');
|
||||
var socksPortInput= document.getElementById('input-socks-port');
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
var saveStatus = document.getElementById('save-status');
|
||||
|
||||
function loadSettings() {
|
||||
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(txt) {
|
||||
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
|
||||
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
|
||||
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
|
||||
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
|
||||
if (sm) serverInput.value = sm[1].trim();
|
||||
if (am) authInput.value = am[1].trim();
|
||||
if (hm) httpPortInput.value = hm[1].trim();
|
||||
if (km) socksPortInput.value = km[1].trim();
|
||||
// Update proxy display card with authoritative param values
|
||||
// and overwrite the localStorage cache so stale ports don't win on next render
|
||||
var httpPort = hm ? hm[1].trim() : null;
|
||||
var socksPort = km ? km[1].trim() : null;
|
||||
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
|
||||
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function setStatus(msg, cls) {
|
||||
saveStatus.textContent = msg;
|
||||
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
|
||||
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', function() {
|
||||
saveBtn.disabled = true;
|
||||
setStatus('Saving...', '');
|
||||
var httpPort = httpPortInput.value.trim() || '8080';
|
||||
var socksPort = socksPortInput.value.trim() || '1080';
|
||||
var params = 'action=update' +
|
||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
|
||||
fetch(PARAM_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
})
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(txt) {
|
||||
saveBtn.disabled = false;
|
||||
if (/^OK/.test(txt.trim())) {
|
||||
setStatus('Saved. Restarting...', 'ok');
|
||||
// Restart the app so new settings take effect
|
||||
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
|
||||
{ method: 'POST', credentials: 'same-origin' });
|
||||
} else {
|
||||
setStatus('Error: ' + txt.trim(), 'err');
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
saveBtn.disabled = false;
|
||||
setStatus('Failed to save', 'err');
|
||||
});
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"schemaVersion": "1.7.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
@@ -16,7 +16,19 @@
|
||||
"architecture": "aarch64"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
"settingPage": "index.html",
|
||||
"paramConfig": [
|
||||
{
|
||||
"name": "CustomServer",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AuthKey",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
|
||||
* Same structure as regular param_bridge.c but without proxy port params.
|
||||
*/
|
||||
|
||||
#include <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 char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: server=%s",
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting (root mode)");
|
||||
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = { "CustomServer", "AuthKey" };
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
syslog(LOG_WARNING, "register callback %s: %s",
|
||||
params[i], error ? error->message : "unknown");
|
||||
if (error) { g_error_free(error); error = NULL; }
|
||||
}
|
||||
}
|
||||
|
||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
g_main_loop_unref(loop);
|
||||
ax_parameter_free(handle);
|
||||
return 0;
|
||||
}
|
||||
@@ -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 .
|
||||
@@ -1,5 +0,0 @@
|
||||
To build, from main directory
|
||||
|
||||
docker build --tag aarch64 .
|
||||
|
||||
docker cp $(docker create aarch64):/opt/app ./build
|
||||
@@ -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*
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
@@ -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
@@ -1,5 +1,5 @@
|
||||
ARG ARCH=armv7hf
|
||||
ARG VERSION=1.3
|
||||
ARG VERSION=1.15.1
|
||||
ARG UBUNTU_VERSION=22.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
@@ -9,4 +9,5 @@ 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 ./
|
||||
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
+14
-1
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+217
-30
@@ -238,6 +238,40 @@
|
||||
.log-line .msg-err { color: var(--red); }
|
||||
.log-line .msg-ok { color: var(--green); }
|
||||
|
||||
/* Settings form */
|
||||
.settings-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.settings-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
|
||||
.settings-input {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
padding: 8px 10px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.settings-input:focus { border-color: var(--accent); }
|
||||
.settings-hint { font-size: 11px; color: var(--muted); }
|
||||
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
|
||||
.save-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.9; }
|
||||
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.save-status { font-size: 12px; color: var(--muted); }
|
||||
.save-status.ok { color: var(--green); }
|
||||
.save-status.err { color: var(--red); }
|
||||
|
||||
/* Refresh indicator */
|
||||
.refresh-bar {
|
||||
display: flex;
|
||||
@@ -364,6 +398,52 @@
|
||||
</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" 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="password" 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" 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" 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 & Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="card">
|
||||
<div class="log-controls">
|
||||
@@ -448,17 +528,18 @@
|
||||
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
// Primary: extract hostname from Axis syslog header (always the real device hostname)
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[1];
|
||||
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
|
||||
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 nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
}
|
||||
|
||||
var loginMatches = txt.match(/active login:\s+\S+/g);
|
||||
@@ -493,28 +574,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
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
||||
tsIP = tsIP || cacheGet('ip');
|
||||
node = node || cacheGet('node');
|
||||
tailnet = tailnet || cacheGet('tailnet');
|
||||
version = version || cacheGet('version');
|
||||
httpPort = httpPort || cacheGet('http-port');
|
||||
socksPort = socksPort || cacheGet('socks-port');
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
// Fallbacks only when syslog has rotated and no state transitions are visible.
|
||||
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
|
||||
// "Tailscale VPN is running" message which stays in syslog indefinitely.
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
|
||||
// (our shell log). The shell log is written AFTER auth completes, so it correctly
|
||||
// post-dates the auth URL when connection succeeds.
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, 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) {
|
||||
@@ -565,6 +676,10 @@
|
||||
auth.style.display = 'none';
|
||||
}
|
||||
|
||||
// Proxy card is always visible — update ports whenever known
|
||||
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
|
||||
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
|
||||
|
||||
if (r.state === 'connected') {
|
||||
document.getElementById('ts-ip').textContent = r.ip || '-';
|
||||
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
|
||||
@@ -603,20 +718,19 @@
|
||||
.then(function(txt) {
|
||||
var result = parse(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);
|
||||
} 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';
|
||||
@@ -670,6 +784,79 @@
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Settings — load current param values and save on submit
|
||||
var PARAM_URL = '/axis-cgi/param.cgi';
|
||||
var serverInput = document.getElementById('input-server');
|
||||
var authInput = document.getElementById('input-authkey');
|
||||
var httpPortInput = document.getElementById('input-http-port');
|
||||
var socksPortInput= document.getElementById('input-socks-port');
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
var saveStatus = document.getElementById('save-status');
|
||||
|
||||
function loadSettings() {
|
||||
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(txt) {
|
||||
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
|
||||
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
|
||||
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
|
||||
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
|
||||
if (sm) serverInput.value = sm[1].trim();
|
||||
if (am) authInput.value = am[1].trim();
|
||||
if (hm) httpPortInput.value = hm[1].trim();
|
||||
if (km) socksPortInput.value = km[1].trim();
|
||||
// Update proxy display card with authoritative param values
|
||||
// and overwrite the localStorage cache so stale ports don't win on next render
|
||||
var httpPort = hm ? hm[1].trim() : null;
|
||||
var socksPort = km ? km[1].trim() : null;
|
||||
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
|
||||
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function setStatus(msg, cls) {
|
||||
saveStatus.textContent = msg;
|
||||
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
|
||||
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', function() {
|
||||
saveBtn.disabled = true;
|
||||
setStatus('Saving...', '');
|
||||
var httpPort = httpPortInput.value.trim() || '8080';
|
||||
var socksPort = socksPortInput.value.trim() || '1080';
|
||||
var params = 'action=update' +
|
||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
|
||||
fetch(PARAM_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
})
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(txt) {
|
||||
saveBtn.disabled = false;
|
||||
if (/^OK/.test(txt.trim())) {
|
||||
setStatus('Saved. Restarting...', 'ok');
|
||||
// Restart the app so new settings take effect
|
||||
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
|
||||
{ method: 'POST', credentials: 'same-origin' });
|
||||
} else {
|
||||
setStatus('Error: ' + txt.trim(), 'err');
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
saveBtn.disabled = false;
|
||||
setStatus('Failed to save', 'err');
|
||||
});
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
+24
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"schemaVersion": "1.7.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
@@ -12,7 +12,29 @@
|
||||
"architecture": "armv7hf"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
"settingPage": "index.html",
|
||||
"paramConfig": [
|
||||
{
|
||||
"name": "CustomServer",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AuthKey",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "HttpProxyPort",
|
||||
"default": "8080",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "Socks5Port",
|
||||
"default": "1080",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (userspace variant).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
|
||||
* 2. Write them to CONFIG_FILE so the shell script can source them.
|
||||
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
|
||||
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
|
||||
* of the child so the new config is picked up.
|
||||
* Rapid changes within 300 ms are coalesced into a single restart.
|
||||
* 5. Watchdog: if the child exits unexpectedly, restart it.
|
||||
*
|
||||
* Runs as the unprivileged 'sdk' ACAP user — no root required.
|
||||
*/
|
||||
|
||||
#include <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 char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_http_proxy_port = NULL;
|
||||
static char *cfg_socks5_port = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
/* ── child process management ──────────────────────────────────────────── */
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
/* ── watchdog ────────────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── config file ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("HttpProxyPort", cfg_http_proxy_port)
|
||||
LOAD("Socks5Port", cfg_socks5_port)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
||||
cache_get(&cfg_http_proxy_port, "8080"),
|
||||
cache_get(&cfg_socks5_port, "1080"),
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
|
||||
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
/* ── signal handler ──────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── main ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting");
|
||||
|
||||
/* Ensure localdata dir exists */
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
syslog(LOG_WARNING, "register callback %s: %s",
|
||||
params[i], error ? error->message : "unknown");
|
||||
if (error) { g_error_free(error); error = NULL; }
|
||||
}
|
||||
}
|
||||
|
||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
g_main_loop_unref(loop);
|
||||
ax_parameter_free(handle);
|
||||
return 0;
|
||||
}
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
ARG ARCH=armv7hf
|
||||
ARG VERSION=1.3
|
||||
ARG VERSION=1.15.1
|
||||
ARG UBUNTU_VERSION=22.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
@@ -9,4 +9,5 @@ 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 ./
|
||||
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
+14
-1
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+217
-30
@@ -238,6 +238,40 @@
|
||||
.log-line .msg-err { color: var(--red); }
|
||||
.log-line .msg-ok { color: var(--green); }
|
||||
|
||||
/* Settings form */
|
||||
.settings-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.settings-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
|
||||
.settings-input {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
padding: 8px 10px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.settings-input:focus { border-color: var(--accent); }
|
||||
.settings-hint { font-size: 11px; color: var(--muted); }
|
||||
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
|
||||
.save-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.9; }
|
||||
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.save-status { font-size: 12px; color: var(--muted); }
|
||||
.save-status.ok { color: var(--green); }
|
||||
.save-status.err { color: var(--red); }
|
||||
|
||||
/* Refresh indicator */
|
||||
.refresh-bar {
|
||||
display: flex;
|
||||
@@ -364,6 +398,52 @@
|
||||
</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" 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="password" 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" 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" 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 & Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="card">
|
||||
<div class="log-controls">
|
||||
@@ -448,17 +528,18 @@
|
||||
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
// Primary: extract hostname from Axis syslog header (always the real device hostname)
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[1];
|
||||
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
|
||||
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 nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
}
|
||||
|
||||
var loginMatches = txt.match(/active login:\s+\S+/g);
|
||||
@@ -493,28 +574,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
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
||||
tsIP = tsIP || cacheGet('ip');
|
||||
node = node || cacheGet('node');
|
||||
tailnet = tailnet || cacheGet('tailnet');
|
||||
version = version || cacheGet('version');
|
||||
httpPort = httpPort || cacheGet('http-port');
|
||||
socksPort = socksPort || cacheGet('socks-port');
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
// Fallbacks only when syslog has rotated and no state transitions are visible.
|
||||
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
|
||||
// "Tailscale VPN is running" message which stays in syslog indefinitely.
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
|
||||
// (our shell log). The shell log is written AFTER auth completes, so it correctly
|
||||
// post-dates the auth URL when connection succeeds.
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, 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) {
|
||||
@@ -565,6 +676,10 @@
|
||||
auth.style.display = 'none';
|
||||
}
|
||||
|
||||
// Proxy card is always visible — update ports whenever known
|
||||
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
|
||||
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
|
||||
|
||||
if (r.state === 'connected') {
|
||||
document.getElementById('ts-ip').textContent = r.ip || '-';
|
||||
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
|
||||
@@ -603,20 +718,19 @@
|
||||
.then(function(txt) {
|
||||
var result = parse(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);
|
||||
} 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';
|
||||
@@ -670,6 +784,79 @@
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Settings — load current param values and save on submit
|
||||
var PARAM_URL = '/axis-cgi/param.cgi';
|
||||
var serverInput = document.getElementById('input-server');
|
||||
var authInput = document.getElementById('input-authkey');
|
||||
var httpPortInput = document.getElementById('input-http-port');
|
||||
var socksPortInput= document.getElementById('input-socks-port');
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
var saveStatus = document.getElementById('save-status');
|
||||
|
||||
function loadSettings() {
|
||||
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(txt) {
|
||||
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
|
||||
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
|
||||
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
|
||||
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
|
||||
if (sm) serverInput.value = sm[1].trim();
|
||||
if (am) authInput.value = am[1].trim();
|
||||
if (hm) httpPortInput.value = hm[1].trim();
|
||||
if (km) socksPortInput.value = km[1].trim();
|
||||
// Update proxy display card with authoritative param values
|
||||
// and overwrite the localStorage cache so stale ports don't win on next render
|
||||
var httpPort = hm ? hm[1].trim() : null;
|
||||
var socksPort = km ? km[1].trim() : null;
|
||||
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
|
||||
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function setStatus(msg, cls) {
|
||||
saveStatus.textContent = msg;
|
||||
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
|
||||
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', function() {
|
||||
saveBtn.disabled = true;
|
||||
setStatus('Saving...', '');
|
||||
var httpPort = httpPortInput.value.trim() || '8080';
|
||||
var socksPort = socksPortInput.value.trim() || '1080';
|
||||
var params = 'action=update' +
|
||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
|
||||
fetch(PARAM_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
})
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(txt) {
|
||||
saveBtn.disabled = false;
|
||||
if (/^OK/.test(txt.trim())) {
|
||||
setStatus('Saved. Restarting...', 'ok');
|
||||
// Restart the app so new settings take effect
|
||||
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
|
||||
{ method: 'POST', credentials: 'same-origin' });
|
||||
} else {
|
||||
setStatus('Error: ' + txt.trim(), 'err');
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
saveBtn.disabled = false;
|
||||
setStatus('Failed to save', 'err');
|
||||
});
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"schemaVersion": "1.7.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
@@ -16,7 +16,19 @@
|
||||
"architecture": "armv7hf"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
"settingPage": "index.html",
|
||||
"paramConfig": [
|
||||
{
|
||||
"name": "CustomServer",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AuthKey",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
|
||||
* Same structure as regular param_bridge.c but without proxy port params.
|
||||
*/
|
||||
|
||||
#include <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 char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: server=%s",
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting (root mode)");
|
||||
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = { "CustomServer", "AuthKey" };
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
syslog(LOG_WARNING, "register callback %s: %s",
|
||||
params[i], error ? error->message : "unknown");
|
||||
if (error) { g_error_free(error); error = NULL; }
|
||||
}
|
||||
}
|
||||
|
||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
g_main_loop_unref(loop);
|
||||
ax_parameter_free(handle);
|
||||
return 0;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
nop:
|
||||
Executable
+65
@@ -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"
|
||||
@@ -383,7 +383,7 @@
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var APP = 'serverconfig';
|
||||
var APP = 'Tailscale_VPN';
|
||||
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
|
||||
var logBox = document.getElementById('log-box');
|
||||
var autoScroll = true;
|
||||
@@ -448,17 +448,18 @@
|
||||
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
// Primary: extract hostname from Axis syslog header (always the real device hostname)
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[1];
|
||||
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
|
||||
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 nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
}
|
||||
|
||||
var loginMatches = txt.match(/active login:\s+\S+/g);
|
||||
@@ -492,6 +493,17 @@
|
||||
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
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
@@ -502,15 +514,27 @@
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
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 (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 };
|
||||
@@ -597,30 +621,38 @@
|
||||
.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() {
|
||||
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') {
|
||||
var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); }).catch(function() { return ''; });
|
||||
var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); }).catch(function() { return ''; });
|
||||
|
||||
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);
|
||||
} 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';
|
||||
});
|
||||
});
|
||||
}).catch(function() {
|
||||
document.getElementById('status-text').textContent = 'Unable to fetch logs';
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
PACKAGENAME=Tailscale_VPN
|
||||
MENUNAME="Tailscale VPN"
|
||||
VENDOR="Mo3he"
|
||||
APPMAJORVERSION=1
|
||||
APPMINORVERSION=96
|
||||
APPMICROVERSION=4
|
||||
APPTYPE=armv7hf
|
||||
APPNAME=Tailscale_VPN
|
||||
APPOPTS=""
|
||||
STARTMODE=respawn
|
||||
OTHERFILES="lib html index.html LICENSE start.sh"
|
||||
SETTINGSPAGEFILE=index.html
|
||||
GRPNAME=
|
||||
USERNAME=root
|
||||
@@ -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 .
|
||||
@@ -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.
|
||||
@@ -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*
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user