Files
Weston Blieden 3ed71ccae3 Add subnet routing and param.cgi-less settings fallback
- Advertise routes (subnet router) support wired through run scripts and params
- Settings UI tries param.cgi first, then falls back to an app-hosted endpoint
  exposed via manifest reverseProxy, so devices without param.cgi (recorder/NVR
  class) can load and save settings without a reinstall
- Embedded GSocketService HTTP server in param_bridge serves the fallback
- Adds gio-2.0 dependency; correct aarch64 tailscale binaries
2026-07-01 09:02:28 +02:00

1017 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tailscale VPN</title>
<style>
:root {
--bg: #0f1117;
--surface: #181b23;
--surface2: #1e2230;
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #2e2d2d;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
--radius: 10px;
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
}
[data-theme="light"] {
--bg: #f5f6f8;
--surface: #ffffff;
--surface2: #f0f1f4;
--border: #e0e3e8;
--text: #1a1a2e;
--muted: #6b7084;
--accent: #2e2d2d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 20px;
font-size: 14px;
max-width: 720px;
margin: 0 auto;
line-height: 1.5;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
}
.theme-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
border-radius: 8px;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
.theme-btn svg { width: 16px; height: 16px; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 14px;
}
.card-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 14px;
}
/* Status */
.status-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 14px;
font-weight: 600;
}
.status-banner.connected .status-text { color: var(--green); }
.status-banner.connecting .status-text { color: var(--yellow); }
.status-banner.disconnected .status-text { color: var(--red); }
.status-time {
margin-left: auto;
font-size: 12px;
color: var(--muted);
font-family: var(--mono);
}
/* Auth block */
.auth-block {
background: rgba(245,158,11,0.08);
border: 1px solid rgba(245,158,11,0.2);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.auth-block p {
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
.auth-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 13px;
padding: 8px 18px;
border-radius: 6px;
margin-bottom: 8px;
}
.auth-btn:hover { opacity: 0.9; }
.auth-url {
display: block;
font-size: 11px;
color: var(--muted);
word-break: break-all;
font-family: var(--mono);
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
background: var(--surface2);
border-radius: 8px;
padding: 12px 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--muted);
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
font-weight: 600;
font-family: var(--mono);
word-break: break-all;
}
.info-value.dim { color: var(--muted); font-weight: 400; }
/* Log viewer */
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.log-badge {
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.log-toggle {
font-size: 12px;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
font-weight: 600;
}
.log-toggle:hover { text-decoration: underline; }
.log-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
max-height: 400px;
overflow-y: auto;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--muted);
white-space: pre-wrap;
word-break: break-all;
}
.log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* 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;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(46,45,45,0.1);
border: 1px solid rgba(46,45,45,0.2);
}
.update-banner.visible { display: flex; }
.update-banner .update-text {
flex: 1;
font-size: 13px;
color: var(--text);
}
.update-banner .update-text strong { color: var(--accent); }
.update-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 12px;
padding: 6px 14px;
border-radius: 6px;
white-space: nowrap;
}
.update-btn:hover { opacity: 0.9; }
@media (max-width: 480px) {
body { padding: 14px; }
.info-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>Tailscale VPN</h1>
</div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
<!-- Status -->
<div id="status-banner" class="status-banner connecting">
<span class="dot"></span>
<span id="status-text" class="status-text">Checking...</span>
<span id="status-time" class="status-time"></span>
</div>
<!-- Update available -->
<div id="update-banner" class="update-banner">
<div class="update-text">Update available: <strong id="update-version"></strong></div>
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</a>
</div>
<!-- Auth (hidden by default) -->
<div id="auth-block" class="auth-block" style="display:none;">
<p>Authenticate this device to connect to your Tailscale network:</p>
<a id="auth-link" class="auth-btn" href="#" target="_blank">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open Login Page
</a>
<span id="auth-url-text" class="auth-url"></span>
</div>
<!-- Connection Info -->
<div class="card" id="info-card" style="display:none;">
<div class="card-title">Connection Details</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Tailscale IP</div>
<div class="info-value" id="ts-ip">-</div>
</div>
<div class="info-item">
<div class="info-label">Node Name</div>
<div class="info-value" id="ts-node">-</div>
</div>
<div class="info-item">
<div class="info-label">Account</div>
<div class="info-value" id="ts-tailnet">-</div>
</div>
<div class="info-item">
<div class="info-label">Version</div>
<div class="info-value" id="ts-version">-</div>
</div>
</div>
<div style="margin-top:14px;text-align:right;">
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
</div>
</div>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-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&apos;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-row">
<label class="settings-label" for="input-advertise-routes">Advertise Routes (Subnet Router)</label>
<input class="settings-input" id="input-advertise-routes" type="text" autocomplete="off" placeholder="192.168.1.0/24,10.0.0.0/8 (leave blank to disable)">
<span class="settings-hint">Comma-separated CIDRs this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = '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;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
// Fallbacks only when syslog has rotated and no state transitions are visible.
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
// (handles stale Running entries in syslog after reinstall or token expiry)
if (isRunning && latestUrl) {
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
// 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() {
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();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var advertiseRoutesInput = document.getElementById('input-advertise-routes');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
// param.cgi is used when available; on devices that lack it (e.g. some
// recorder/NVR-class devices) we fall back to the app's own endpoint,
// exposed through the manifest reverseProxy mapping at API_URL.
var API_URL = '/local/' + APP + '/api/settings';
function updateProxyDisplay(httpPort, socksPort) {
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
}
function applyParamText(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
var avm = txt.match(/root\.\S+\.AdvertiseRoutes=(.*)/);
// If none of the expected keys are present the endpoint isn't param.cgi
// (e.g. a generic 404 page); signal the caller to use the fallback.
if (!sm && !hm && !km) return false;
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';
if (avm) advertiseRoutesInput.value = avm[1].trim();
updateProxyDisplay(hm ? hm[1].trim() : null, km ? km[1].trim() : null);
return true;
}
function applyJson(obj) {
if (typeof obj.CustomServer === 'string') serverInput.value = obj.CustomServer;
if (typeof obj.AuthKey === 'string') authInput.value = obj.AuthKey;
if (typeof obj.HttpProxyPort === 'string') httpPortInput.value = obj.HttpProxyPort;
if (typeof obj.Socks5Port === 'string') socksPortInput.value = obj.Socks5Port;
if (typeof obj.AcceptDNS === 'string') acceptDnsInput.checked = obj.AcceptDNS === 'true';
if (typeof obj.AcceptRoutes === 'string') acceptRoutesInput.checked = obj.AcceptRoutes === 'true';
if (typeof obj.AdvertiseRoutes === 'string') advertiseRoutesInput.value = obj.AdvertiseRoutes;
updateProxyDisplay(obj.HttpProxyPort, obj.Socks5Port);
}
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) { if (!applyParamText(txt)) return Promise.reject(); })
.catch(function() { loadSettingsFallback(); });
}
function loadSettingsFallback() {
fetch(API_URL + '?t=' + Date.now(), { credentials: 'same-origin', cache: 'no-store' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(obj) { if (obj) applyJson(obj); })
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
function saveViaFallback(httpPort, socksPort) {
var body = 'CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&HttpProxyPort=' + encodeURIComponent(httpPort) +
'&Socks5Port=' + encodeURIComponent(socksPort) +
'&AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
return fetch(API_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
if (/OK/.test(txt)) {
// The app applies the change and restarts its tunnel itself,
// so no separate control.cgi restart is needed here.
setStatus('Saved. Restarting...', 'ok');
} else {
setStatus('Error saving settings', 'err');
}
});
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
}
// param.cgi reachable but rejected the update — surface the error.
setStatus('Error: ' + txt.trim(), 'err');
})
.catch(function() {
// param.cgi unavailable (e.g. recorder-class device) — use the fallback.
return saveViaFallback(httpPort, socksPort);
})
.then(function() { saveBtn.disabled = false; })
.catch(function() { saveBtn.disabled = false; setStatus('Failed to save', 'err'); });
});
loadSettings();
})();
</script>
</body>
</html>