|
|
@@ -1,54 +1,94 @@
|
|
|
// DebTik - Debian Linux Router Frontend Application Logic
|
|
|
|
|
|
// ================= Interceptor: Automatic Auth Bearer & 401 Handling =================
|
|
|
-const originalFetch = window.fetch;
|
|
|
-window.fetch = async function(...args) {
|
|
|
- let [resource, config] = args;
|
|
|
- config = config || {};
|
|
|
-
|
|
|
- // Clone or init headers
|
|
|
- let headers = config.headers;
|
|
|
- if (!headers) {
|
|
|
- headers = {};
|
|
|
- config.headers = headers;
|
|
|
- }
|
|
|
+const originalFetch = window.fetch.bind(window);
|
|
|
+const pendingAPIReads = new Map();
|
|
|
+const telemetryHealth = { lastSuccess: 0, failures: new Map() };
|
|
|
+let coreRefreshPromise = null;
|
|
|
+let lastSummaryRefresh = 0;
|
|
|
+
|
|
|
+function renderTelemetryHealth() {
|
|
|
+ const el = document.getElementById('telemetry-health');
|
|
|
+ if (!el) return;
|
|
|
+ const failures = [...telemetryHealth.failures.entries()];
|
|
|
+ const seconds = telemetryHealth.lastSuccess ? Math.floor((Date.now() - telemetryHealth.lastSuccess) / 1000) : null;
|
|
|
+ const stale = seconds === null || seconds > 15;
|
|
|
+ el.textContent = failures.length ? `Data unavailable / incomplete${seconds === null ? '' : ` · updated ${seconds}s ago`}` : stale ? 'Waiting for fresh data' : `Updated ${seconds}s ago`;
|
|
|
+ el.className = `text-[10px] font-mono ${failures.length || stale ? 'text-amber-400' : 'text-emerald-400'}`;
|
|
|
+ el.title = failures.length ? failures.map(([path, error]) => `${path}: ${error}`).join('\n') : telemetryHealth.lastSuccess ? `Last successful update: ${new Date(telemetryHealth.lastSuccess).toLocaleTimeString()}` : 'No successful update yet';
|
|
|
+}
|
|
|
+
|
|
|
+function stopSessionPolling() {
|
|
|
+ if (window._telemetryInterval) clearInterval(window._telemetryInterval);
|
|
|
+ window._telemetryInterval = null;
|
|
|
+ if (trafficEventSource) trafficEventSource.close();
|
|
|
+ trafficEventSource = null;
|
|
|
+ if (trafficPollFallback) clearInterval(trafficPollFallback);
|
|
|
+ trafficPollFallback = null;
|
|
|
+ if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer);
|
|
|
+ bwtestState.pollTimer = null;
|
|
|
+ if (logsTimer) clearInterval(logsTimer);
|
|
|
+ logsTimer = null;
|
|
|
+ stopFwStatPolling();
|
|
|
+ stopNATStatPolling();
|
|
|
+ stopMangleStatPolling();
|
|
|
+ telemetryHealth.failures.clear();
|
|
|
+ telemetryHealth.lastSuccess = 0;
|
|
|
+ lastSummaryRefresh = 0;
|
|
|
+}
|
|
|
|
|
|
+window.fetch = async function(resource, options = {}) {
|
|
|
+ const url = new URL(resource instanceof Request ? resource.url : resource, window.location.href);
|
|
|
+ const isAPI = url.origin === window.location.origin && url.pathname.startsWith('/api/');
|
|
|
+ if (!isAPI) return originalFetch(resource, options);
|
|
|
+ const config = { ...options, headers: new Headers(options.headers || (resource instanceof Request ? resource.headers : undefined)) };
|
|
|
const token = localStorage.getItem('debtik_token');
|
|
|
- if (token) {
|
|
|
- if (headers instanceof Headers) {
|
|
|
- if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
|
|
|
- } else if (Array.isArray(headers)) {
|
|
|
- if (!headers.some(([k]) => k.toLowerCase() === 'authorization')) headers.push(['Authorization', `Bearer ${token}`]);
|
|
|
- } else {
|
|
|
- if (!headers['Authorization']) headers['Authorization'] = `Bearer ${token}`;
|
|
|
+ if (token && !config.headers.has('Authorization')) config.headers.set('Authorization', `Bearer ${token}`);
|
|
|
+ const method = (config.method || (resource instanceof Request ? resource.method : 'GET')).toUpperCase();
|
|
|
+ const isRead = method === 'GET' && !url.pathname.includes('/stream') && !url.pathname.startsWith('/api/auth/');
|
|
|
+ const key = `${url.href}|${JSON.stringify([...config.headers])}`;
|
|
|
+ if (isRead && pendingAPIReads.has(key)) return (await pendingAPIReads.get(key)).clone();
|
|
|
+ const perform = async () => {
|
|
|
+ let timeout;
|
|
|
+ if (isRead && !config.signal) {
|
|
|
+ const controller = new AbortController();
|
|
|
+ config.signal = controller.signal;
|
|
|
+ timeout = setTimeout(() => controller.abort(), 15000);
|
|
|
}
|
|
|
- }
|
|
|
-
|
|
|
- const response = await originalFetch(resource, config);
|
|
|
-
|
|
|
- // If 401 Unauthorized on protected API endpoint, prompt login immediately
|
|
|
- if (response.status === 401 && typeof resource === 'string' && resource.includes('/api/') && !resource.includes('/api/auth/login')) {
|
|
|
- if (state.currentUser) {
|
|
|
- if (window._telemetryInterval) {
|
|
|
- clearInterval(window._telemetryInterval);
|
|
|
- window._telemetryInterval = null;
|
|
|
+ try {
|
|
|
+ const response = await originalFetch(resource, config);
|
|
|
+ if (isRead) {
|
|
|
+ if (response.ok) {
|
|
|
+ telemetryHealth.lastSuccess = Date.now();
|
|
|
+ telemetryHealth.failures.delete(url.pathname);
|
|
|
+ } else {
|
|
|
+ telemetryHealth.failures.set(url.pathname, `HTTP ${response.status}; displayed values may be old`);
|
|
|
+ }
|
|
|
+ renderTelemetryHealth();
|
|
|
}
|
|
|
- if (trafficEventSource) {
|
|
|
- try { trafficEventSource.close(); } catch(e) {}
|
|
|
- trafficEventSource = null;
|
|
|
+ if (response.status === 401 && url.pathname !== '/api/auth/login' && state.currentUser && token === localStorage.getItem('debtik_token')) {
|
|
|
+ stopSessionPolling();
|
|
|
+ state.isPolling = false;
|
|
|
+ state.currentUser = null;
|
|
|
+ localStorage.removeItem('debtik_token');
|
|
|
+ showLoginScreen(true);
|
|
|
}
|
|
|
- if (trafficPollFallback) {
|
|
|
- clearInterval(trafficPollFallback);
|
|
|
- trafficPollFallback = null;
|
|
|
+ return response;
|
|
|
+ } catch (err) {
|
|
|
+ if (isRead) {
|
|
|
+ telemetryHealth.failures.set(url.pathname, err.name === 'AbortError' ? 'Request timed out' : 'Connection failed; displayed values may be old');
|
|
|
+ renderTelemetryHealth();
|
|
|
}
|
|
|
- state.isPolling = false;
|
|
|
- state.currentUser = null;
|
|
|
- localStorage.removeItem('debtik_token');
|
|
|
- showLoginScreen(true);
|
|
|
+ throw err;
|
|
|
+ } finally {
|
|
|
+ if (timeout) clearTimeout(timeout);
|
|
|
}
|
|
|
- }
|
|
|
-
|
|
|
- return response;
|
|
|
+ };
|
|
|
+ if (!isRead) return perform();
|
|
|
+ const request = perform();
|
|
|
+ pendingAPIReads.set(key, request);
|
|
|
+ try { return (await request).clone(); }
|
|
|
+ finally { if (pendingAPIReads.get(key) === request) pendingAPIReads.delete(key); }
|
|
|
};
|
|
|
|
|
|
let state = {
|
|
|
@@ -141,7 +181,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
// Full state telemetry polling every 5 seconds (smooth & flicker-free)
|
|
|
if (!window._telemetryInterval) {
|
|
|
window._telemetryInterval = setInterval(() => {
|
|
|
- if (state.isPolling && state.currentUser) {
|
|
|
+ if (state.isPolling && state.currentUser && !document.hidden) {
|
|
|
fetchData(false);
|
|
|
}
|
|
|
}, 5000);
|
|
|
@@ -293,6 +333,9 @@ function navigate(tabName) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ stopFwStatPolling();
|
|
|
+ stopNATStatPolling();
|
|
|
+ stopMangleStatPolling();
|
|
|
state.activeTab = tabName;
|
|
|
window.location.hash = tabName;
|
|
|
|
|
|
@@ -322,7 +365,7 @@ function navigate(tabName) {
|
|
|
'dynamic-routing': { title: 'BGP Dynamic Routing', sub: 'Dynamic routing engine and peering sessions' },
|
|
|
ppp: { title: 'PPP Servers & Secrets', sub: 'Subscriber authentication, dynamic IP pools and VPN access concentrators' },
|
|
|
eoip: { title: 'EoIP Tunnels', sub: 'Ethernet over IP (EoIP / GRETAP) Layer 2 encapsulation & transparent network bridging' },
|
|
|
- aerotun: { title: 'AeroTUN High-Speed VPN', sub: 'Ultra-fast Layer 3 encrypted tunnel with ChaCha20-Poly1305 and live Adaptive LZ4 compression' },
|
|
|
+ aerotun: { title: 'AeroTUN High-Speed VPN', sub: 'Layer 3 encrypted tunnel with XChaCha20-Poly1305 and adaptive LZ4 compression' },
|
|
|
pppoe: { title: 'PPPoE Client WAN', sub: 'Point-to-Point Protocol over Ethernet WAN access' },
|
|
|
wireguard: { title: 'WireGuard VPN', sub: 'High-performance cryptographic Layer 3 tunnels with ChaCha20-Poly1305 key exchange' },
|
|
|
ipsec: { title: 'IPSec (IKEv2) strongSwan', sub: 'Enterprise Site-to-Site & Roadwarrior IPSec controller' },
|
|
|
@@ -335,6 +378,7 @@ function navigate(tabName) {
|
|
|
users: { title: 'Local User Manager', sub: 'Manage local system administrator credentials and access permissions' },
|
|
|
certificates: { title: 'Certificates & SSL/TLS', sub: 'Root Certificate Authorities (CA), Server/Client certificates and automated Let\'s Encrypt' },
|
|
|
services: { title: 'Management Services', sub: 'Enable or disable Web, SSH, and Telnet management listeners and listening ports' },
|
|
|
+ updates: { title: 'Git Updates', sub: 'Check, install and restore appliance software' },
|
|
|
logs: { title: 'System Activity Logs', sub: 'Real-time circular buffer log viewer and topic-based event monitoring' }
|
|
|
};
|
|
|
|
|
|
@@ -388,30 +432,41 @@ function navigate(tabName) {
|
|
|
if (tabName === 'users') loadUsers();
|
|
|
if (tabName === 'certificates') loadCertificates();
|
|
|
if (tabName === 'services') loadServices();
|
|
|
+ if (tabName === 'updates') loadUpdateStatus();
|
|
|
if (tabName === 'logs') loadLogs();
|
|
|
setTimeout(initTableSorting, 50);
|
|
|
}
|
|
|
|
|
|
// Fetch all core system, network and VPN telemetry
|
|
|
-async function fetchData(isManual = false) {
|
|
|
+function fetchData(isManual = false) {
|
|
|
+ if (coreRefreshPromise) return coreRefreshPromise;
|
|
|
+ coreRefreshPromise = refreshCoreData(isManual).finally(() => { coreRefreshPromise = null; });
|
|
|
+ return coreRefreshPromise;
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshCoreData(isManual = false) {
|
|
|
const spinner = document.getElementById('refresh-spinner');
|
|
|
if (isManual && spinner) spinner.classList.add('animate-spin');
|
|
|
|
|
|
try {
|
|
|
+ const refreshSummaries = isManual || Date.now() - lastSummaryRefresh > 30000;
|
|
|
+ const summaryFetch = path => (refreshSummaries || (state.activeTab === 'interfaces' && ['/vlans', '/roles'].includes(path))) ? fetch(`${API_BASE}${path}`).catch(() => ({ ok: false })) : Promise.resolve({ ok: false });
|
|
|
const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes, rolesRes] = await Promise.all([
|
|
|
- fetch(`${API_BASE}/system/status`),
|
|
|
- fetch(`${API_BASE}/interfaces`),
|
|
|
- fetch(`${API_BASE}/vlans`),
|
|
|
- fetch(`${API_BASE}/vpn/status`),
|
|
|
- fetch(`${API_BASE}/firewall/summary`),
|
|
|
- fetch(`${API_BASE}/routing/dynamic/summary`),
|
|
|
- fetch(`${API_BASE}/bridge/summary`),
|
|
|
- fetch(`${API_BASE}/dhcp/clients/summary`),
|
|
|
- fetch(`${API_BASE}/dhcp/clients`),
|
|
|
- fetch(`${API_BASE}/vrrp/summary`).catch(() => null),
|
|
|
- fetch(`${API_BASE}/roles`).catch(() => null)
|
|
|
+ fetch(`${API_BASE}/system/status`).catch(() => ({ ok: false })),
|
|
|
+ fetch(`${API_BASE}/interfaces`).catch(() => ({ ok: false })),
|
|
|
+ summaryFetch('/vlans'),
|
|
|
+ summaryFetch('/vpn/status'),
|
|
|
+ summaryFetch('/firewall/summary'),
|
|
|
+ summaryFetch('/routing/dynamic/summary'),
|
|
|
+ summaryFetch('/bridge/summary'),
|
|
|
+ summaryFetch('/dhcp/clients/summary'),
|
|
|
+ summaryFetch('/dhcp/clients'),
|
|
|
+ summaryFetch('/vrrp/summary').catch(() => null),
|
|
|
+ summaryFetch('/roles').catch(() => null)
|
|
|
]);
|
|
|
|
|
|
+ if (refreshSummaries && [vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes, rolesRes].every(r => r && r.ok)) lastSummaryRefresh = Date.now();
|
|
|
+
|
|
|
if (rolesRes && rolesRes.ok) {
|
|
|
state.roles = await rolesRes.json();
|
|
|
const countEl = document.getElementById('badge-roles-count');
|
|
|
@@ -472,27 +527,26 @@ async function fetchData(isManual = false) {
|
|
|
else renderInterfacesList();
|
|
|
}
|
|
|
if (state.activeTab === 'vlans') renderVlansList();
|
|
|
- if (state.activeTab === 'firewall') loadFirewall(false);
|
|
|
- if (state.activeTab === 'routes') loadRoutes(false);
|
|
|
- if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf') loadDynamicRouting(false);
|
|
|
- if (state.activeTab === 'ppp') loadPPP(false);
|
|
|
- if (state.activeTab === 'eoip') loadEoIP(false);
|
|
|
- if (state.activeTab === 'aerotun') loadAeroTUN(false);
|
|
|
- if (state.activeTab === 'pppoe') loadPPPoE(false);
|
|
|
- if (state.activeTab === 'wireguard') loadWireGuard(false);
|
|
|
- if (state.activeTab === 'ipsec') loadIPSec(false);
|
|
|
- if (state.activeTab === 'l2tp') loadL2TP(false);
|
|
|
- if (state.activeTab === 'pptp') loadPPTP(false);
|
|
|
- if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
|
|
|
- if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
|
|
|
- if (state.activeTab === 'vrrp') loadVRRP(false);
|
|
|
- if (state.activeTab === 'settings') loadSettingsData(false);
|
|
|
- if (state.activeTab === 'users') loadUsers(false);
|
|
|
- if (state.activeTab === 'certificates') loadCertificates(false);
|
|
|
- if (state.activeTab === 'services') loadServices(false);
|
|
|
- loadCertificates(false).catch(() => {});
|
|
|
- loadServices(false).catch(() => {});
|
|
|
- fetchBwTestServerStatus();
|
|
|
+ if (state.activeTab === 'firewall') await loadFirewall(false);
|
|
|
+ if (state.activeTab === 'routes') await loadRoutes(false);
|
|
|
+ if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf') await loadDynamicRouting(false);
|
|
|
+ if (state.activeTab === 'ppp') await loadPPP(false);
|
|
|
+ if (state.activeTab === 'eoip') await loadEoIP(false);
|
|
|
+ if (state.activeTab === 'aerotun') await loadAeroTUN(false);
|
|
|
+ if (state.activeTab === 'pppoe') await loadPPPoE(false);
|
|
|
+ if (state.activeTab === 'wireguard') await loadWireGuard(false);
|
|
|
+ if (state.activeTab === 'ipsec') await loadIPSec(false);
|
|
|
+ if (state.activeTab === 'l2tp') await loadL2TP(false);
|
|
|
+ if (state.activeTab === 'pptp') await loadPPTP(false);
|
|
|
+ if (state.activeTab === 'dhcp-client') await loadDHCPClients(false);
|
|
|
+ if (state.activeTab === 'dhcp-server') await loadDHCPServer(false);
|
|
|
+ if (state.activeTab === 'vrrp') await loadVRRP(false);
|
|
|
+ if (state.activeTab === 'settings') await loadSettingsData(false);
|
|
|
+ if (state.activeTab === 'users') await loadUsers(false);
|
|
|
+ if (state.activeTab === 'certificates') await loadCertificates(false);
|
|
|
+ if (state.activeTab === 'services') await loadServices(false);
|
|
|
+ if (state.activeTab === 'updates') await loadUpdateStatus(false);
|
|
|
+ if (state.activeTab === 'bwtest') await fetchBwTestServerStatus();
|
|
|
|
|
|
} catch (err) {
|
|
|
console.error('Failed to fetch router state:', err);
|
|
|
@@ -7557,7 +7611,7 @@ function renderAeroTUN() {
|
|
|
|
|
|
const statActive = document.getElementById('aerotun-stat-active');
|
|
|
if (statActive) {
|
|
|
- const activeCount = tunnels.filter(t => t.running).length;
|
|
|
+ const activeCount = tunnels.filter(t => t.connected).length;
|
|
|
statActive.innerText = `${activeCount} / ${tunnels.length}`;
|
|
|
}
|
|
|
|
|
|
@@ -7601,9 +7655,9 @@ function renderAeroTUN() {
|
|
|
? `<span class="px-1.5 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-semibold" title="Adaptive LZ4 active">${t.compression_ratio || 'LZ4'}</span>`
|
|
|
: `<span class="text-slate-500 text-[10px]">Off</span>`;
|
|
|
|
|
|
- const activePeer = t.active_peer_endpoint
|
|
|
- ? `<span class="text-emerald-400 font-mono text-[11px]" title="Last seen: ${t.last_handshake || 'just now'}">● ${escapeHtml(t.active_peer_endpoint)}</span>`
|
|
|
- : `<span class="text-slate-500 text-[11px]">${t.mode === 'server' ? 'Waiting for peer...' : 'Connecting...'}</span>`;
|
|
|
+ const activePeer = t.connected && t.active_peer_endpoint
|
|
|
+ ? `<span class="text-emerald-400 font-mono text-[11px]" title="Last authenticated packet: ${escapeHtml(t.last_handshake || 'just now')}; dropped: ${t.dropped_packets || 0}; auth failures: ${t.auth_failures || 0}; replay drops: ${t.replay_drops || 0}${t.last_error ? '; last error: ' + escapeHtml(t.last_error) : ''}">● ${escapeHtml(t.active_peer_endpoint)}</span>`
|
|
|
+ : `<span class="text-slate-500 text-[11px]">${t.last_error ? 'Failed: ' + escapeHtml(t.last_error) : (!t.running ? (t.disabled ? 'Saved / disabled' : 'Stopped') : (t.mode === 'server' ? 'Waiting for peer...' : 'Connecting...'))}</span>`;
|
|
|
|
|
|
const rawRx = formatBytes(t.rx_bytes || 0);
|
|
|
const rawTx = formatBytes(t.tx_bytes || 0);
|
|
|
@@ -7611,10 +7665,11 @@ function renderAeroTUN() {
|
|
|
const wireTx = formatBytes(t.wire_bytes_out || 0);
|
|
|
|
|
|
tr.innerHTML = `
|
|
|
- <td class="py-2.5 px-3 text-center">${renderMikroTikFlag(t.running, t.disabled)}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center">${renderMikroTikFlag(t.connected, t.disabled)}</td>
|
|
|
<td class="py-2.5 px-3 font-bold text-white font-mono flex items-center gap-1.5">
|
|
|
<span>${escapeHtml(t.name)}</span>
|
|
|
${t.default_route ? '<span class="text-[9px] px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30">DEF</span>' : ''}
|
|
|
+ <span class="text-slate-400 text-[10px]">v${t.protocol_version || 2}</span>
|
|
|
</td>
|
|
|
<td class="py-2.5 px-3">${modeBadge}</td>
|
|
|
<td class="py-2.5 px-3 text-xs font-mono">${endpointInfo}</td>
|
|
|
@@ -7661,16 +7716,33 @@ function onAeroTUNModeChange() {
|
|
|
async function generateAeroTUNKey() {
|
|
|
try {
|
|
|
const res = await fetch(`${API_BASE}/vpn/aerotun/genkey`);
|
|
|
- if (res.ok) {
|
|
|
- const data = await res.json();
|
|
|
- document.getElementById('aerotun-modal-psk').value = data.key;
|
|
|
- showToast('Key Generated', 'Cryptographic 256-bit PSK generated', 'success');
|
|
|
- }
|
|
|
+ if (!res.ok) throw new Error(await responseError(res));
|
|
|
+ const data = await res.json();
|
|
|
+ document.getElementById('aerotun-modal-psk').value = data.key;
|
|
|
+ showToast('Key Generated', 'Share this PSK securely with the peer before enabling', 'success');
|
|
|
} catch (err) {
|
|
|
- const arr = new Uint8Array(32);
|
|
|
- crypto.getRandomValues(arr);
|
|
|
- const hex = Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
|
- document.getElementById('aerotun-modal-psk').value = hex;
|
|
|
+ showToast('Key Generation Failed', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function updateAeroTUNSetupHint() {
|
|
|
+ const version = document.getElementById('aerotun-modal-protocol').value;
|
|
|
+ const key = document.getElementById('aerotun-modal-public').value;
|
|
|
+ const peer = document.getElementById('aerotun-modal-peerkey').value.trim();
|
|
|
+ const hint = document.getElementById('aerotun-setup-hint');
|
|
|
+ if (!hint) return;
|
|
|
+ hint.innerText = version !== '3' ? 'Legacy v2 uses a shared PSK. Both endpoints must use the same protocol.' : !key ? '1. Save disabled to create the local identity. 2. Exchange public keys and share the same PSK. 3. Set allowed peer prefixes, then enable.' : !peer ? 'Local identity saved. Copy the public key to the peer and paste its public key here. Use the same PSK on both endpoints.' : 'Peer identity configured. Check reciprocal addresses, allowed peer source prefixes and the shared PSK before enabling. Allowed prefixes do not create LAN routes.';
|
|
|
+}
|
|
|
+
|
|
|
+async function copyAeroTUNPublicKey() {
|
|
|
+ const input = document.getElementById('aerotun-modal-public');
|
|
|
+ if (!input.value) { showToast('Save First', 'Save the disabled tunnel to create its public key', 'info'); return; }
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(input.value);
|
|
|
+ showToast('Public Key Copied', 'Paste it into the other endpoint’s pinned peer key', 'success');
|
|
|
+ } catch {
|
|
|
+ input.focus(); input.select();
|
|
|
+ showToast('Copy Public Key', 'Key selected; press Ctrl+C to copy', 'info');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -7678,6 +7750,10 @@ function openAddAeroTUNModal() {
|
|
|
const tunnels = state.aerotunTunnels || [];
|
|
|
document.getElementById('aerotun-modal-title').innerText = 'Add AeroTUN Interface';
|
|
|
document.getElementById('aerotun-modal-id').value = '';
|
|
|
+ document.getElementById('aerotun-modal-protocol').value='3';
|
|
|
+ document.getElementById('aerotun-modal-public').value='';
|
|
|
+ document.getElementById('aerotun-modal-peerkey').value='';
|
|
|
+ document.getElementById('aerotun-modal-allowed').value=`10.99.${tunnels.length}.2/32`;
|
|
|
document.getElementById('aerotun-modal-name').value = `aerotun${tunnels.length}`;
|
|
|
document.getElementById('aerotun-modal-mode').value = 'server';
|
|
|
document.getElementById('aerotun-modal-port').value = 51830 + tunnels.length;
|
|
|
@@ -7687,12 +7763,13 @@ function openAddAeroTUNModal() {
|
|
|
document.getElementById('aerotun-modal-psk').value = '';
|
|
|
document.getElementById('aerotun-modal-mtu').value = '1420';
|
|
|
document.getElementById('aerotun-modal-comment').value = '';
|
|
|
- document.getElementById('aerotun-modal-compression').checked = true;
|
|
|
+ document.getElementById('aerotun-modal-compression').checked = false;
|
|
|
document.getElementById('aerotun-modal-defroute').checked = false;
|
|
|
- document.getElementById('aerotun-modal-disabled').checked = false;
|
|
|
+ document.getElementById('aerotun-modal-disabled').checked = true;
|
|
|
|
|
|
generateAeroTUNKey();
|
|
|
onAeroTUNModeChange();
|
|
|
+ updateAeroTUNSetupHint();
|
|
|
document.getElementById('aerotun-modal').classList.remove('hidden');
|
|
|
}
|
|
|
|
|
|
@@ -7702,13 +7779,18 @@ function openEditAeroTUNModal(id) {
|
|
|
|
|
|
document.getElementById('aerotun-modal-title').innerText = `Edit AeroTUN Interface (${t.name})`;
|
|
|
document.getElementById('aerotun-modal-id').value = t.id;
|
|
|
+ document.getElementById('aerotun-modal-protocol').value=String(t.protocol_version || 2);
|
|
|
+ document.getElementById('aerotun-modal-public').value=t.public_key || '';
|
|
|
+ document.getElementById('aerotun-modal-peerkey').value=t.peer_public_key || '';
|
|
|
+ document.getElementById('aerotun-modal-allowed').value=(t.allowed_ips || []).join(', ');
|
|
|
document.getElementById('aerotun-modal-name').value = t.name;
|
|
|
document.getElementById('aerotun-modal-mode').value = t.mode || 'server';
|
|
|
document.getElementById('aerotun-modal-port').value = t.listen_port || '';
|
|
|
document.getElementById('aerotun-modal-endpoint').value = t.remote_endpoint || '';
|
|
|
document.getElementById('aerotun-modal-local').value = t.local_address || '';
|
|
|
document.getElementById('aerotun-modal-peer').value = t.peer_address || '';
|
|
|
- document.getElementById('aerotun-modal-psk').value = t.psk || '';
|
|
|
+ document.getElementById('aerotun-modal-psk').value = '';
|
|
|
+ document.getElementById('aerotun-modal-psk').placeholder = t.has_psk ? 'Leave blank to keep the existing key' : 'New random 256-bit PSK';
|
|
|
document.getElementById('aerotun-modal-mtu').value = t.mtu || 1420;
|
|
|
document.getElementById('aerotun-modal-comment').value = t.comment || '';
|
|
|
document.getElementById('aerotun-modal-compression').checked = !!t.compression;
|
|
|
@@ -7716,6 +7798,7 @@ function openEditAeroTUNModal(id) {
|
|
|
document.getElementById('aerotun-modal-disabled').checked = !!t.disabled;
|
|
|
|
|
|
onAeroTUNModeChange();
|
|
|
+ updateAeroTUNSetupHint();
|
|
|
document.getElementById('aerotun-modal').classList.remove('hidden');
|
|
|
}
|
|
|
|
|
|
@@ -7743,7 +7826,12 @@ async function submitSaveAeroTUN(e) {
|
|
|
showToast('Validation Error', 'Remote endpoint (host:port) is required in client mode', 'error');
|
|
|
return;
|
|
|
}
|
|
|
- if (!psk) {
|
|
|
+ const version = parseInt(document.getElementById('aerotun-modal-protocol').value, 10);
|
|
|
+ if (version === 3 && !disabled && !document.getElementById('aerotun-modal-peerkey').value.trim()) {
|
|
|
+ showToast('Peer Key Required', 'Save disabled first, exchange public keys, then enable', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!psk && !id) {
|
|
|
showToast('Validation Error', 'Pre-shared key (PSK) is required', 'error');
|
|
|
return;
|
|
|
}
|
|
|
@@ -7753,6 +7841,9 @@ async function submitSaveAeroTUN(e) {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({
|
|
|
+ protocol_version:parseInt(document.getElementById('aerotun-modal-protocol').value,10),
|
|
|
+ peer_public_key:document.getElementById('aerotun-modal-peerkey').value.trim(),
|
|
|
+ allowed_ips:document.getElementById('aerotun-modal-allowed').value.split(',').map(s=>s.trim()).filter(Boolean),
|
|
|
id,
|
|
|
name,
|
|
|
mode,
|
|
|
@@ -7770,9 +7861,15 @@ async function submitSaveAeroTUN(e) {
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
|
|
- showToast('AeroTUN Saved', `Tunnel ${name} configured successfully`, 'success');
|
|
|
- closeAeroTUNModal();
|
|
|
- loadAeroTUN(false);
|
|
|
+ const saved = await res.json();
|
|
|
+ await loadAeroTUN(false);
|
|
|
+ if (!id && version === 3) {
|
|
|
+ openEditAeroTUNModal(saved.id);
|
|
|
+ showToast('Identity Saved', 'Copy the local public key and exchange keys with the peer', 'success');
|
|
|
+ } else {
|
|
|
+ closeAeroTUNModal();
|
|
|
+ showToast('AeroTUN Saved', disabled ? 'Configuration saved; tunnel is disabled' : 'Configuration saved; waiting for authenticated peer traffic', 'success');
|
|
|
+ }
|
|
|
fetchData(false);
|
|
|
} else {
|
|
|
const err = await res.text();
|
|
|
@@ -7787,7 +7884,7 @@ async function startAeroTUNTunnel(id) {
|
|
|
try {
|
|
|
const res = await fetch(`${API_BASE}/vpn/aerotun/tunnels/${encodeURIComponent(id)}/start`, { method: 'POST' });
|
|
|
if (res.ok) {
|
|
|
- showToast('AeroTUN Started', 'Tunnel interface is active', 'success');
|
|
|
+ showToast('AeroTUN Started', 'Interface started; connection is confirmed by authenticated peer traffic', 'info');
|
|
|
loadAeroTUN(false);
|
|
|
fetchData(false);
|
|
|
} else {
|
|
|
@@ -8689,19 +8786,20 @@ async function loadOspfAll() {
|
|
|
}
|
|
|
|
|
|
function renderBgpStatusPill() {
|
|
|
- const pill = document.getElementById('bgp-live-status-pill');
|
|
|
- if (!pill) return;
|
|
|
+ const pills = document.querySelectorAll('.bgp-live-status-pill');
|
|
|
+ pills.forEach(pill => {
|
|
|
const inst = state.bgpInstance;
|
|
|
if (!inst || !inst.enabled) {
|
|
|
- pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
|
|
|
+ pill.className = 'bgp-live-status-pill text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
|
|
|
pill.innerText = 'BGP DISABLED';
|
|
|
} else if (inst.running) {
|
|
|
- pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-purple-950 text-purple-300 border border-purple-800/60 font-semibold';
|
|
|
+ pill.className = 'bgp-live-status-pill text-[10px] px-2 py-0.5 rounded font-mono bg-purple-950 text-purple-300 border border-purple-800/60 font-semibold';
|
|
|
pill.innerText = `BGP ACTIVE (AS ${inst.as})`;
|
|
|
} else {
|
|
|
- pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold';
|
|
|
+ pill.className = 'bgp-live-status-pill text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold';
|
|
|
pill.innerText = 'BGP STARTING...';
|
|
|
}
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
function renderBgpPeers() {
|
|
|
@@ -14894,14 +14992,13 @@ const bwtestState = {
|
|
|
peakTx: 0,
|
|
|
peakRx: 0,
|
|
|
targetDuration: 10,
|
|
|
- lastSampleIndex: 0
|
|
|
+ lastSampleElapsed: -1,
|
|
|
+ sessionID: null
|
|
|
};
|
|
|
|
|
|
async function loadBwTestView() {
|
|
|
- fetchBwTestServerStatus();
|
|
|
- loadBwTestHistory();
|
|
|
resizeBwTestCanvas();
|
|
|
- checkActiveBwTest();
|
|
|
+ await Promise.all([fetchBwTestServerStatus(), loadBwTestHistory(), checkActiveBwTest()]);
|
|
|
}
|
|
|
|
|
|
async function fetchBwTestServerStatus() {
|
|
|
@@ -14940,9 +15037,11 @@ async function fetchBwTestServerStatus() {
|
|
|
|
|
|
// Modal fields
|
|
|
const toggle = document.getElementById('bwtest-server-enable-toggle');
|
|
|
- if (toggle) toggle.checked = !!s.enabled;
|
|
|
+ const serverModal = document.getElementById('modal-bwtest-server');
|
|
|
+ const editing = serverModal && !serverModal.classList.contains('hidden');
|
|
|
+ if (toggle && !editing) toggle.checked = !!s.enabled;
|
|
|
const portInp = document.getElementById('bwtest-server-port-input');
|
|
|
- if (portInp) portInp.value = s.port || 2000;
|
|
|
+ if (portInp && !editing) portInp.value = s.port || 2000;
|
|
|
const actSess = document.getElementById('bwtest-modal-active-sessions');
|
|
|
if (actSess) actSess.innerText = s.active_sessions || 0;
|
|
|
const totServ = document.getElementById('bwtest-modal-total-served');
|
|
|
@@ -14956,6 +15055,11 @@ async function fetchBwTestServerStatus() {
|
|
|
}
|
|
|
|
|
|
function openBwTestServerModal() {
|
|
|
+ const cfg = bwtestState.serverConfig;
|
|
|
+ if (cfg) {
|
|
|
+ document.getElementById('bwtest-server-enable-toggle').checked = !!cfg.enabled;
|
|
|
+ document.getElementById('bwtest-server-port-input').value = cfg.port || 2000;
|
|
|
+ }
|
|
|
fetchBwTestServerStatus();
|
|
|
document.getElementById('modal-bwtest-server')?.classList.remove('hidden');
|
|
|
}
|
|
|
@@ -15068,7 +15172,7 @@ async function checkActiveBwTest() {
|
|
|
const st = await res.json();
|
|
|
if (st.running) {
|
|
|
setBwTestRunningUI(true);
|
|
|
- if (st.current_sample) handleBwTestSample(st.current_sample);
|
|
|
+ processBwTestStatus(st);
|
|
|
startBwTestTelemetry();
|
|
|
}
|
|
|
} catch (err) {
|
|
|
@@ -15092,7 +15196,8 @@ async function startBwTest(e) {
|
|
|
bwtestState.chartPoints = [];
|
|
|
bwtestState.peakTx = 0;
|
|
|
bwtestState.peakRx = 0;
|
|
|
- bwtestState.lastSampleIndex = 0;
|
|
|
+ bwtestState.lastSampleElapsed = -1;
|
|
|
+ bwtestState.sessionID = null;
|
|
|
|
|
|
// Clear UI elements
|
|
|
document.getElementById('bwtest-stat-tx-mbps').innerText = '0.00';
|
|
|
@@ -15181,44 +15286,64 @@ function setBwTestRunningUI(running) {
|
|
|
|
|
|
async function stopBwTest() {
|
|
|
try {
|
|
|
- await fetch(`${API_BASE}/tools/bwtest/stop`, { method: 'POST' });
|
|
|
- showToast('Test Stopped', 'Bandwidth test terminated by user', 'info');
|
|
|
+ const res = await fetch(`${API_BASE}/tools/bwtest/stop`, { method: 'POST' });
|
|
|
+ if (!res.ok) throw new Error(await responseError(res));
|
|
|
+ showToast('Stop Requested', 'Waiting for the bandwidth test to finish', 'info');
|
|
|
+ if (!bwtestState.pollTimer) startBwTestTelemetry();
|
|
|
} catch (err) {
|
|
|
- console.error('Failed to stop test:', err);
|
|
|
+ showToast('Stop Failed', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function responseError(res) {
|
|
|
+ const text = await res.text();
|
|
|
+ try { const data = JSON.parse(text); return data.message || (typeof data.error === 'string' ? data.error : null) || `HTTP ${res.status}`; }
|
|
|
+ catch { return text || `HTTP ${res.status}`; }
|
|
|
+}
|
|
|
+
|
|
|
+function processBwTestStatus(st) {
|
|
|
+ if (st.session_id && bwtestState.sessionID !== st.session_id) {
|
|
|
+ bwtestState.sessionID = st.session_id;
|
|
|
+ bwtestState.lastSampleElapsed = -1;
|
|
|
+ }
|
|
|
+ for (const sample of st.recent_samples || []) {
|
|
|
+ if (sample.elapsed_sec > (bwtestState.lastSampleElapsed ?? -1)) {
|
|
|
+ handleBwTestSample(sample);
|
|
|
+ bwtestState.lastSampleElapsed = sample.elapsed_sec;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!st.running) {
|
|
|
+ if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer);
|
|
|
+ bwtestState.pollTimer = null;
|
|
|
+ setBwTestRunningUI(false);
|
|
|
+ handleBwTestFinish(st.summary, st.error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function startBwTestTelemetry() {
|
|
|
+ if (!state.currentUser) return;
|
|
|
if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer);
|
|
|
-
|
|
|
- bwtestState.pollTimer = setInterval(async () => {
|
|
|
+ let busy = false;
|
|
|
+ const poll = async () => {
|
|
|
+ if (busy || !state.currentUser || document.hidden) return;
|
|
|
+ busy = true;
|
|
|
try {
|
|
|
const res = await fetch(`${API_BASE}/tools/bwtest/status`);
|
|
|
- if (!res.ok) return;
|
|
|
- const st = await res.json();
|
|
|
-
|
|
|
- if (st.recent_samples && st.recent_samples.length > 0) {
|
|
|
- // Process new samples
|
|
|
- const newSamples = st.recent_samples.slice(bwtestState.lastSampleIndex);
|
|
|
- newSamples.forEach(s => handleBwTestSample(s));
|
|
|
- bwtestState.lastSampleIndex = st.recent_samples.length;
|
|
|
- }
|
|
|
-
|
|
|
- if (!st.running) {
|
|
|
- clearInterval(bwtestState.pollTimer);
|
|
|
- bwtestState.pollTimer = null;
|
|
|
- setBwTestRunningUI(false);
|
|
|
- handleBwTestFinish(st.summary, st.error);
|
|
|
- }
|
|
|
+ if (!res.ok) throw new Error(await responseError(res));
|
|
|
+ processBwTestStatus(await res.json());
|
|
|
} catch (err) {
|
|
|
console.error('BwTest polling error:', err);
|
|
|
- }
|
|
|
- }, 400);
|
|
|
+ } finally { busy = false; }
|
|
|
+ };
|
|
|
+ bwtestState.pollTimer = setInterval(poll, 400);
|
|
|
+ poll();
|
|
|
}
|
|
|
|
|
|
function handleBwTestSample(s) {
|
|
|
if (!s) return;
|
|
|
|
|
|
+ const payloadHint = document.getElementById('bwtest-effective-payload');
|
|
|
+ if (payloadHint && s.packet_size) payloadHint.innerText = `Effective datagram: ${s.packet_size} bytes`;
|
|
|
const txMbps = s.tx_mbps || 0;
|
|
|
const rxMbps = s.rx_mbps || 0;
|
|
|
|
|
|
@@ -15227,7 +15352,10 @@ function handleBwTestSample(s) {
|
|
|
|
|
|
// KPI updates
|
|
|
const elTx = document.getElementById('bwtest-stat-tx-mbps');
|
|
|
- if (elTx) elTx.innerText = txMbps.toFixed(2);
|
|
|
+ if (elTx) {
|
|
|
+ elTx.innerText = txMbps.toFixed(2);
|
|
|
+ elTx.title = `Confirmed by peer; locally offered: ${(s.offered_tx_mbps || 0).toFixed(2)} Mbps`;
|
|
|
+ }
|
|
|
const elTxPeak = document.getElementById('bwtest-stat-tx-peak');
|
|
|
if (elTxPeak) elTxPeak.innerText = `${bwtestState.peakTx.toFixed(2)} Mbps`;
|
|
|
const elTxPkts = document.getElementById('bwtest-stat-tx-pkts');
|
|
|
@@ -15974,7 +16102,7 @@ async function handleLoginSubmit(event) {
|
|
|
// Ensure background telemetry polling is active
|
|
|
if (!window._telemetryInterval) {
|
|
|
window._telemetryInterval = setInterval(() => {
|
|
|
- if (state.isPolling && state.currentUser) {
|
|
|
+ if (state.isPolling && state.currentUser && !document.hidden) {
|
|
|
fetchData(false);
|
|
|
}
|
|
|
}, 5000);
|
|
|
@@ -16003,6 +16131,7 @@ async function handleLogoutClick() {
|
|
|
});
|
|
|
} catch (e) {}
|
|
|
|
|
|
+ stopSessionPolling();
|
|
|
localStorage.removeItem('debtik_token');
|
|
|
state.currentUser = null;
|
|
|
state.isPolling = false;
|
|
|
@@ -16665,9 +16794,9 @@ async function loadLogs(manual = false) {
|
|
|
}
|
|
|
|
|
|
// Setup auto-refresh if enabled and on logs tab
|
|
|
- if (!logsTimer) {
|
|
|
+ if (!logsTimer && state.currentUser) {
|
|
|
logsTimer = setInterval(() => {
|
|
|
- if (state.activeTab === 'logs') {
|
|
|
+ if (state.currentUser && !document.hidden && state.activeTab === 'logs') {
|
|
|
const auto = document.getElementById('logs-auto-refresh');
|
|
|
if (auto && auto.checked) {
|
|
|
loadLogs(false);
|
|
|
@@ -17094,3 +17223,61 @@ async function deleteCertificate(name) {
|
|
|
|
|
|
|
|
|
|
|
|
+
|
|
|
+// Refresh immediately when returning to a backgrounded controller tab.
|
|
|
+document.addEventListener('visibilitychange', () => {
|
|
|
+ if (!state.currentUser) return;
|
|
|
+ if (document.hidden) {
|
|
|
+ if (trafficEventSource) trafficEventSource.close();
|
|
|
+ trafficEventSource = null;
|
|
|
+ if (trafficPollFallback) clearInterval(trafficPollFallback);
|
|
|
+ trafficPollFallback = null;
|
|
|
+ } else {
|
|
|
+ fetchData(false);
|
|
|
+ initLiveTrafficStream();
|
|
|
+ if (state.activeTab === 'bwtest') loadBwTestView();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+async function loadUpdateStatus(populate = true) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/update/status`);
|
|
|
+ if (!res.ok) throw new Error(await responseError(res));
|
|
|
+ const data = await res.json();
|
|
|
+ if (populate && document.activeElement?.id !== 'update-repository') document.getElementById('update-repository').value = data.repository || '';
|
|
|
+ if (populate && document.activeElement?.id !== 'update-branch') document.getElementById('update-branch').value = data.branch || 'master';
|
|
|
+ if (populate && document.activeElement?.id !== 'update-tls-pin') document.getElementById('update-tls-pin').value = data.tls_key_pin || '';
|
|
|
+ document.getElementById('update-installed').textContent = data.installed_commit || 'unknown';
|
|
|
+ document.getElementById('update-state').textContent = data.state || 'idle';
|
|
|
+ document.getElementById('update-log').textContent = data.log || '';
|
|
|
+ } catch (err) { showToast('Update Status', err.message, 'error'); }
|
|
|
+}
|
|
|
+async function saveUpdateConfig() {
|
|
|
+ try {
|
|
|
+ const repository = document.getElementById('update-repository').value.trim();
|
|
|
+ const branch = document.getElementById('update-branch').value.trim();
|
|
|
+ const tls_key_pin = document.getElementById('update-tls-pin').value.trim();
|
|
|
+ const res = await fetch(`${API_BASE}/update/config`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({repository,branch,tls_key_pin})});
|
|
|
+ if (!res.ok) throw new Error(await responseError(res));
|
|
|
+ showToast('Update Source Saved', 'Repository and branch saved', 'success');
|
|
|
+ await loadUpdateStatus(false);
|
|
|
+ } catch (err) { showToast('Update Source', err.message, 'error'); }
|
|
|
+}
|
|
|
+async function checkUpdate() {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/update/check`, {method:'POST'});
|
|
|
+ if (!res.ok) throw new Error(await responseError(res));
|
|
|
+ const data = await res.json();
|
|
|
+ document.getElementById('update-available').textContent = data.available_commit;
|
|
|
+ showToast('Git Check', data.update_available ? 'An update is available' : 'Already at the selected commit', 'info');
|
|
|
+ } catch (err) { showToast('Git Check Failed', err.message, 'error'); }
|
|
|
+}
|
|
|
+async function startUpdate(action) {
|
|
|
+ if (!confirm(action === 'rollback' ? 'Restore the previous version and restart DebTik?' : 'Install the selected Git branch and restart DebTik?')) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/update/${action}`, {method:'POST'});
|
|
|
+ if (!res.ok) throw new Error(await responseError(res));
|
|
|
+ showToast('Update Started', 'The service will restart; reconnect after a few seconds', 'info');
|
|
|
+ await loadUpdateStatus(false);
|
|
|
+ } catch (err) { showToast('Update Failed', err.message, 'error'); }
|
|
|
+}
|