mirror of
https://github.com/Mo3he/Axis_Cam_Tailscale.git
synced 2026-10-01 03:55:40 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
741062bcef | ||
|
|
c4ac8ab601 | ||
|
|
fd5cec50bb | ||
|
|
9a35f4e584 | ||
|
|
896fe380ff | ||
|
|
b398b5498c | ||
|
|
08a2140d68 | ||
|
|
1a5d2c1a24 | ||
|
|
4066273b3f | ||
|
|
924f04c44a | ||
|
|
674f1c4853 | ||
|
|
93855e5762 | ||
|
|
889db273ec | ||
|
|
52572fc61c | ||
|
|
76138394e1 | ||
|
|
db24028d61 | ||
|
|
f5a30122a3 | ||
|
|
768a859c1f | ||
|
|
f395d91de8 |
+5
-5
@@ -126,9 +126,9 @@ Before opening a Pull Request (PR), please consider the following guidelines:
|
||||
And finally when you are satisfied with your changes, open a new PR.
|
||||
|
||||
<!-- markdownlint-disable MD034 -->
|
||||
[issues]: https://github.com/AxisCommunications/tailscale-acap/issues
|
||||
[issues_new]: https://github.com/AxisCommunications/tailscale-acap/issues/new
|
||||
[issues_bugs]: https://github.com/AxisCommunications/tailscale-acap/issues?q=label%3Abug
|
||||
[discussions]: https://github.com/AxisCommunications/tailscale-acap/discussions
|
||||
[discussions_new]: https://github.com/AxisCommunications/tailscale-acap/discussions/new
|
||||
[issues]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues
|
||||
[issues_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues/new
|
||||
[issues_bugs]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues?q=label%3Abug
|
||||
[discussions]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions
|
||||
[discussions_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions/new
|
||||
<!-- markdownlint-enable MD034 -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
Copyright (c) 2022 Weston Blieden (ACAP packaging and wrapper code)
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
||||
@@ -14,7 +14,7 @@ This repository provides an **ACAP package** that installs the [Tailscale VPN cl
|
||||
[](LICENSE)
|
||||

|
||||
[](https://github.com/sponsors/Mo3he)
|
||||
[](https://www.buymeacoffee.com/mo3he)
|
||||
[](https://www.buymeacoffee.com/mo3he)
|
||||
|
||||
> **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
|
||||
|
||||
@@ -27,10 +27,12 @@ This repository provides an **ACAP package** that installs the [Tailscale VPN cl
|
||||
- [Usage](#usage)
|
||||
- [Settings](#settings)
|
||||
- [Proxy Support](#proxy-support)
|
||||
- [Accessing Tailnet Services from the Camera](#accessing-tailnet-services-from-the-camera)
|
||||
- [Updating Tailscale](#updating-tailscale)
|
||||
- [Purpose](#purpose)
|
||||
- [Useful Links](#useful-links)
|
||||
- [Compatibility](#compatibility)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Star History](#star-history)
|
||||
- [Support](#support)
|
||||
|
||||
@@ -73,6 +75,8 @@ All parameters are configurable via the web UI (**Open → Settings** card) and
|
||||
| Auth Key | *(empty)* | Pre-authentication key (`tskey-auth-...`). Cleared automatically after first successful connection. Leave blank to authenticate via browser. |
|
||||
| HTTP Proxy Port | `8080` | Port for the outbound HTTP/HTTPS proxy. |
|
||||
| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 proxy. |
|
||||
| Accept DNS | `off` | Passes `--accept-dns=true` to `tailscale up`. Allows the tailnet to push DNS settings to the camera. Off by default to avoid overriding the camera's DNS configuration. Not available on `armv7hf_acap3`. |
|
||||
| Accept Routes | `off` | Passes `--accept-routes=true` to `tailscale up`. Allows the camera to use subnet routes advertised by other nodes in the tailnet. Not available on `armv7hf_acap3`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -100,6 +104,35 @@ For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:<po
|
||||
|
||||
---
|
||||
|
||||
## Accessing Tailnet Services from the Camera
|
||||
|
||||
There is an important asymmetry to understand. Making the camera **reachable from** the tailnet (browsing to it, VAPIX, SSH from another tailnet node) works on every build. The harder direction is the camera **reaching out to** a tailnet peer, for example mounting an SMB/CIFS network share hosted on another node. How well this works depends on which build you use.
|
||||
|
||||
### Why the build matters
|
||||
|
||||
| Build | Networking mode | Camera-initiated access to tailnet peers |
|
||||
|---|---|---|
|
||||
| Non-root (`aarch64`, `armv7hf`) and `armv7hf_acap3` | `--tun=userspace-networking` (no kernel `tailscale0` interface) | Only through the local **SOCKS5 / HTTP proxies**, and only for **proxy-aware** apps. Firmware system services (the SMB share client, NTP, etc.) are proxy-unaware, so they **cannot** reach a peer's `100.x` Tailscale IP directly. |
|
||||
| **ROOT** (`aarch64_root`, `armv7hf_root`) | Kernel networking with a real `tailscale0` interface | Peer `100.x` IPs are routable at the OS level, so firmware services **can** connect directly. Enable **Accept Routes** to also reach subnets behind other nodes. |
|
||||
|
||||
In short: on non-root builds the proxies cover apps that know how to use a proxy, but a system feature like "add network share" opens a raw socket that never touches the tunnel. The ROOT build is the clean way to let the camera *consume* tailnet services.
|
||||
|
||||
### Plan B: reverse-SSH tunnel
|
||||
> **Requires root on the camera.** Port 445 is privileged, so binding it needs a root-capable build (e.g. developer certificates installed).
|
||||
|
||||
If you cannot use the ROOT build but still need the camera to mount a share on a machine that is on your tailnet, you can make the remote share appear **local** to the camera with a reverse SSH tunnel. Because the destination becomes `127.0.0.1`, the proxy-unaware SMB client never has to route over the tailnet.
|
||||
|
||||
From a computer that has both the share and tailnet access to the camera:
|
||||
|
||||
```bash
|
||||
# Forward the camera's local port 445 back to the SMB share on this machine
|
||||
ssh -R 445:localhost:445 root@<camera-tailscale-ip>
|
||||
```
|
||||
|
||||
Then, in the camera's **System → Storage → Add network share** dialog, use `127.0.0.1` as the share host and connect.
|
||||
|
||||
---
|
||||
|
||||
## Updating Tailscale
|
||||
|
||||
- New `.eap` files are auto-built and released **weekly** (if a new Tailscale version is available).
|
||||
@@ -182,6 +215,26 @@ curl --anyauth "*" -u <username>:<password> <device_ip>/axis-cgi/basicdeviceinfo
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### AXIS OS 13 Preparation
|
||||
|
||||
AXIS OS 13 (scheduled for September 2026) introduces several breaking changes that affect all ACAP applications. The following items are required to maintain compatibility. See the full [AXIS OS 13 breaking changes](https://www.axis.com/for-developers/news/AXIS-OS-13-breaking-changes) announcement for details.
|
||||
|
||||
- [ ] **Recompile for 64-bit time (Y2038)** - AXIS OS 13 switches to a 64-bit time interface. All ACAP apps must be recompiled against the updated SDK. Cameras with incompatible apps installed will roll back the OS upgrade rather than proceed.
|
||||
- [ ] **Sign the ACAP via the Axis ACAP Portal** - AXIS OS 13 removes the ability to install unsigned applications in production environments. The app must be submitted and signed through the official Axis ACAP Portal to remain installable.
|
||||
- [ ] **Migrate to Manifest Schema v2** - The `manifest.json` must use Manifest Schema v2, including an explicit declaration of compatible AXIS OS versions, to satisfy the new signing and compatibility requirements.
|
||||
- [ ] **Audit for executable stack usage** - Any ACAP compiled with an executable stack must be recompiled to comply with the new security restrictions in AXIS OS 13.
|
||||
- [ ] **Verify web UI works over HTTPS** - AXIS OS 13 enforces HTTPS-only connections by default. The bundled web UI must be tested to confirm it functions correctly under this constraint.
|
||||
|
||||
### General Improvements
|
||||
|
||||
- [x] **Accept DNS from tailnet toggle** - Add an opt-in setting to the settings page that passes `--accept-dns=true` to `tailscale up`. Defaults to off to prevent Tailscale from overriding `resolv.conf` on cameras that don't need MagicDNS.
|
||||
- [x] **Accept routes toggle** - Add an opt-in setting that passes `--accept-routes=true` to `tailscale up`, allowing the camera to use subnet routes advertised by other nodes in the tailnet.
|
||||
- [ ] **Switch to tiny-tailscale binaries** - Evaluate replacing the bundled `tailscale` and `tailscaled` binaries with [tiny-tailscale](https://github.com/iamromulan/tiny-tailscale) builds. These combine both into a single binary, strip unused features, and are significantly smaller (~43% reduction), reducing install size and memory footprint across all architectures.
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date)
|
||||
|
||||
@@ -18,6 +18,8 @@ CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
CONF_HTTP="8080"
|
||||
CONF_SOCKS="1080"
|
||||
ACCEPT_DNS="false"
|
||||
ACCEPT_ROUTES="false"
|
||||
|
||||
if [ -f "$STATE_DIR/params.conf" ]; then
|
||||
. "$STATE_DIR/params.conf"
|
||||
@@ -56,7 +58,7 @@ TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
@@ -66,11 +68,74 @@ if [ -n "$AUTH_KEY" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
eval $TAILSCALE_CMD
|
||||
UP_EXIT=$?
|
||||
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
|
||||
fi
|
||||
|
||||
# Run `tailscale up` in the background and act on its outcome. If the node needs
|
||||
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
|
||||
# ensures the status publisher below keeps running so the UI can surface the
|
||||
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
|
||||
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
|
||||
# code is captured directly. We must NOT background `up` separately and `wait`
|
||||
# for it from here, because in POSIX sh `wait` only works on children of the
|
||||
# current shell — a subshell waiting on the parent's child returns 127.
|
||||
{
|
||||
eval "$TAILSCALE_CMD"
|
||||
up_exit=$?
|
||||
if [ "$up_exit" -eq 0 ]; then
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
: > "$STATE_DIR/authkey_clear"
|
||||
fi
|
||||
else
|
||||
logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)"
|
||||
fi
|
||||
} &
|
||||
TAILSCALE_UP_PID=$!
|
||||
|
||||
logger -t "Tailscale_VPN" "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"
|
||||
|
||||
# Publish tailscale's real backend state as JSON for the web UI to consume.
|
||||
# This is the authoritative connection signal (BackendState / Self.Online /
|
||||
# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at
|
||||
# /local/Tailscale_VPN/status.json.
|
||||
STATUS_FILE="$APP_DIR/html/status.json"
|
||||
|
||||
publish_status() {
|
||||
if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then
|
||||
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
|
||||
chmod 644 "$STATUS_FILE" 2>/dev/null
|
||||
else
|
||||
rm -f "$STATUS_FILE.tmp" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
status_loop() {
|
||||
while true; do
|
||||
publish_status
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
status_loop &
|
||||
STATUS_LOOP_PID=$!
|
||||
|
||||
# Clean up the status writer, up watcher, daemon and published status on
|
||||
# stop/restart so param_bridge (which signals this script) leaves no orphans or
|
||||
# stale state.
|
||||
cleanup() {
|
||||
[ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait $TAILSCALED_PID
|
||||
|
||||
+125
-26
@@ -272,6 +272,23 @@
|
||||
.save-status.ok { color: var(--green); }
|
||||
.save-status.err { color: var(--red); }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.toggle-slider {
|
||||
position: absolute; cursor: pointer; inset: 0;
|
||||
background: var(--border); border-radius: 20px; transition: background 0.2s;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
content: ''; position: absolute;
|
||||
height: 14px; width: 14px; left: 3px; bottom: 3px;
|
||||
background: white; border-radius: 50%; transition: transform 0.2s;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--green); }
|
||||
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
|
||||
.toggle-info { flex: 1; }
|
||||
|
||||
/* Refresh indicator */
|
||||
.refresh-bar {
|
||||
display: flex;
|
||||
@@ -407,24 +424,44 @@
|
||||
<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)">
|
||||
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
|
||||
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-authkey">Auth Key</label>
|
||||
<input class="settings-input" id="input-authkey" type="password" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
|
||||
<input class="settings-input" id="input-http-port" type="text" placeholder="8080">
|
||||
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
|
||||
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
|
||||
<input class="settings-input" id="input-socks-port" type="text" placeholder="1080">
|
||||
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
|
||||
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
|
||||
</div>
|
||||
<div class="settings-row toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="input-accept-dns">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="toggle-info">
|
||||
<div class="settings-label">Accept DNS</div>
|
||||
<span class="settings-hint">Pass <code>--accept-dns=true</code> to tailscale up. Allows the tailnet to push DNS settings to this device. Off by default to avoid overriding the camera's DNS configuration.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="input-accept-routes">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="toggle-info">
|
||||
<div class="settings-label">Accept Routes</div>
|
||||
<span class="settings-hint">Pass <code>--accept-routes=true</code> to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<span class="save-status" id="save-status"></span>
|
||||
<button class="save-btn" id="save-btn">Save & Restart</button>
|
||||
@@ -453,6 +490,7 @@
|
||||
(function() {
|
||||
var APP = 'Tailscale_VPN';
|
||||
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
|
||||
var STATUS_URL = 'status.json';
|
||||
var logBox = document.getElementById('log-box');
|
||||
var autoScroll = true;
|
||||
|
||||
@@ -700,29 +738,82 @@
|
||||
.catch(function() { return false; });
|
||||
}
|
||||
|
||||
// Ground truth published by the run script from `tailscale status --json`.
|
||||
function fetchStatus() {
|
||||
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.catch(function() { return null; });
|
||||
}
|
||||
|
||||
// Apply Tailscale's authoritative backend state onto the result object.
|
||||
function applyStatus(result, st) {
|
||||
var self = st.Self || {};
|
||||
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
|
||||
var ip4 = null;
|
||||
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
|
||||
var bs = st.BackendState;
|
||||
|
||||
if (st.Version) result.version = String(st.Version).split('-')[0];
|
||||
|
||||
if (bs === 'Running' && self.Online === true) {
|
||||
// Genuinely connected and reachable on the tailnet
|
||||
result.state = 'connected';
|
||||
result.url = null;
|
||||
result.ip = ip4 || result.ip;
|
||||
result.node = self.HostName || result.node;
|
||||
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
|
||||
cacheSet('ip', result.ip); cacheSet('node', result.node);
|
||||
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
|
||||
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Running') {
|
||||
// Backend running but node not online: either a transient network
|
||||
// drop (no action needed) or the node was removed/expired and needs
|
||||
// re-auth. Not connected. Keep any login URL the log parser found
|
||||
// (status.json's AuthURL lags during the `tailscale up` re-auth
|
||||
// window) so the login button still appears when re-auth is needed.
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Stopped') {
|
||||
result.state = 'disconnected';
|
||||
result.url = null;
|
||||
} else {
|
||||
// NoState / Starting / unknown
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
Promise.all([
|
||||
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.catch(function() { return ''; }),
|
||||
fetchStatus()
|
||||
]).then(function(arr) {
|
||||
var txt = arr[0];
|
||||
var st = arr[1];
|
||||
var result = parse(txt || '');
|
||||
if (txt) renderLogs(txt);
|
||||
// Verify the app is actually running - status.json can be stale if stopped
|
||||
checkAppRunning().then(function(running) {
|
||||
if (!running) {
|
||||
result.state = 'disconnected';
|
||||
} else if (st && st.BackendState) {
|
||||
// Authoritative: Tailscale's own backend state
|
||||
applyStatus(result, st);
|
||||
} else if (!result.url && result.state !== 'connected') {
|
||||
// Fallback to log heuristic when status.json is unavailable
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
@@ -779,6 +870,8 @@
|
||||
var authInput = document.getElementById('input-authkey');
|
||||
var httpPortInput = document.getElementById('input-http-port');
|
||||
var socksPortInput= document.getElementById('input-socks-port');
|
||||
var acceptDnsInput = document.getElementById('input-accept-dns');
|
||||
var acceptRoutesInput = document.getElementById('input-accept-routes');
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
var saveStatus = document.getElementById('save-status');
|
||||
|
||||
@@ -790,10 +883,14 @@
|
||||
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
|
||||
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
|
||||
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
|
||||
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
|
||||
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
|
||||
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();
|
||||
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
|
||||
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
|
||||
// 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;
|
||||
@@ -819,7 +916,9 @@
|
||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
|
||||
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
|
||||
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
|
||||
fetch(PARAM_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -8,7 +8,7 @@
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4",
|
||||
"version": "1.98.4",
|
||||
"architecture": "aarch64"
|
||||
},
|
||||
"configuration": {
|
||||
@@ -33,6 +33,16 @@
|
||||
"name": "Socks5Port",
|
||||
"default": "1080",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AcceptDNS",
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AcceptRoutes",
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
static pid_t child_pid = -1;
|
||||
@@ -43,6 +44,8 @@ static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_http_proxy_port = NULL;
|
||||
static char *cfg_socks5_port = NULL;
|
||||
static char *cfg_accept_dns = NULL;
|
||||
static char *cfg_accept_routes = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
@@ -118,6 +121,31 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── auth-key sentinel ───────────────────────────────────────────────────── */
|
||||
|
||||
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
|
||||
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
|
||||
* disappears from the settings UI. This replaces the old exit-code-0 path,
|
||||
* which never fired because tailscaled keeps the child alive indefinitely. */
|
||||
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (access(SENTINEL_FILE, F_OK) != 0)
|
||||
return G_SOURCE_CONTINUE;
|
||||
|
||||
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
unlink(SENTINEL_FILE);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── config file ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
@@ -139,6 +167,8 @@ static void load_config_cache(AXParameter *handle) {
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("HttpProxyPort", cfg_http_proxy_port)
|
||||
LOAD("Socks5Port", cfg_socks5_port)
|
||||
LOAD("AcceptDNS", cfg_accept_dns)
|
||||
LOAD("AcceptRoutes", cfg_accept_routes)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
@@ -153,6 +183,8 @@ static void write_config_file(void) {
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
||||
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
|
||||
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
||||
@@ -185,6 +217,8 @@ static void parameter_changed(const gchar *name, const gchar *value,
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
|
||||
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
|
||||
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
|
||||
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
@@ -211,6 +245,10 @@ int main(void) {
|
||||
/* Ensure localdata dir exists */
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
|
||||
* freshly configured key before it has been used. */
|
||||
unlink(SENTINEL_FILE);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
@@ -225,7 +263,8 @@ int main(void) {
|
||||
start_child();
|
||||
|
||||
const char *params[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
|
||||
"AcceptDNS", "AcceptRoutes"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
@@ -240,6 +279,7 @@ int main(void) {
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
@@ -15,6 +15,8 @@ chmod 755 $TAILSCALE_PATH
|
||||
|
||||
CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
ACCEPT_DNS="false"
|
||||
ACCEPT_ROUTES="false"
|
||||
|
||||
if [ -f "$STATE_DIR/params.conf" ]; then
|
||||
. "$STATE_DIR/params.conf"
|
||||
@@ -30,7 +32,7 @@ TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)"
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
@@ -40,9 +42,71 @@ if [ -n "$AUTH_KEY" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
eval $TAILSCALE_CMD
|
||||
UP_EXIT=$?
|
||||
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
|
||||
fi
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
|
||||
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
|
||||
fi
|
||||
|
||||
# Run `tailscale up` in the background and act on its outcome. If the node needs
|
||||
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
|
||||
# ensures the status publisher below keeps running so the UI can surface the
|
||||
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
|
||||
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
|
||||
# code is captured directly. We must NOT background `up` separately and `wait`
|
||||
# for it from here, because in POSIX sh `wait` only works on children of the
|
||||
# current shell — a subshell waiting on the parent's child returns 127.
|
||||
{
|
||||
eval "$TAILSCALE_CMD"
|
||||
up_exit=$?
|
||||
if [ "$up_exit" -eq 0 ]; then
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
|
||||
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
: > "$STATE_DIR/authkey_clear"
|
||||
fi
|
||||
else
|
||||
logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)"
|
||||
fi
|
||||
} &
|
||||
TAILSCALE_UP_PID=$!
|
||||
|
||||
# Publish tailscale's real backend state as JSON for the web UI to consume.
|
||||
# This is the authoritative connection signal (BackendState / Self.Online /
|
||||
# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at
|
||||
# /local/Tailscale_VPN/status.json.
|
||||
STATUS_FILE="$APP_DIR/html/status.json"
|
||||
|
||||
publish_status() {
|
||||
if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then
|
||||
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
|
||||
chmod 644 "$STATUS_FILE" 2>/dev/null
|
||||
else
|
||||
rm -f "$STATUS_FILE.tmp" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
status_loop() {
|
||||
while true; do
|
||||
publish_status
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
status_loop &
|
||||
STATUS_LOOP_PID=$!
|
||||
|
||||
# Clean up the status writer, up watcher, daemon and published status on
|
||||
# stop/restart so param_bridge (which signals this script) leaves no orphans or
|
||||
# stale state.
|
||||
cleanup() {
|
||||
[ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait $TAILSCALED_PID
|
||||
|
||||
@@ -272,6 +272,23 @@
|
||||
.save-status.ok { color: var(--green); }
|
||||
.save-status.err { color: var(--red); }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.toggle-slider {
|
||||
position: absolute; cursor: pointer; inset: 0;
|
||||
background: var(--border); border-radius: 20px; transition: background 0.2s;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
content: ''; position: absolute;
|
||||
height: 14px; width: 14px; left: 3px; bottom: 3px;
|
||||
background: white; border-radius: 50%; transition: transform 0.2s;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--green); }
|
||||
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
|
||||
.toggle-info { flex: 1; }
|
||||
|
||||
/* Refresh indicator */
|
||||
.refresh-bar {
|
||||
display: flex;
|
||||
@@ -407,24 +424,44 @@
|
||||
<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)">
|
||||
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
|
||||
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-authkey">Auth Key</label>
|
||||
<input class="settings-input" id="input-authkey" type="password" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
|
||||
<input class="settings-input" id="input-http-port" type="text" placeholder="8080">
|
||||
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
|
||||
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
|
||||
<input class="settings-input" id="input-socks-port" type="text" placeholder="1080">
|
||||
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
|
||||
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
|
||||
</div>
|
||||
<div class="settings-row toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="input-accept-dns">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="toggle-info">
|
||||
<div class="settings-label">Accept DNS</div>
|
||||
<span class="settings-hint">Pass <code>--accept-dns=true</code> to tailscale up. Allows the tailnet to push DNS settings to this device. Off by default to avoid overriding the camera's DNS configuration.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="input-accept-routes">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="toggle-info">
|
||||
<div class="settings-label">Accept Routes</div>
|
||||
<span class="settings-hint">Pass <code>--accept-routes=true</code> to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<span class="save-status" id="save-status"></span>
|
||||
<button class="save-btn" id="save-btn">Save & Restart</button>
|
||||
@@ -453,6 +490,7 @@
|
||||
(function() {
|
||||
var APP = 'Tailscale_VPN';
|
||||
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
|
||||
var STATUS_URL = 'status.json';
|
||||
var logBox = document.getElementById('log-box');
|
||||
var autoScroll = true;
|
||||
|
||||
@@ -700,29 +738,82 @@
|
||||
.catch(function() { return false; });
|
||||
}
|
||||
|
||||
// Ground truth published by the run script from `tailscale status --json`.
|
||||
function fetchStatus() {
|
||||
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.catch(function() { return null; });
|
||||
}
|
||||
|
||||
// Apply Tailscale's authoritative backend state onto the result object.
|
||||
function applyStatus(result, st) {
|
||||
var self = st.Self || {};
|
||||
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
|
||||
var ip4 = null;
|
||||
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
|
||||
var bs = st.BackendState;
|
||||
|
||||
if (st.Version) result.version = String(st.Version).split('-')[0];
|
||||
|
||||
if (bs === 'Running' && self.Online === true) {
|
||||
// Genuinely connected and reachable on the tailnet
|
||||
result.state = 'connected';
|
||||
result.url = null;
|
||||
result.ip = ip4 || result.ip;
|
||||
result.node = self.HostName || result.node;
|
||||
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
|
||||
cacheSet('ip', result.ip); cacheSet('node', result.node);
|
||||
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
|
||||
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Running') {
|
||||
// Backend running but node not online: either a transient network
|
||||
// drop (no action needed) or the node was removed/expired and needs
|
||||
// re-auth. Not connected. Keep any login URL the log parser found
|
||||
// (status.json's AuthURL lags during the `tailscale up` re-auth
|
||||
// window) so the login button still appears when re-auth is needed.
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Stopped') {
|
||||
result.state = 'disconnected';
|
||||
result.url = null;
|
||||
} else {
|
||||
// NoState / Starting / unknown
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
Promise.all([
|
||||
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.catch(function() { return ''; }),
|
||||
fetchStatus()
|
||||
]).then(function(arr) {
|
||||
var txt = arr[0];
|
||||
var st = arr[1];
|
||||
var result = parse(txt || '');
|
||||
if (txt) renderLogs(txt);
|
||||
// Verify the app is actually running - status.json can be stale if stopped
|
||||
checkAppRunning().then(function(running) {
|
||||
if (!running) {
|
||||
result.state = 'disconnected';
|
||||
} else if (st && st.BackendState) {
|
||||
// Authoritative: Tailscale's own backend state
|
||||
applyStatus(result, st);
|
||||
} else if (!result.url && result.state !== 'connected') {
|
||||
// Fallback to log heuristic when status.json is unavailable
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
@@ -779,6 +870,8 @@
|
||||
var authInput = document.getElementById('input-authkey');
|
||||
var httpPortInput = document.getElementById('input-http-port');
|
||||
var socksPortInput= document.getElementById('input-socks-port');
|
||||
var acceptDnsInput = document.getElementById('input-accept-dns');
|
||||
var acceptRoutesInput = document.getElementById('input-accept-routes');
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
var saveStatus = document.getElementById('save-status');
|
||||
|
||||
@@ -790,10 +883,14 @@
|
||||
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
|
||||
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
|
||||
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
|
||||
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
|
||||
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
|
||||
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();
|
||||
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
|
||||
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
|
||||
// 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;
|
||||
@@ -819,7 +916,9 @@
|
||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
|
||||
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
|
||||
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
|
||||
fetch(PARAM_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4",
|
||||
"version": "1.98.4",
|
||||
"architecture": "aarch64"
|
||||
},
|
||||
"configuration": {
|
||||
@@ -27,6 +27,16 @@
|
||||
"name": "AuthKey",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AcceptDNS",
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AcceptRoutes",
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
static pid_t child_pid = -1;
|
||||
@@ -31,6 +32,8 @@ static guint reload_timer_id = 0;
|
||||
|
||||
static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_accept_dns = NULL;
|
||||
static char *cfg_accept_routes = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
@@ -102,6 +105,29 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
|
||||
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
|
||||
* disappears from the settings UI. This replaces the old exit-code-0 path,
|
||||
* which never fired because tailscaled keeps the child alive indefinitely. */
|
||||
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (access(SENTINEL_FILE, F_OK) != 0)
|
||||
return G_SOURCE_CONTINUE;
|
||||
|
||||
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
unlink(SENTINEL_FILE);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
@@ -119,6 +145,8 @@ static void load_config_cache(AXParameter *handle) {
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("AcceptDNS", cfg_accept_dns)
|
||||
LOAD("AcceptRoutes", cfg_accept_routes)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
@@ -131,6 +159,8 @@ static void write_config_file(void) {
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
|
||||
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: server=%s",
|
||||
@@ -156,6 +186,8 @@ static void parameter_changed(const gchar *name, const gchar *value,
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
|
||||
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
@@ -177,6 +209,10 @@ int main(void) {
|
||||
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
|
||||
* freshly configured key before it has been used. */
|
||||
unlink(SENTINEL_FILE);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
@@ -190,7 +226,7 @@ int main(void) {
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = { "CustomServer", "AuthKey" };
|
||||
const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes" };
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
@@ -204,6 +240,7 @@ int main(void) {
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
@@ -18,6 +18,8 @@ CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
CONF_HTTP="8080"
|
||||
CONF_SOCKS="1080"
|
||||
ACCEPT_DNS="false"
|
||||
ACCEPT_ROUTES="false"
|
||||
|
||||
if [ -f "$STATE_DIR/params.conf" ]; then
|
||||
. "$STATE_DIR/params.conf"
|
||||
@@ -56,7 +58,7 @@ TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
@@ -66,11 +68,74 @@ if [ -n "$AUTH_KEY" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
eval $TAILSCALE_CMD
|
||||
UP_EXIT=$?
|
||||
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
|
||||
fi
|
||||
|
||||
# Run `tailscale up` in the background and act on its outcome. If the node needs
|
||||
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
|
||||
# ensures the status publisher below keeps running so the UI can surface the
|
||||
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
|
||||
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
|
||||
# code is captured directly. We must NOT background `up` separately and `wait`
|
||||
# for it from here, because in POSIX sh `wait` only works on children of the
|
||||
# current shell — a subshell waiting on the parent's child returns 127.
|
||||
{
|
||||
eval "$TAILSCALE_CMD"
|
||||
up_exit=$?
|
||||
if [ "$up_exit" -eq 0 ]; then
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
: > "$STATE_DIR/authkey_clear"
|
||||
fi
|
||||
else
|
||||
logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)"
|
||||
fi
|
||||
} &
|
||||
TAILSCALE_UP_PID=$!
|
||||
|
||||
logger -t "Tailscale_VPN" "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"
|
||||
|
||||
# Publish tailscale's real backend state as JSON for the web UI to consume.
|
||||
# This is the authoritative connection signal (BackendState / Self.Online /
|
||||
# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at
|
||||
# /local/Tailscale_VPN/status.json.
|
||||
STATUS_FILE="$APP_DIR/html/status.json"
|
||||
|
||||
publish_status() {
|
||||
if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then
|
||||
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
|
||||
chmod 644 "$STATUS_FILE" 2>/dev/null
|
||||
else
|
||||
rm -f "$STATUS_FILE.tmp" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
status_loop() {
|
||||
while true; do
|
||||
publish_status
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
status_loop &
|
||||
STATUS_LOOP_PID=$!
|
||||
|
||||
# Clean up the status writer, up watcher, daemon and published status on
|
||||
# stop/restart so param_bridge (which signals this script) leaves no orphans or
|
||||
# stale state.
|
||||
cleanup() {
|
||||
[ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait $TAILSCALED_PID
|
||||
|
||||
+125
-26
@@ -272,6 +272,23 @@
|
||||
.save-status.ok { color: var(--green); }
|
||||
.save-status.err { color: var(--red); }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.toggle-slider {
|
||||
position: absolute; cursor: pointer; inset: 0;
|
||||
background: var(--border); border-radius: 20px; transition: background 0.2s;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
content: ''; position: absolute;
|
||||
height: 14px; width: 14px; left: 3px; bottom: 3px;
|
||||
background: white; border-radius: 50%; transition: transform 0.2s;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--green); }
|
||||
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
|
||||
.toggle-info { flex: 1; }
|
||||
|
||||
/* Refresh indicator */
|
||||
.refresh-bar {
|
||||
display: flex;
|
||||
@@ -407,24 +424,44 @@
|
||||
<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)">
|
||||
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
|
||||
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-authkey">Auth Key</label>
|
||||
<input class="settings-input" id="input-authkey" type="password" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
|
||||
<input class="settings-input" id="input-http-port" type="text" placeholder="8080">
|
||||
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
|
||||
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
|
||||
<input class="settings-input" id="input-socks-port" type="text" placeholder="1080">
|
||||
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
|
||||
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
|
||||
</div>
|
||||
<div class="settings-row toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="input-accept-dns">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="toggle-info">
|
||||
<div class="settings-label">Accept DNS</div>
|
||||
<span class="settings-hint">Pass <code>--accept-dns=true</code> to tailscale up. Allows the tailnet to push DNS settings to this device. Off by default to avoid overriding the camera's DNS configuration.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="input-accept-routes">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="toggle-info">
|
||||
<div class="settings-label">Accept Routes</div>
|
||||
<span class="settings-hint">Pass <code>--accept-routes=true</code> to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<span class="save-status" id="save-status"></span>
|
||||
<button class="save-btn" id="save-btn">Save & Restart</button>
|
||||
@@ -453,6 +490,7 @@
|
||||
(function() {
|
||||
var APP = 'Tailscale_VPN';
|
||||
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
|
||||
var STATUS_URL = 'status.json';
|
||||
var logBox = document.getElementById('log-box');
|
||||
var autoScroll = true;
|
||||
|
||||
@@ -700,29 +738,82 @@
|
||||
.catch(function() { return false; });
|
||||
}
|
||||
|
||||
// Ground truth published by the run script from `tailscale status --json`.
|
||||
function fetchStatus() {
|
||||
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.catch(function() { return null; });
|
||||
}
|
||||
|
||||
// Apply Tailscale's authoritative backend state onto the result object.
|
||||
function applyStatus(result, st) {
|
||||
var self = st.Self || {};
|
||||
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
|
||||
var ip4 = null;
|
||||
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
|
||||
var bs = st.BackendState;
|
||||
|
||||
if (st.Version) result.version = String(st.Version).split('-')[0];
|
||||
|
||||
if (bs === 'Running' && self.Online === true) {
|
||||
// Genuinely connected and reachable on the tailnet
|
||||
result.state = 'connected';
|
||||
result.url = null;
|
||||
result.ip = ip4 || result.ip;
|
||||
result.node = self.HostName || result.node;
|
||||
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
|
||||
cacheSet('ip', result.ip); cacheSet('node', result.node);
|
||||
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
|
||||
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Running') {
|
||||
// Backend running but node not online: either a transient network
|
||||
// drop (no action needed) or the node was removed/expired and needs
|
||||
// re-auth. Not connected. Keep any login URL the log parser found
|
||||
// (status.json's AuthURL lags during the `tailscale up` re-auth
|
||||
// window) so the login button still appears when re-auth is needed.
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Stopped') {
|
||||
result.state = 'disconnected';
|
||||
result.url = null;
|
||||
} else {
|
||||
// NoState / Starting / unknown
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
Promise.all([
|
||||
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.catch(function() { return ''; }),
|
||||
fetchStatus()
|
||||
]).then(function(arr) {
|
||||
var txt = arr[0];
|
||||
var st = arr[1];
|
||||
var result = parse(txt || '');
|
||||
if (txt) renderLogs(txt);
|
||||
// Verify the app is actually running - status.json can be stale if stopped
|
||||
checkAppRunning().then(function(running) {
|
||||
if (!running) {
|
||||
result.state = 'disconnected';
|
||||
} else if (st && st.BackendState) {
|
||||
// Authoritative: Tailscale's own backend state
|
||||
applyStatus(result, st);
|
||||
} else if (!result.url && result.state !== 'connected') {
|
||||
// Fallback to log heuristic when status.json is unavailable
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
@@ -779,6 +870,8 @@
|
||||
var authInput = document.getElementById('input-authkey');
|
||||
var httpPortInput = document.getElementById('input-http-port');
|
||||
var socksPortInput= document.getElementById('input-socks-port');
|
||||
var acceptDnsInput = document.getElementById('input-accept-dns');
|
||||
var acceptRoutesInput = document.getElementById('input-accept-routes');
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
var saveStatus = document.getElementById('save-status');
|
||||
|
||||
@@ -790,10 +883,14 @@
|
||||
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
|
||||
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
|
||||
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
|
||||
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
|
||||
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
|
||||
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();
|
||||
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
|
||||
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
|
||||
// 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;
|
||||
@@ -819,7 +916,9 @@
|
||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
|
||||
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
|
||||
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
|
||||
fetch(PARAM_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+11
-1
@@ -8,7 +8,7 @@
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4",
|
||||
"version": "1.98.4",
|
||||
"architecture": "armv7hf"
|
||||
},
|
||||
"configuration": {
|
||||
@@ -33,6 +33,16 @@
|
||||
"name": "Socks5Port",
|
||||
"default": "1080",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AcceptDNS",
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AcceptRoutes",
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+45
-5
@@ -31,18 +31,21 @@
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
static pid_t child_pid = -1;
|
||||
static guint reload_timer_id = 0;
|
||||
static 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 char *cfg_accept_dns = NULL;
|
||||
static char *cfg_accept_routes = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
@@ -118,6 +121,31 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── auth-key sentinel ───────────────────────────────────────────────────── */
|
||||
|
||||
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
|
||||
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
|
||||
* disappears from the settings UI. This replaces the old exit-code-0 path,
|
||||
* which never fired because tailscaled keeps the child alive indefinitely. */
|
||||
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (access(SENTINEL_FILE, F_OK) != 0)
|
||||
return G_SOURCE_CONTINUE;
|
||||
|
||||
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
unlink(SENTINEL_FILE);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── config file ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
@@ -139,6 +167,8 @@ static void load_config_cache(AXParameter *handle) {
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("HttpProxyPort", cfg_http_proxy_port)
|
||||
LOAD("Socks5Port", cfg_socks5_port)
|
||||
LOAD("AcceptDNS", cfg_accept_dns)
|
||||
LOAD("AcceptRoutes", cfg_accept_routes)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
@@ -153,6 +183,8 @@ static void write_config_file(void) {
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
||||
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
|
||||
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
||||
@@ -185,6 +217,8 @@ static void parameter_changed(const gchar *name, const gchar *value,
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
|
||||
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
|
||||
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
|
||||
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
@@ -211,6 +245,10 @@ int main(void) {
|
||||
/* Ensure localdata dir exists */
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
|
||||
* freshly configured key before it has been used. */
|
||||
unlink(SENTINEL_FILE);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
@@ -225,7 +263,8 @@ int main(void) {
|
||||
start_child();
|
||||
|
||||
const char *params[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
|
||||
"AcceptDNS", "AcceptRoutes"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
@@ -240,6 +279,7 @@ int main(void) {
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
@@ -15,6 +15,8 @@ chmod 755 $TAILSCALE_PATH
|
||||
|
||||
CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
ACCEPT_DNS="false"
|
||||
ACCEPT_ROUTES="false"
|
||||
|
||||
if [ -f "$STATE_DIR/params.conf" ]; then
|
||||
. "$STATE_DIR/params.conf"
|
||||
@@ -30,7 +32,7 @@ TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)"
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
@@ -40,9 +42,71 @@ if [ -n "$AUTH_KEY" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
eval $TAILSCALE_CMD
|
||||
UP_EXIT=$?
|
||||
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
|
||||
fi
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
|
||||
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
|
||||
fi
|
||||
|
||||
# Run `tailscale up` in the background and act on its outcome. If the node needs
|
||||
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
|
||||
# ensures the status publisher below keeps running so the UI can surface the
|
||||
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
|
||||
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
|
||||
# code is captured directly. We must NOT background `up` separately and `wait`
|
||||
# for it from here, because in POSIX sh `wait` only works on children of the
|
||||
# current shell — a subshell waiting on the parent's child returns 127.
|
||||
{
|
||||
eval "$TAILSCALE_CMD"
|
||||
up_exit=$?
|
||||
if [ "$up_exit" -eq 0 ]; then
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
|
||||
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
: > "$STATE_DIR/authkey_clear"
|
||||
fi
|
||||
else
|
||||
logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)"
|
||||
fi
|
||||
} &
|
||||
TAILSCALE_UP_PID=$!
|
||||
|
||||
# Publish tailscale's real backend state as JSON for the web UI to consume.
|
||||
# This is the authoritative connection signal (BackendState / Self.Online /
|
||||
# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at
|
||||
# /local/Tailscale_VPN/status.json.
|
||||
STATUS_FILE="$APP_DIR/html/status.json"
|
||||
|
||||
publish_status() {
|
||||
if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then
|
||||
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
|
||||
chmod 644 "$STATUS_FILE" 2>/dev/null
|
||||
else
|
||||
rm -f "$STATUS_FILE.tmp" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
status_loop() {
|
||||
while true; do
|
||||
publish_status
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
status_loop &
|
||||
STATUS_LOOP_PID=$!
|
||||
|
||||
# Clean up the status writer, up watcher, daemon and published status on
|
||||
# stop/restart so param_bridge (which signals this script) leaves no orphans or
|
||||
# stale state.
|
||||
cleanup() {
|
||||
[ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait $TAILSCALED_PID
|
||||
|
||||
+125
-26
@@ -272,6 +272,23 @@
|
||||
.save-status.ok { color: var(--green); }
|
||||
.save-status.err { color: var(--red); }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.toggle-slider {
|
||||
position: absolute; cursor: pointer; inset: 0;
|
||||
background: var(--border); border-radius: 20px; transition: background 0.2s;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
content: ''; position: absolute;
|
||||
height: 14px; width: 14px; left: 3px; bottom: 3px;
|
||||
background: white; border-radius: 50%; transition: transform 0.2s;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--green); }
|
||||
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
|
||||
.toggle-info { flex: 1; }
|
||||
|
||||
/* Refresh indicator */
|
||||
.refresh-bar {
|
||||
display: flex;
|
||||
@@ -407,24 +424,44 @@
|
||||
<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)">
|
||||
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
|
||||
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-authkey">Auth Key</label>
|
||||
<input class="settings-input" id="input-authkey" type="password" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
|
||||
<input class="settings-input" id="input-http-port" type="text" placeholder="8080">
|
||||
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
|
||||
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
|
||||
<input class="settings-input" id="input-socks-port" type="text" placeholder="1080">
|
||||
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
|
||||
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
|
||||
</div>
|
||||
<div class="settings-row toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="input-accept-dns">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="toggle-info">
|
||||
<div class="settings-label">Accept DNS</div>
|
||||
<span class="settings-hint">Pass <code>--accept-dns=true</code> to tailscale up. Allows the tailnet to push DNS settings to this device. Off by default to avoid overriding the camera's DNS configuration.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="input-accept-routes">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="toggle-info">
|
||||
<div class="settings-label">Accept Routes</div>
|
||||
<span class="settings-hint">Pass <code>--accept-routes=true</code> to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<span class="save-status" id="save-status"></span>
|
||||
<button class="save-btn" id="save-btn">Save & Restart</button>
|
||||
@@ -453,6 +490,7 @@
|
||||
(function() {
|
||||
var APP = 'Tailscale_VPN';
|
||||
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
|
||||
var STATUS_URL = 'status.json';
|
||||
var logBox = document.getElementById('log-box');
|
||||
var autoScroll = true;
|
||||
|
||||
@@ -700,29 +738,82 @@
|
||||
.catch(function() { return false; });
|
||||
}
|
||||
|
||||
// Ground truth published by the run script from `tailscale status --json`.
|
||||
function fetchStatus() {
|
||||
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.catch(function() { return null; });
|
||||
}
|
||||
|
||||
// Apply Tailscale's authoritative backend state onto the result object.
|
||||
function applyStatus(result, st) {
|
||||
var self = st.Self || {};
|
||||
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
|
||||
var ip4 = null;
|
||||
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
|
||||
var bs = st.BackendState;
|
||||
|
||||
if (st.Version) result.version = String(st.Version).split('-')[0];
|
||||
|
||||
if (bs === 'Running' && self.Online === true) {
|
||||
// Genuinely connected and reachable on the tailnet
|
||||
result.state = 'connected';
|
||||
result.url = null;
|
||||
result.ip = ip4 || result.ip;
|
||||
result.node = self.HostName || result.node;
|
||||
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
|
||||
cacheSet('ip', result.ip); cacheSet('node', result.node);
|
||||
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
|
||||
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Running') {
|
||||
// Backend running but node not online: either a transient network
|
||||
// drop (no action needed) or the node was removed/expired and needs
|
||||
// re-auth. Not connected. Keep any login URL the log parser found
|
||||
// (status.json's AuthURL lags during the `tailscale up` re-auth
|
||||
// window) so the login button still appears when re-auth is needed.
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Stopped') {
|
||||
result.state = 'disconnected';
|
||||
result.url = null;
|
||||
} else {
|
||||
// NoState / Starting / unknown
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
Promise.all([
|
||||
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.catch(function() { return ''; }),
|
||||
fetchStatus()
|
||||
]).then(function(arr) {
|
||||
var txt = arr[0];
|
||||
var st = arr[1];
|
||||
var result = parse(txt || '');
|
||||
if (txt) renderLogs(txt);
|
||||
// Verify the app is actually running - status.json can be stale if stopped
|
||||
checkAppRunning().then(function(running) {
|
||||
if (!running) {
|
||||
result.state = 'disconnected';
|
||||
} else if (st && st.BackendState) {
|
||||
// Authoritative: Tailscale's own backend state
|
||||
applyStatus(result, st);
|
||||
} else if (!result.url && result.state !== 'connected') {
|
||||
// Fallback to log heuristic when status.json is unavailable
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
@@ -779,6 +870,8 @@
|
||||
var authInput = document.getElementById('input-authkey');
|
||||
var httpPortInput = document.getElementById('input-http-port');
|
||||
var socksPortInput= document.getElementById('input-socks-port');
|
||||
var acceptDnsInput = document.getElementById('input-accept-dns');
|
||||
var acceptRoutesInput = document.getElementById('input-accept-routes');
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
var saveStatus = document.getElementById('save-status');
|
||||
|
||||
@@ -790,10 +883,14 @@
|
||||
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
|
||||
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
|
||||
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
|
||||
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
|
||||
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
|
||||
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();
|
||||
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
|
||||
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
|
||||
// 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;
|
||||
@@ -819,7 +916,9 @@
|
||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
|
||||
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
|
||||
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false');
|
||||
fetch(PARAM_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4",
|
||||
"version": "1.98.4",
|
||||
"architecture": "armv7hf"
|
||||
},
|
||||
"configuration": {
|
||||
@@ -27,6 +27,16 @@
|
||||
"name": "AuthKey",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AcceptDNS",
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AcceptRoutes",
|
||||
"default": "false",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,16 +21,19 @@
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
#define APP_NAME "Tailscale_VPN"
|
||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
||||
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
static pid_t child_pid = -1;
|
||||
static guint reload_timer_id = 0;
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
|
||||
static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_accept_dns = NULL;
|
||||
static char *cfg_accept_routes = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
@@ -102,6 +105,29 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
|
||||
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
|
||||
* disappears from the settings UI. This replaces the old exit-code-0 path,
|
||||
* which never fired because tailscaled keeps the child alive indefinitely. */
|
||||
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (access(SENTINEL_FILE, F_OK) != 0)
|
||||
return G_SOURCE_CONTINUE;
|
||||
|
||||
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
unlink(SENTINEL_FILE);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
@@ -119,6 +145,8 @@ static void load_config_cache(AXParameter *handle) {
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("AcceptDNS", cfg_accept_dns)
|
||||
LOAD("AcceptRoutes", cfg_accept_routes)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
@@ -131,6 +159,8 @@ static void write_config_file(void) {
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
|
||||
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: server=%s",
|
||||
@@ -156,6 +186,8 @@ static void parameter_changed(const gchar *name, const gchar *value,
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
|
||||
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
@@ -177,6 +209,10 @@ int main(void) {
|
||||
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
|
||||
* freshly configured key before it has been used. */
|
||||
unlink(SENTINEL_FILE);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
@@ -190,7 +226,7 @@ int main(void) {
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = { "CustomServer", "AuthKey" };
|
||||
const char *params[] = { "CustomServer", "AuthKey", "AcceptDNS", "AcceptRoutes" };
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
if (!ax_parameter_register_callback(handle, params[i],
|
||||
parameter_changed, handle, &error)) {
|
||||
@@ -204,6 +240,7 @@ int main(void) {
|
||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
||||
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
|
||||
|
||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
||||
g_main_loop_run(loop);
|
||||
|
||||
@@ -30,6 +30,10 @@ RUN cp html/index.html index.html
|
||||
# The log is written at runtime to localdata/ (resolved path at runtime).
|
||||
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log
|
||||
|
||||
# Symlink the runtime status.json (written by start.sh from `tailscale status
|
||||
# --json`) into html/ so the web UI can read Tailscale's authoritative state.
|
||||
RUN ln -sf ../localdata/status.json html/status.json
|
||||
|
||||
# Build and package
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
|
||||
|
||||
|
||||
@@ -55,11 +55,37 @@ logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
|
||||
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
|
||||
|
||||
# Monitoring loop: stay alive while tailscaled is running.
|
||||
# This keeps the parent Tailscale_VPN (C launcher) in the process table
|
||||
# so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
|
||||
# Publish tailscale's real backend state as JSON for the web UI to consume.
|
||||
# This is the authoritative connection signal (BackendState / Self.Online /
|
||||
# TailscaleIPs / AuthURL) instead of scraping logs, which otherwise reports
|
||||
# "connected" whenever the launcher keeps the process alive (e.g. no Internet).
|
||||
# Written to localdata and exposed at html/status.json via a build-time symlink.
|
||||
STATUS_FILE="$STATE_DIR/status.json"
|
||||
|
||||
publish_status() {
|
||||
if "$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" status --json > "$STATUS_FILE.tmp" 2>/dev/null; then
|
||||
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
|
||||
chmod 644 "$STATUS_FILE" 2>/dev/null
|
||||
else
|
||||
rm -f "$STATUS_FILE.tmp" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove stale status on stop so the UI does not show a connected node after exit.
|
||||
cleanup() {
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
# Monitoring loop: stay alive while tailscaled is running and keep the published
|
||||
# status fresh. This keeps the parent Tailscale_VPN (C launcher) in the process
|
||||
# table so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
|
||||
while kill -0 "$TAILSCALED_PID" 2>/dev/null; do
|
||||
publish_status
|
||||
sleep 5
|
||||
done
|
||||
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
|
||||
@@ -612,6 +612,53 @@
|
||||
// ACAP3: also fetch the raw tailscaled.log (symlinked into html/) so the parser
|
||||
// can find IP, version, tailnet and Running state from tailscaled's own output.
|
||||
var DAEMON_LOG_URL = 'tailscaled.log';
|
||||
// Authoritative backend state published by start.sh (symlinked into html/).
|
||||
var STATUS_URL = 'status.json';
|
||||
|
||||
// Ground truth published by start.sh from `tailscale status --json`.
|
||||
function fetchStatus() {
|
||||
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.catch(function() { return null; });
|
||||
}
|
||||
|
||||
// Apply Tailscale's authoritative backend state onto the result object.
|
||||
function applyStatus(result, st) {
|
||||
var self = st.Self || {};
|
||||
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
|
||||
var ip4 = null;
|
||||
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
|
||||
var bs = st.BackendState;
|
||||
|
||||
if (st.Version) result.version = String(st.Version).split('-')[0];
|
||||
|
||||
if (bs === 'Running' && self.Online === true) {
|
||||
// Genuinely connected and reachable on the tailnet
|
||||
result.state = 'connected';
|
||||
result.url = null;
|
||||
result.ip = ip4 || result.ip;
|
||||
result.node = self.HostName || result.node;
|
||||
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
|
||||
cacheSet('ip', result.ip); cacheSet('node', result.node);
|
||||
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
|
||||
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Running') {
|
||||
// Backend running but node not online: transient network drop or the
|
||||
// node was removed/expired and needs re-auth. Not connected. Keep any
|
||||
// login URL the log parser found so the login button still appears.
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
} else if (bs === 'Stopped') {
|
||||
result.state = 'disconnected';
|
||||
result.url = null;
|
||||
} else {
|
||||
// NoState / Starting / unknown
|
||||
result.state = 'connecting';
|
||||
result.url = st.AuthURL || result.url;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
@@ -619,17 +666,22 @@
|
||||
var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); }).catch(function() { return ''; });
|
||||
|
||||
Promise.all([syslogFetch, daemonFetch]).then(function(res) {
|
||||
Promise.all([syslogFetch, daemonFetch, fetchStatus()]).then(function(res) {
|
||||
// Syslog provides Axis timestamp headers (node name) and start/stop events.
|
||||
// tailscaled.log provides IP, version, tailnet, and -> Running state.
|
||||
var txt = res[0] + '\n' + res[1];
|
||||
var st = res[2];
|
||||
var result = parse(txt);
|
||||
renderLogs(res[0]); // show syslog in log panel; daemon log is too verbose
|
||||
// Always verify with the app status API - syslog can have stale entries
|
||||
// Always verify with the app status API - logs can have stale entries
|
||||
checkAppRunning().then(function(running) {
|
||||
if (!running) {
|
||||
result.state = 'disconnected';
|
||||
} else if (st && st.BackendState) {
|
||||
// Authoritative: Tailscale's own backend state
|
||||
applyStatus(result, st);
|
||||
} else if (!result.url && result.state !== 'connected') {
|
||||
// Fallback to log heuristic when status.json is unavailable
|
||||
result.state = 'connected';
|
||||
result.ip = result.ip || cacheGet('ip');
|
||||
result.node = result.node || cacheGet('node');
|
||||
|
||||
@@ -2,7 +2,7 @@ PACKAGENAME=Tailscale_VPN
|
||||
MENUNAME="Tailscale VPN"
|
||||
VENDOR="Mo3he"
|
||||
APPMAJORVERSION=1
|
||||
APPMINORVERSION=96
|
||||
APPMINORVERSION=98
|
||||
APPMICROVERSION=4
|
||||
APPTYPE=armv7hf
|
||||
APPNAME=Tailscale_VPN
|
||||
|
||||
+55
-39
@@ -3,22 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tailscale VPN for Axis Cameras</title>
|
||||
<meta name="description" content="Install Tailscale VPN directly on your Axis camera. Secure remote access with WireGuard, no extra hardware needed.">
|
||||
<title>Tailscale VPN for Axis Devices</title>
|
||||
<meta name="description" content="Install Tailscale VPN directly on your Axis device. Secure remote access with WireGuard, no extra hardware needed.">
|
||||
|
||||
<!-- Open Graph / LinkedIn -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://mo3he.github.io/Axis_Cam_Tailscale/">
|
||||
<meta property="og:title" content="Tailscale VPN for Axis Cameras">
|
||||
<meta property="og:description" content="Secure remote access to your Axis cameras over WireGuard. No extra hardware, no complex network config - just install and connect.">
|
||||
<meta property="og:title" content="Tailscale VPN for Axis Devices">
|
||||
<meta property="og:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
|
||||
<meta property="og:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
|
||||
<meta property="og:image:width" content="1340">
|
||||
<meta property="og:image:height" content="724">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Tailscale VPN for Axis Cameras">
|
||||
<meta name="twitter:description" content="Secure remote access to your Axis cameras over WireGuard. No extra hardware, no complex network config - just install and connect.">
|
||||
<meta name="twitter:title" content="Tailscale VPN for Axis Devices">
|
||||
<meta name="twitter:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
|
||||
<meta name="twitter:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
|
||||
|
||||
<style>
|
||||
@@ -117,6 +117,11 @@
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.hero h1 span { color: var(--accent); }
|
||||
.hero-word {
|
||||
display: inline-block;
|
||||
color: var(--accent);
|
||||
transition: opacity 0.25s, transform 0.25s;
|
||||
}
|
||||
.hero p {
|
||||
font-size: 1.2rem;
|
||||
color: var(--muted);
|
||||
@@ -223,7 +228,7 @@
|
||||
width: fit-content;
|
||||
}
|
||||
.tag-recommended { background: rgba(52, 211, 153, 0.15); color: var(--green); }
|
||||
.tag-custom { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
|
||||
.tag-acap3 { background: rgba(168, 85, 247, 0.15); color: #a855f7; }
|
||||
.tag-root { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
|
||||
.download-card h3 {
|
||||
font-size: 1.1rem;
|
||||
@@ -334,7 +339,6 @@
|
||||
<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>
|
||||
@@ -345,8 +349,8 @@
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="badge">Open Source · ACAP Package</div>
|
||||
<h1>Tailscale VPN for <span>Axis Cameras</span></h1>
|
||||
<p>Secure remote access to your Axis cameras over WireGuard. No extra hardware, no complex network config - just install and connect.</p>
|
||||
<h1>Tailscale VPN for<br>Axis <span class="hero-word" id="heroWord">Cameras</span></h1>
|
||||
<p>Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.</p>
|
||||
<div class="hero-buttons">
|
||||
<a href="#downloads" class="btn btn-primary">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
@@ -379,12 +383,12 @@
|
||||
<div class="feature-card">
|
||||
<div class="icon">⚡</div>
|
||||
<h3>No Root Required</h3>
|
||||
<p>Runs in user-space networking mode on Axis OS 12+. No need to enable root access on your camera.</p>
|
||||
<p>Runs in user-space networking mode on Axis OS 12+. No need to enable root access on your device.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">📦</div>
|
||||
<h3>Simple EAP Install</h3>
|
||||
<p>Upload the .eap file through your camera's web interface. Start the app and authenticate - done.</p>
|
||||
<p>Upload the .eap file through your device's web interface. Start the app and authenticate - done.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🔄</div>
|
||||
@@ -394,19 +398,19 @@
|
||||
<div class="feature-card">
|
||||
<div class="icon">🌐</div>
|
||||
<h3>Headscale Compatible</h3>
|
||||
<p>The Custom variant supports self-hosted Headscale servers with configurable server URL and auth key.</p>
|
||||
<p>Supports self-hosted Headscale servers with configurable server URL and auth key, built into every variant.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🏗️</div>
|
||||
<h3>ARM & AARCH64</h3>
|
||||
<p>Supports both ARM (armv7hf) and AARCH64 architectures, covering a wide range of Axis camera models.</p>
|
||||
<div class="icon">🔀</div>
|
||||
<h3>Outbound Proxy</h3>
|
||||
<p>Allows the device to route its own outbound traffic through Tailscale via a local HTTP/HTTPS and SOCKS5 proxy.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Downloads -->
|
||||
<section id="downloads" class="downloads">
|
||||
<h2>Download</h2>
|
||||
<p class="subtitle">Pick the right variant for your camera and Axis OS version.</p>
|
||||
<p class="subtitle">Pick the right variant for your device and Axis OS version.</p>
|
||||
<div class="download-grid">
|
||||
<!-- Standard -->
|
||||
<div class="download-card">
|
||||
@@ -424,22 +428,6 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Custom -->
|
||||
<div class="download-card">
|
||||
<div class="tag tag-custom">Custom / Headscale</div>
|
||||
<h3>Custom Server</h3>
|
||||
<p>Supports custom Tailscale control servers and auth keys. Ideal for self-hosted Headscale setups.</p>
|
||||
<div class="arch-buttons">
|
||||
<a class="arch-btn" data-asset="custom" data-arch="aarch64" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
AARCH64
|
||||
</a>
|
||||
<a class="arch-btn" data-asset="custom" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
ARM
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ROOT -->
|
||||
<div class="download-card">
|
||||
<div class="tag tag-root">Root · Legacy</div>
|
||||
@@ -456,6 +444,18 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ACAP3 -->
|
||||
<div class="download-card">
|
||||
<div class="tag tag-acap3">ACAP3 · Legacy</div>
|
||||
<h3>ACAP3 (Older Axis OS)</h3>
|
||||
<p>For devices running Axis OS versions that do not support ACAP4. ARM only.</p>
|
||||
<div class="arch-buttons">
|
||||
<a class="arch-btn" data-asset="acap3" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
ARM
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -467,14 +467,14 @@
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-text">
|
||||
<strong>Download the EAP</strong>
|
||||
<span>Grab the right .eap file for your camera architecture from the releases page above.</span>
|
||||
<span>Grab the right .eap file for your device architecture from the releases page above.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-text">
|
||||
<strong>Upload to your camera</strong>
|
||||
<span>Log into your Axis camera web interface and go to <strong>Apps → Add App</strong>. Upload the .eap file.</span>
|
||||
<strong>Upload to your device</strong>
|
||||
<span>Log into your Axis device web interface and go to <strong>Apps → Add App</strong>. Upload the .eap file.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
@@ -540,6 +540,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Rotate hero word
|
||||
var heroWord = document.getElementById('heroWord');
|
||||
var devices = ['Cameras', 'Door Stations', 'Intercoms', 'Speakers', 'Radars', 'Encoders'];
|
||||
var wordIdx = 0;
|
||||
function cycleWord() {
|
||||
wordIdx = (wordIdx + 1) % devices.length;
|
||||
heroWord.style.opacity = '0';
|
||||
heroWord.style.transform = 'translateY(8px)';
|
||||
setTimeout(function() {
|
||||
heroWord.textContent = devices[wordIdx];
|
||||
heroWord.style.opacity = '1';
|
||||
heroWord.style.transform = 'translateY(0)';
|
||||
}, 250);
|
||||
}
|
||||
setInterval(cycleWord, 3000);
|
||||
|
||||
// Rewrite download links to point directly to latest release assets
|
||||
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
|
||||
.then(function(r) { return r.json(); })
|
||||
@@ -555,9 +571,9 @@
|
||||
var archMatch = name.indexOf(arch) !== -1;
|
||||
var typeMatch = false;
|
||||
if (type === 'Tailscale_VPN') {
|
||||
typeMatch = name.indexOf('custom') === -1 && name.indexOf('root') === -1;
|
||||
} else if (type === 'custom') {
|
||||
typeMatch = name.indexOf('custom') !== -1;
|
||||
typeMatch = name.indexOf('root') === -1 && name.indexOf('acap3') === -1;
|
||||
} else if (type === 'acap3') {
|
||||
typeMatch = name.indexOf('acap3') !== -1;
|
||||
} else if (type === 'ROOT') {
|
||||
typeMatch = name.indexOf('root') !== -1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user