mirror of
https://github.com/Mo3he/Axis_Cam_Tailscale.git
synced 2026-10-01 03:55:40 +00:00
Compare commits
13
Commits
v1.96.4-r2
...
v1.96.4-r3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41e2a508c0 | ||
|
|
256aaa9d23 | ||
|
|
3cc3cd4804 | ||
|
|
2c7ce1dc52 | ||
|
|
6bacc9e92b | ||
|
|
281fed0aa0 | ||
|
|
988adfc83a | ||
|
|
0761b6e959 | ||
|
|
ab2f4c050e | ||
|
|
c396f00d0b | ||
|
|
ced340453c | ||
|
|
4afd79167e | ||
|
|
e9919e069d |
@@ -126,8 +126,6 @@ jobs:
|
||||
# Detect variant suffix for .eap naming
|
||||
if [[ "$FOLDER_NAME" == *_ROOT ]]; then
|
||||
VARIANT="_root"
|
||||
elif [[ "$FOLDER_NAME" == *_custom ]]; then
|
||||
VARIANT="_custom"
|
||||
elif [[ "$FOLDER_NAME" == *_acap3 ]]; then
|
||||
VARIANT="_acap3"
|
||||
else
|
||||
@@ -152,7 +150,9 @@ jobs:
|
||||
# Extract .eap files into build folder
|
||||
EAP_OUTPUT="./build/${TAG_NAME}"
|
||||
mkdir -p "$EAP_OUTPUT"
|
||||
docker cp $(docker create "$TAG_NAME"):/opt/app "$EAP_OUTPUT"
|
||||
CID=$(docker create "$TAG_NAME")
|
||||
docker cp "$CID":/opt/app "$EAP_OUTPUT"
|
||||
docker rm "$CID" >/dev/null
|
||||
|
||||
# Move all .eap files to releases folder, append variant if needed
|
||||
find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do
|
||||
|
||||
@@ -6,7 +6,7 @@ This repository provides an **ACAP package** that installs the [Tailscale VPN cl
|
||||
|
||||
- Secure remote access to cameras
|
||||
- Easy to install via EAP package
|
||||
- Works on **Axis OS 12+** (non-root version)
|
||||
- Works on **Axis OS 11.11+** (non-root version)
|
||||
- Works on **legacy Axis OS 9.x / 10.x** via the ACAP 3 variant
|
||||
- Based on **WireGuard VPN** technology
|
||||
|
||||
@@ -14,8 +14,9 @@ This repository provides an **ACAP package** that installs the [Tailscale VPN cl
|
||||
[](LICENSE)
|
||||

|
||||
[](https://github.com/sponsors/Mo3he)
|
||||
[](https://www.buymeacoffee.com/mo3he)
|
||||
|
||||
> **Disclaimer:** This is an independent, community-developed ACAP package and is not an official Axis Communications product. It was developed entirely on personal time and is not affiliated with, endorsed by, or supported by Axis Communications AB. Use it at your own risk. For official Axis software, visit axis.com
|
||||
> **Disclaimer:** This is an independent, community-developed ACAP package and is not an official Axis Communications product. It is not affiliated with, endorsed by, or supported by Axis Communications AB. Use it at your own risk. For official Axis software, visit axis.com
|
||||
|
||||
> **Tailscale Notice:** Tailscale is a product of Tailscale Inc. This package independently redistributes the Tailscale binaries under the [BSD 3-Clause License](LICENSE) and is not affiliated with, endorsed by, or supported by Tailscale Inc. For the official Tailscale client, visit [tailscale.com](https://tailscale.com).
|
||||
---
|
||||
@@ -24,10 +25,9 @@ This repository provides an **ACAP package** that installs the [Tailscale VPN cl
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Settings](#settings)
|
||||
- [Proxy Support](#proxy-support)
|
||||
- [Updating Tailscale](#updating-tailscale)
|
||||
- [Testers Needed](#testers-needed)
|
||||
- [Good News](#good-news)
|
||||
- [Purpose](#purpose)
|
||||
- [Useful Links](#useful-links)
|
||||
- [Compatibility](#compatibility)
|
||||
@@ -55,32 +55,48 @@ Once installed:
|
||||
|
||||
## Usage
|
||||
|
||||
- Runs a startup script to set permissions and launch Tailscale.
|
||||
- View logs via the **Open** button in the app.
|
||||
- Authenticate using the provided URL.
|
||||
- Runs a C-based parameter bridge (compiled via ACAP SDK 1.15.1) that reads settings from the ACAP parameter store and launches Tailscale.
|
||||
- View logs and connection status via the **Open** button in the app.
|
||||
- Authenticate using the provided URL, or pre-enter an auth key in **Settings**.
|
||||
- Change the **Custom Server URL** in Settings to use a self-hosted [Headscale](https://headscale.net/) control server.
|
||||
- Parameter changes (ports, server URL, auth key) are applied automatically without needing to reinstall the app.
|
||||
|
||||
---
|
||||
|
||||
## Proxy Support
|
||||
## Settings
|
||||
|
||||
All non-ROOT variants expose two local proxy endpoints that route outbound traffic through the Tailscale tunnel:
|
||||
All parameters are configurable via the web UI (**Open → Settings** card) and take effect immediately without reinstalling:
|
||||
|
||||
### HTTP CONNECT Proxy — `http://127.0.0.1:8080`
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| Custom Server URL | *(empty)* | Control server URL for [Headscale](https://headscale.net/) or other self-hosted servers. Leave blank to use Tailscale's official servers. |
|
||||
| Auth Key | *(empty)* | Pre-authentication key (`tskey-auth-...`). Cleared automatically after first successful connection. Leave blank to authenticate via browser. |
|
||||
| HTTP Proxy Port | `8080` | Port for the outbound HTTP/HTTPS proxy. |
|
||||
| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 proxy. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
All non-ROOT variants expose two local proxy endpoints that route outbound traffic through the Tailscale tunnel. The ports are configurable via **Settings → HTTP Proxy Port / SOCKS5 Proxy Port** in the web UI.
|
||||
|
||||
### HTTP CONNECT Proxy — `http://127.0.0.1:8080` (default)
|
||||
|
||||
Routes HTTP and HTTPS traffic. Set this wherever an HTTP/HTTPS proxy field is available on the camera:
|
||||
|
||||
| Location | Field | Value |
|
||||
|---|---|---|
|
||||
| System → Network → Global proxies | HTTP proxy | `http://127.0.0.1:8080` |
|
||||
| System → Network → Global proxies | HTTPS proxy | `http://127.0.0.1:8080` |
|
||||
| System → MQTT → Broker | HTTP proxy | `http://127.0.0.1:8080` |
|
||||
| System → MQTT → Broker | HTTPS proxy | `http://127.0.0.1:8080` |
|
||||
| System → Network → Global proxies | HTTP proxy | `http://127.0.0.1:<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:1055`
|
||||
### SOCKS5 Proxy — `127.0.0.1:1080` (default)
|
||||
|
||||
For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:1055`.
|
||||
For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:<port>`.
|
||||
|
||||
> Proxy addresses are shown in the **Connection Details** panel of the web UI when connected.
|
||||
> The active proxy addresses are always shown in the **Proxy Configuration** card of the web UI.
|
||||
|
||||
> If you change a port that is already in use by another process, the app will log an error and exit rather than silently falling back to a different port.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,23 +124,13 @@ 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
|
||||
|
||||
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 12+**.
|
||||
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 11.11+**.
|
||||
|
||||
- Runs in **user space networking mode**.
|
||||
|
||||
For **full networking features**, use the **ROOT** version on Axis OS < 12.
|
||||
For **full kernel networking**, use the **ROOT** version. Note: ROOT mode requires Axis OS 11.11–11.x — Axis OS 12 and later removed root access for third-party applications.
|
||||
|
||||
### Legacy camera support (Axis OS 9.x / 10.x)
|
||||
|
||||
@@ -157,15 +163,13 @@ The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**-
|
||||
|
||||
| Variant | Architecture | Axis OS | Notes |
|
||||
|---|---|---|---|
|
||||
| `armv7hf` | ARMv7 | 11+ (ACAP 4) | Standard, userspace networking |
|
||||
| `aarch64` | AArch64 | 11+ (ACAP 4) | Standard, userspace networking |
|
||||
| `armv7hf_root` | ARMv7 | 10 or earlier | Full kernel networking (root) |
|
||||
| `aarch64_root` | AArch64 | 10 or earlier | Full kernel networking (root) |
|
||||
| `armv7hf_custom` | ARMv7 | 11+ (ACAP 4) | Custom server / Headscale support |
|
||||
| `aarch64_custom` | AArch64 | 11+ (ACAP 4) | Custom server / Headscale support |
|
||||
| `aarch64` | AArch64 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports |
|
||||
| `armv7hf` | ARMv7 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports |
|
||||
| `aarch64_root` | AArch64 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
|
||||
| `armv7hf_root` | ARMv7 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
|
||||
| `armv7hf_acap3` | ARMv7 | **9.x – 10.x** | Legacy cameras, ACAP SDK 3 |
|
||||
|
||||
> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 11+ → use the standard variant. Axis OS 9/10 on ARMv7 → use `armv7hf_acap3`.
|
||||
> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 12+ → use the standard variant (`aarch64` or `armv7hf`). Axis OS 11.11–11.x → standard variant, or ROOT if you need kernel networking. Axis OS 9.x/10.x on ARMv7 → use `armv7hf_acap3`.
|
||||
|
||||
You can verify your device details using the following command:
|
||||
|
||||
|
||||
+2
-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
|
||||
@@ -10,4 +10,4 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
COPY ./app /opt/app/
|
||||
WORKDIR /opt/app
|
||||
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
+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,34 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting Tailscale VPN service"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
chmod 755 "$APP_DIR/lib/tailscale"
|
||||
chmod 755 "$APP_DIR/lib/tailscaled"
|
||||
|
||||
# Kill any leftover daemon from a previous run
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting tailscaled daemon (userspace networking)"
|
||||
"$APP_DIR/lib/tailscaled" \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
--socks5-server=localhost:1055 \
|
||||
--outbound-http-proxy-listen=localhost:8080 \
|
||||
--tun=userspace-networking \
|
||||
2>&1 | logger -t "Tailscale_VPN" &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
|
||||
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --hostname="$(hostname)" 2>&1 | logger -t "Tailscale_VPN"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
|
||||
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
|
||||
wait $TAILSCALED_PID
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
@@ -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
|
||||
+190
-26
@@ -11,7 +11,7 @@
|
||||
--border: #262a35;
|
||||
--text: #e4e6ed;
|
||||
--muted: #8b8fa3;
|
||||
--accent: #4f8ff7;
|
||||
--accent: #2e2d2d;
|
||||
--green: #22c55e;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
@@ -26,7 +26,7 @@
|
||||
--border: #e0e3e8;
|
||||
--text: #1a1a2e;
|
||||
--muted: #6b7084;
|
||||
--accent: #2563eb;
|
||||
--accent: #2e2d2d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -231,13 +231,47 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-box .log-line { display: block; }
|
||||
.log-box .log-line:hover { background: rgba(79,143,247,0.06); }
|
||||
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
|
||||
.log-line .ts { color: var(--muted); opacity: 0.6; }
|
||||
.log-line .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); }
|
||||
|
||||
/* 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;
|
||||
@@ -257,8 +291,8 @@
|
||||
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);
|
||||
background: rgba(46,45,45,0.1);
|
||||
border: 1px solid rgba(46,45,45,0.2);
|
||||
}
|
||||
.update-banner.visible { display: flex; }
|
||||
.update-banner .update-text {
|
||||
@@ -292,18 +326,6 @@
|
||||
|
||||
<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">
|
||||
@@ -364,6 +386,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">
|
||||
@@ -494,19 +562,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Parse proxy ports from log — use last match so old entries don't win
|
||||
var httpPort = null;
|
||||
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
|
||||
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
|
||||
var socksPort = null;
|
||||
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
|
||||
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
|
||||
|
||||
// Cache when found, restore from cache when missing
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
||||
tsIP = tsIP || cacheGet('ip');
|
||||
node = node || cacheGet('node');
|
||||
tailnet = tailnet || cacheGet('tailnet');
|
||||
version = version || cacheGet('version');
|
||||
httpPort = httpPort || cacheGet('http-port');
|
||||
socksPort = socksPort || cacheGet('socks-port');
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
// Fallbacks only when syslog has rotated and no state transitions are visible.
|
||||
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
|
||||
// "Tailscale VPN is running" message which stays in syslog indefinitely.
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
@@ -515,17 +598,22 @@
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
|
||||
// (our shell log). The shell log is written AFTER auth completes, so it correctly
|
||||
// post-dates the auth URL when connection succeeds.
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
}
|
||||
|
||||
function classifyLine(msg) {
|
||||
@@ -576,6 +664,10 @@
|
||||
auth.style.display = 'none';
|
||||
}
|
||||
|
||||
// Proxy card is always visible — update ports whenever known
|
||||
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
|
||||
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
|
||||
|
||||
if (r.state === 'connected') {
|
||||
document.getElementById('ts-ip').textContent = r.ip || '-';
|
||||
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
|
||||
@@ -619,7 +711,6 @@
|
||||
if (!running) {
|
||||
result.state = 'disconnected';
|
||||
} else if (!result.url && result.state !== 'connected') {
|
||||
// App is running but syslog is empty/rotated
|
||||
result.state = 'connected';
|
||||
result.ip = result.ip || cacheGet('ip');
|
||||
result.node = result.node || cacheGet('node');
|
||||
@@ -681,6 +772,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,250 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (userspace variant).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
|
||||
* 2. Write them to CONFIG_FILE so the shell script can source them.
|
||||
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
|
||||
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
|
||||
* of the child so the new config is picked up.
|
||||
* Rapid changes within 300 ms are coalesced into a single restart.
|
||||
* 5. Watchdog: if the child exits unexpectedly, restart it.
|
||||
*
|
||||
* Runs as the unprivileged 'sdk' ACAP user — no root required.
|
||||
*/
|
||||
|
||||
#include <axsdk/axparameter.h>
|
||||
#include <glib-unix.h>
|
||||
#include <stdbool.h>
|
||||
#include <syslog.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
static pid_t child_pid = -1;
|
||||
static guint reload_timer_id = 0;
|
||||
|
||||
static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_http_proxy_port = NULL;
|
||||
static char *cfg_socks5_port = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
/* ── child process management ──────────────────────────────────────────── */
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
/* ── watchdog ────────────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── config file ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("HttpProxyPort", cfg_http_proxy_port)
|
||||
LOAD("Socks5Port", cfg_socks5_port)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
||||
cache_get(&cfg_http_proxy_port, "8080"),
|
||||
cache_get(&cfg_socks5_port, "1080"),
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
|
||||
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
/* ── signal handler ──────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── main ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting");
|
||||
|
||||
/* Ensure localdata dir exists */
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
syslog(LOG_WARNING, "register callback %s: %s",
|
||||
params[i], error ? error->message : "unknown");
|
||||
if (error) { g_error_free(error); error = NULL; }
|
||||
}
|
||||
}
|
||||
|
||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
g_main_loop_unref(loop);
|
||||
ax_parameter_free(handle);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG ARCH=aarch64
|
||||
ARG VERSION=1.3
|
||||
ARG VERSION=1.15.1
|
||||
ARG UBUNTU_VERSION=22.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
@@ -10,4 +10,4 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
COPY ./app /opt/app/
|
||||
WORKDIR /opt/app
|
||||
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
@@ -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 --hostname="$(hostname)" 2>&1 | logger -t "Tailscale_VPN"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
wait $TAILSCALED_PID
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
@@ -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
|
||||
@@ -11,7 +11,7 @@
|
||||
--border: #262a35;
|
||||
--text: #e4e6ed;
|
||||
--muted: #8b8fa3;
|
||||
--accent: #4f8ff7;
|
||||
--accent: #2e2d2d;
|
||||
--green: #22c55e;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
@@ -26,7 +26,7 @@
|
||||
--border: #e0e3e8;
|
||||
--text: #1a1a2e;
|
||||
--muted: #6b7084;
|
||||
--accent: #2563eb;
|
||||
--accent: #2e2d2d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -231,13 +231,47 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-box .log-line { display: block; }
|
||||
.log-box .log-line:hover { background: rgba(79,143,247,0.06); }
|
||||
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
|
||||
.log-line .ts { color: var(--muted); opacity: 0.6; }
|
||||
.log-line .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); }
|
||||
|
||||
/* 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;
|
||||
@@ -257,8 +291,8 @@
|
||||
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);
|
||||
background: rgba(46,45,45,0.1);
|
||||
border: 1px solid rgba(46,45,45,0.2);
|
||||
}
|
||||
.update-banner.visible { display: flex; }
|
||||
.update-banner .update-text {
|
||||
@@ -292,18 +326,6 @@
|
||||
|
||||
<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">
|
||||
@@ -364,6 +386,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">
|
||||
@@ -494,19 +562,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Parse proxy ports from log — use last match so old entries don't win
|
||||
var httpPort = null;
|
||||
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
|
||||
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
|
||||
var socksPort = null;
|
||||
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
|
||||
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
|
||||
|
||||
// Cache when found, restore from cache when missing
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
||||
tsIP = tsIP || cacheGet('ip');
|
||||
node = node || cacheGet('node');
|
||||
tailnet = tailnet || cacheGet('tailnet');
|
||||
version = version || cacheGet('version');
|
||||
httpPort = httpPort || cacheGet('http-port');
|
||||
socksPort = socksPort || cacheGet('socks-port');
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
// Fallbacks only when syslog has rotated and no state transitions are visible.
|
||||
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
|
||||
// "Tailscale VPN is running" message which stays in syslog indefinitely.
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
@@ -515,17 +598,22 @@
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
|
||||
// (our shell log). The shell log is written AFTER auth completes, so it correctly
|
||||
// post-dates the auth URL when connection succeeds.
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
}
|
||||
|
||||
function classifyLine(msg) {
|
||||
@@ -576,6 +664,10 @@
|
||||
auth.style.display = 'none';
|
||||
}
|
||||
|
||||
// Proxy card is always visible — update ports whenever known
|
||||
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
|
||||
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
|
||||
|
||||
if (r.state === 'connected') {
|
||||
document.getElementById('ts-ip').textContent = r.ip || '-';
|
||||
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
|
||||
@@ -680,6 +772,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,214 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
|
||||
* Same structure as regular param_bridge.c but without proxy port params.
|
||||
*/
|
||||
|
||||
#include <axsdk/axparameter.h>
|
||||
#include <glib-unix.h>
|
||||
#include <stdbool.h>
|
||||
#include <syslog.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
static pid_t child_pid = -1;
|
||||
static guint reload_timer_id = 0;
|
||||
|
||||
static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: server=%s",
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting (root mode)");
|
||||
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = { "CustomServer", "AuthKey" };
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
syslog(LOG_WARNING, "register callback %s: %s",
|
||||
params[i], error ? error->message : "unknown");
|
||||
if (error) { g_error_free(error); error = NULL; }
|
||||
}
|
||||
}
|
||||
|
||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
g_main_loop_unref(loop);
|
||||
ax_parameter_free(handle);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
ARG ARCH=aarch64
|
||||
ARG VERSION=12.3.0
|
||||
ARG UBUNTU_VERSION=24.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
|
||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
|
||||
# Building the ACAP application
|
||||
COPY ./app /opt/app/
|
||||
WORKDIR /opt/app
|
||||
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build .
|
||||
@@ -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 @@
|
||||
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,695 +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 class="info-item">
|
||||
<div class="info-label">HTTP/HTTPS Proxy</div>
|
||||
<div class="info-value">http://127.0.0.1:8080</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">SOCKS5 Proxy</div>
|
||||
<div class="info-value">127.0.0.1:1055</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;
|
||||
}
|
||||
|
||||
// Primary: extract hostname from Axis syslog header (always the real device hostname)
|
||||
var node = null;
|
||||
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) {
|
||||
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);
|
||||
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 an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
}
|
||||
|
||||
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);
|
||||
// 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);
|
||||
});
|
||||
})
|
||||
.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,85 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Make sure this script terminates any existing Tailscale processes before starting new ones
|
||||
|
||||
# Kill any existing tailscaled processes
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
# Simple script to start Tailscale with custom configuration
|
||||
APP_DIR="/usr/local/packages/serverconfig"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
CONFIG_FILE="$STATE_DIR/config.txt"
|
||||
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
|
||||
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
|
||||
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
|
||||
|
||||
# Create localdata directory if it doesn't exist
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Log to syslog
|
||||
logger -t "tailscale_script" "Starting Tailscale VPN service"
|
||||
|
||||
# Set execute permissions
|
||||
chmod 755 $TAILSCALED_PATH
|
||||
chmod 755 $TAILSCALE_PATH
|
||||
|
||||
# Read configuration (if exists)
|
||||
CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
logger -t "tailscale_script" "Reading configuration from $CONFIG_FILE"
|
||||
# Read values from config file
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
"custom_server") CUSTOM_SERVER="$value" ;;
|
||||
"auth_key") AUTH_KEY="$value" ;;
|
||||
esac
|
||||
done < "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# Start tailscaled with state stored in localdata
|
||||
logger -t "tailscale_script" "Starting tailscaled daemon"
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
--socks5-server=localhost:1055 \
|
||||
--outbound-http-proxy-listen=localhost:8080 \
|
||||
--tun=userspace-networking \
|
||||
2>&1 | logger -t "tailscale_script" &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
# Wait for tailscaled to initialize
|
||||
sleep 2
|
||||
|
||||
# Build up the command based on available parameters
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
logger -t "tailscale_script" "Using custom server: $CUSTOM_SERVER"
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
fi
|
||||
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
logger -t "tailscale_script" "Using authentication key"
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
# Connect to Tailscale network
|
||||
logger -t "tailscale_script" "Running: $TAILSCALE_CMD"
|
||||
eval $TAILSCALE_CMD
|
||||
UP_EXIT=$?
|
||||
|
||||
# Clear auth key from config after first use — Tailscale auth keys are single-use
|
||||
# and the node identity is persisted in tailscaled.state, so the key is no longer needed.
|
||||
if [ -n "$AUTH_KEY" ] && [ "$UP_EXIT" -eq 0 ] && [ -f "$CONFIG_FILE" ]; then
|
||||
logger -t "tailscale_script" "Clearing auth key from config after successful authentication"
|
||||
printf 'custom_server=%s\nauth_key=\n' "$CUSTOM_SERVER" > "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# Keep the script running to maintain the tailscaled process
|
||||
logger -t "tailscale_script" "Tailscale VPN is running"
|
||||
logger -t "tailscale_script" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
|
||||
logger -t "tailscale_script" "SOCKS5 proxy: 127.0.0.1:1055"
|
||||
logger -t "tailscale_script" "To change settings, modify parameters in ACAP web interface"
|
||||
|
||||
# Wait for tailscaled process to exit
|
||||
wait $TAILSCALED_PID
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-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
|
||||
@@ -10,4 +10,4 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
COPY ./app /opt/app/
|
||||
WORKDIR /opt/app
|
||||
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
+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,34 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting Tailscale VPN service"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
chmod 755 "$APP_DIR/lib/tailscale"
|
||||
chmod 755 "$APP_DIR/lib/tailscaled"
|
||||
|
||||
# Kill any leftover daemon from a previous run
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting tailscaled daemon (userspace networking)"
|
||||
"$APP_DIR/lib/tailscaled" \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
--socks5-server=localhost:1055 \
|
||||
--outbound-http-proxy-listen=localhost:8080 \
|
||||
--tun=userspace-networking \
|
||||
2>&1 | logger -t "Tailscale_VPN" &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
|
||||
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --hostname="$(hostname)" 2>&1 | logger -t "Tailscale_VPN"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
|
||||
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
|
||||
wait $TAILSCALED_PID
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
@@ -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
|
||||
+190
-25
@@ -11,7 +11,7 @@
|
||||
--border: #262a35;
|
||||
--text: #e4e6ed;
|
||||
--muted: #8b8fa3;
|
||||
--accent: #4f8ff7;
|
||||
--accent: #2e2d2d;
|
||||
--green: #22c55e;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
@@ -26,7 +26,7 @@
|
||||
--border: #e0e3e8;
|
||||
--text: #1a1a2e;
|
||||
--muted: #6b7084;
|
||||
--accent: #2563eb;
|
||||
--accent: #2e2d2d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -231,13 +231,47 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-box .log-line { display: block; }
|
||||
.log-box .log-line:hover { background: rgba(79,143,247,0.06); }
|
||||
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
|
||||
.log-line .ts { color: var(--muted); opacity: 0.6; }
|
||||
.log-line .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); }
|
||||
|
||||
/* 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;
|
||||
@@ -257,8 +291,8 @@
|
||||
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);
|
||||
background: rgba(46,45,45,0.1);
|
||||
border: 1px solid rgba(46,45,45,0.2);
|
||||
}
|
||||
.update-banner.visible { display: flex; }
|
||||
.update-banner .update-text {
|
||||
@@ -292,18 +326,6 @@
|
||||
|
||||
<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">
|
||||
@@ -364,6 +386,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">
|
||||
@@ -494,19 +562,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Parse proxy ports from log — use last match so old entries don't win
|
||||
var httpPort = null;
|
||||
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
|
||||
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
|
||||
var socksPort = null;
|
||||
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
|
||||
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
|
||||
|
||||
// Cache when found, restore from cache when missing
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
||||
tsIP = tsIP || cacheGet('ip');
|
||||
node = node || cacheGet('node');
|
||||
tailnet = tailnet || cacheGet('tailnet');
|
||||
version = version || cacheGet('version');
|
||||
httpPort = httpPort || cacheGet('http-port');
|
||||
socksPort = socksPort || cacheGet('socks-port');
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
// Fallbacks only when syslog has rotated and no state transitions are visible.
|
||||
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
|
||||
// "Tailscale VPN is running" message which stays in syslog indefinitely.
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
@@ -515,17 +598,22 @@
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
|
||||
// (our shell log). The shell log is written AFTER auth completes, so it correctly
|
||||
// post-dates the auth URL when connection succeeds.
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
}
|
||||
|
||||
function classifyLine(msg) {
|
||||
@@ -576,6 +664,10 @@
|
||||
auth.style.display = 'none';
|
||||
}
|
||||
|
||||
// Proxy card is always visible — update ports whenever known
|
||||
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
|
||||
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
|
||||
|
||||
if (r.state === 'connected') {
|
||||
document.getElementById('ts-ip').textContent = r.ip || '-';
|
||||
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
|
||||
@@ -680,6 +772,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,250 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (userspace variant).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
|
||||
* 2. Write them to CONFIG_FILE so the shell script can source them.
|
||||
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
|
||||
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
|
||||
* of the child so the new config is picked up.
|
||||
* Rapid changes within 300 ms are coalesced into a single restart.
|
||||
* 5. Watchdog: if the child exits unexpectedly, restart it.
|
||||
*
|
||||
* Runs as the unprivileged 'sdk' ACAP user — no root required.
|
||||
*/
|
||||
|
||||
#include <axsdk/axparameter.h>
|
||||
#include <glib-unix.h>
|
||||
#include <stdbool.h>
|
||||
#include <syslog.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
|
||||
static pid_t child_pid = -1;
|
||||
static guint reload_timer_id = 0;
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
|
||||
static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_http_proxy_port = NULL;
|
||||
static char *cfg_socks5_port = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
/* ── child process management ──────────────────────────────────────────── */
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
/* ── watchdog ────────────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── config file ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("HttpProxyPort", cfg_http_proxy_port)
|
||||
LOAD("Socks5Port", cfg_socks5_port)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
||||
cache_get(&cfg_http_proxy_port, "8080"),
|
||||
cache_get(&cfg_socks5_port, "1080"),
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
|
||||
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
/* ── signal handler ──────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── main ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting");
|
||||
|
||||
/* Ensure localdata dir exists */
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
syslog(LOG_WARNING, "register callback %s: %s",
|
||||
params[i], error ? error->message : "unknown");
|
||||
if (error) { g_error_free(error); error = NULL; }
|
||||
}
|
||||
}
|
||||
|
||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
g_main_loop_unref(loop);
|
||||
ax_parameter_free(handle);
|
||||
return 0;
|
||||
}
|
||||
+2
-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
|
||||
@@ -10,4 +10,4 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
COPY ./app /opt/app/
|
||||
WORKDIR /opt/app
|
||||
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
+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 --hostname="$(hostname)" 2>&1 | logger -t "Tailscale_VPN"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
wait $TAILSCALED_PID
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
@@ -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
|
||||
+190
-25
@@ -11,7 +11,7 @@
|
||||
--border: #262a35;
|
||||
--text: #e4e6ed;
|
||||
--muted: #8b8fa3;
|
||||
--accent: #4f8ff7;
|
||||
--accent: #2e2d2d;
|
||||
--green: #22c55e;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
@@ -26,7 +26,7 @@
|
||||
--border: #e0e3e8;
|
||||
--text: #1a1a2e;
|
||||
--muted: #6b7084;
|
||||
--accent: #2563eb;
|
||||
--accent: #2e2d2d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -231,13 +231,47 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-box .log-line { display: block; }
|
||||
.log-box .log-line:hover { background: rgba(79,143,247,0.06); }
|
||||
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
|
||||
.log-line .ts { color: var(--muted); opacity: 0.6; }
|
||||
.log-line .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); }
|
||||
|
||||
/* 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;
|
||||
@@ -257,8 +291,8 @@
|
||||
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);
|
||||
background: rgba(46,45,45,0.1);
|
||||
border: 1px solid rgba(46,45,45,0.2);
|
||||
}
|
||||
.update-banner.visible { display: flex; }
|
||||
.update-banner .update-text {
|
||||
@@ -292,18 +326,6 @@
|
||||
|
||||
<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">
|
||||
@@ -364,6 +386,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">
|
||||
@@ -494,19 +562,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Parse proxy ports from log — use last match so old entries don't win
|
||||
var httpPort = null;
|
||||
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
|
||||
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
|
||||
var socksPort = null;
|
||||
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
|
||||
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
|
||||
|
||||
// Cache when found, restore from cache when missing
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
||||
tsIP = tsIP || cacheGet('ip');
|
||||
node = node || cacheGet('node');
|
||||
tailnet = tailnet || cacheGet('tailnet');
|
||||
version = version || cacheGet('version');
|
||||
httpPort = httpPort || cacheGet('http-port');
|
||||
socksPort = socksPort || cacheGet('socks-port');
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
// Fallbacks only when syslog has rotated and no state transitions are visible.
|
||||
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
|
||||
// "Tailscale VPN is running" message which stays in syslog indefinitely.
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
@@ -515,17 +598,22 @@
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
|
||||
// (our shell log). The shell log is written AFTER auth completes, so it correctly
|
||||
// post-dates the auth URL when connection succeeds.
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
|
||||
}
|
||||
|
||||
function classifyLine(msg) {
|
||||
@@ -576,6 +664,10 @@
|
||||
auth.style.display = 'none';
|
||||
}
|
||||
|
||||
// Proxy card is always visible — update ports whenever known
|
||||
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
|
||||
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
|
||||
|
||||
if (r.state === 'connected') {
|
||||
document.getElementById('ts-ip').textContent = r.ip || '-';
|
||||
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
|
||||
@@ -680,6 +772,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,214 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
|
||||
* Same structure as regular param_bridge.c but without proxy port params.
|
||||
*/
|
||||
|
||||
#include <axsdk/axparameter.h>
|
||||
#include <glib-unix.h>
|
||||
#include <stdbool.h>
|
||||
#include <syslog.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
|
||||
static pid_t child_pid = -1;
|
||||
static guint reload_timer_id = 0;
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
|
||||
static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: server=%s",
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting (root mode)");
|
||||
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = { "CustomServer", "AuthKey" };
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
syslog(LOG_WARNING, "register callback %s: %s",
|
||||
params[i], error ? error->message : "unknown");
|
||||
if (error) { g_error_free(error); error = NULL; }
|
||||
}
|
||||
}
|
||||
|
||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
g_main_loop_unref(loop);
|
||||
ax_parameter_free(handle);
|
||||
return 0;
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
--border: #262a35;
|
||||
--text: #e4e6ed;
|
||||
--muted: #8b8fa3;
|
||||
--accent: #4f8ff7;
|
||||
--accent: #2e2d2d;
|
||||
--green: #22c55e;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
@@ -26,7 +26,7 @@
|
||||
--border: #e0e3e8;
|
||||
--text: #1a1a2e;
|
||||
--muted: #6b7084;
|
||||
--accent: #2563eb;
|
||||
--accent: #2e2d2d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -231,7 +231,7 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-box .log-line { display: block; }
|
||||
.log-box .log-line:hover { background: rgba(79,143,247,0.06); }
|
||||
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
|
||||
.log-line .ts { color: var(--muted); opacity: 0.6; }
|
||||
.log-line .msg-info { color: var(--accent); }
|
||||
.log-line .msg-warn { color: var(--yellow); }
|
||||
@@ -257,8 +257,8 @@
|
||||
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);
|
||||
background: rgba(46,45,45,0.1);
|
||||
border: 1px solid rgba(46,45,45,0.2);
|
||||
}
|
||||
.update-banner.visible { display: flex; }
|
||||
.update-banner .update-text {
|
||||
@@ -292,18 +292,6 @@
|
||||
|
||||
<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">
|
||||
@@ -514,19 +502,21 @@
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
ARG ARCH=armv7hf
|
||||
ARG VERSION=12.3.0
|
||||
ARG UBUNTU_VERSION=24.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
|
||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
|
||||
# Building the ACAP application
|
||||
COPY ./app /opt/app/
|
||||
WORKDIR /opt/app
|
||||
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build .
|
||||
@@ -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,695 +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 class="info-item">
|
||||
<div class="info-label">HTTP/HTTPS Proxy</div>
|
||||
<div class="info-value">http://127.0.0.1:8080</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">SOCKS5 Proxy</div>
|
||||
<div class="info-value">127.0.0.1:1055</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;
|
||||
}
|
||||
|
||||
// Primary: extract hostname from Axis syslog header (always the real device hostname)
|
||||
var node = null;
|
||||
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) {
|
||||
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);
|
||||
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 an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
||||
if (isRunning && latestUrl) {
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
}
|
||||
|
||||
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);
|
||||
// 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);
|
||||
});
|
||||
})
|
||||
.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,85 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Make sure this script terminates any existing Tailscale processes before starting new ones
|
||||
|
||||
# Kill any existing tailscaled processes
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
# Simple script to start Tailscale with custom configuration
|
||||
APP_DIR="/usr/local/packages/serverconfig"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
CONFIG_FILE="$STATE_DIR/config.txt"
|
||||
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
|
||||
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
|
||||
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
|
||||
|
||||
# Create localdata directory if it doesn't exist
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Log to syslog
|
||||
logger -t "tailscale_script" "Starting Tailscale VPN service"
|
||||
|
||||
# Set execute permissions
|
||||
chmod 755 $TAILSCALED_PATH
|
||||
chmod 755 $TAILSCALE_PATH
|
||||
|
||||
# Read configuration (if exists)
|
||||
CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
logger -t "tailscale_script" "Reading configuration from $CONFIG_FILE"
|
||||
# Read values from config file
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
"custom_server") CUSTOM_SERVER="$value" ;;
|
||||
"auth_key") AUTH_KEY="$value" ;;
|
||||
esac
|
||||
done < "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# Start tailscaled with state stored in localdata
|
||||
logger -t "tailscale_script" "Starting tailscaled daemon"
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
--socks5-server=localhost:1055 \
|
||||
--outbound-http-proxy-listen=localhost:8080 \
|
||||
--tun=userspace-networking \
|
||||
2>&1 | logger -t "tailscale_script" &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
# Wait for tailscaled to initialize
|
||||
sleep 2
|
||||
|
||||
# Build up the command based on available parameters
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
logger -t "tailscale_script" "Using custom server: $CUSTOM_SERVER"
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
fi
|
||||
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
logger -t "tailscale_script" "Using authentication key"
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
# Connect to Tailscale network
|
||||
logger -t "tailscale_script" "Running: $TAILSCALE_CMD"
|
||||
eval $TAILSCALE_CMD
|
||||
UP_EXIT=$?
|
||||
|
||||
# Clear auth key from config after first use — Tailscale auth keys are single-use
|
||||
# and the node identity is persisted in tailscaled.state, so the key is no longer needed.
|
||||
if [ -n "$AUTH_KEY" ] && [ "$UP_EXIT" -eq 0 ] && [ -f "$CONFIG_FILE" ]; then
|
||||
logger -t "tailscale_script" "Clearing auth key from config after successful authentication"
|
||||
printf 'custom_server=%s\nauth_key=\n' "$CUSTOM_SERVER" > "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# Keep the script running to maintain the tailscaled process
|
||||
logger -t "tailscale_script" "Tailscale VPN is running"
|
||||
logger -t "tailscale_script" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
|
||||
logger -t "tailscale_script" "SOCKS5 proxy: 127.0.0.1:1055"
|
||||
logger -t "tailscale_script" "To change settings, modify parameters in ACAP web interface"
|
||||
|
||||
# Wait for tailscaled process to exit
|
||||
wait $TAILSCALED_PID
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -334,6 +334,7 @@
|
||||
<a href="#install">Install</a>
|
||||
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener" style="color: #db61a2;">Sponsor</a>
|
||||
<a href="https://buymeacoffee.com/mo3he" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:0.35rem;background:#FFDD00;color:#000;font-weight:700;font-size:0.85rem;padding:0.35rem 0.85rem;border-radius:8px;line-height:1;">☕ Buy me a coffee</a>
|
||||
<button class="theme-toggle" 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>
|
||||
@@ -363,7 +364,7 @@
|
||||
<div style="background:rgba(79,143,247,0.07);border:1px solid rgba(79,143,247,0.18);border-radius:10px;padding:12px 18px;font-size:12.5px;color:var(--muted);line-height:1.6;">
|
||||
<strong style="color:var(--text);">Disclaimer:</strong>
|
||||
This is an independent, community-developed ACAP package and is <strong style="color:var(--text);">not an official Axis Communications product</strong>.
|
||||
It was developed entirely on personal time and is not affiliated with, endorsed by, or supported by Axis Communications AB.
|
||||
It is not affiliated with, endorsed by, or supported by Axis Communications AB.
|
||||
Use at your own risk. For official Axis software, visit <a href="https://www.axis.com" target="_blank" rel="noopener">axis.com</a>.
|
||||
</div>
|
||||
</div>
|
||||
@@ -492,6 +493,7 @@
|
||||
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases" target="_blank" rel="noopener">Releases</a>
|
||||
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener">Sponsor</a>
|
||||
<a href="https://buymeacoffee.com/mo3he" target="_blank" rel="noopener">Buy Me a Coffee</a>
|
||||
<a href="https://tailscale.com/" target="_blank" rel="noopener">Tailscale</a>
|
||||
<a href="https://www.axis.com/" target="_blank" rel="noopener">Axis</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user