diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js index 7e70c995..a8806e5b 100644 --- a/cli/src/cli/api/client.js +++ b/cli/src/cli/api/client.js @@ -26,9 +26,12 @@ function getDataDir() { } const MACHINE_ID_FILE = path.join(getDataDir(), "machine-id"); +const AUTH_DIR = path.join(getDataDir(), "auth"); +const CLI_SECRET_FILE = path.join(AUTH_DIR, "cli-secret"); let config = { ...DEFAULT_CONFIG }; let cachedCliToken = null; +let cachedCliSecret = null; // Read raw machineId from shared file (written by server) → guarantees token match function loadRawMachineId() { @@ -39,10 +42,26 @@ function loadRawMachineId() { try { return machineIdSync(); } catch { return ""; } } +// Random secret shared with server via file → token unpredictable from machineId alone. +function loadCliSecret() { + if (cachedCliSecret) return cachedCliSecret; + try { + cachedCliSecret = fs.readFileSync(CLI_SECRET_FILE, "utf8").trim(); + if (cachedCliSecret) return cachedCliSecret; + } catch {} + cachedCliSecret = crypto.randomBytes(32).toString("hex"); + try { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + fs.writeFileSync(CLI_SECRET_FILE, cachedCliSecret, { mode: 0o600 }); + } catch {} + return cachedCliSecret; +} + function getCliToken() { if (cachedCliToken !== null) return cachedCliToken; const raw = loadRawMachineId(); - cachedCliToken = raw ? crypto.createHash("sha256").update(raw + CLI_TOKEN_SALT).digest("hex").substring(0, 16) : ""; + const secret = loadCliSecret(); + cachedCliToken = raw ? crypto.createHash("sha256").update(raw + CLI_TOKEN_SALT + secret).digest("hex").substring(0, 16) : ""; return cachedCliToken; } diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 4b2534dd..ef27d773 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -35,6 +35,19 @@ async function clientPingUrl(url) { } catch { return false; } } +// Race multiple URLs: resolve true as soon as any one passes ping. +async function clientPingAny(...urls) { + const checks = urls.filter(Boolean).map(clientPingUrl); + if (!checks.length) return false; + return new Promise((resolve) => { + let pending = checks.length; + checks.forEach((p) => p.then((ok) => { + if (ok) resolve(true); + else if (--pending === 0) resolve(false); + })); + }); +} + const CAVEMAN_LEVELS = [ { id: "lite", label: "Lite", desc: "Drop filler, keep grammar" }, { id: "full", label: "Full", desc: "Drop articles, fragments OK" }, @@ -121,21 +134,20 @@ export default function APIPageClient({ machineId }) { loadSettings(); }, []); - // Adaptive status poll: slow when healthy, fast when degraded; pause when tab hidden. + // Status poll: only while degraded (not yet reachable). Stop once healthy to avoid spam. + // Visibility re-check: refresh once when tab becomes visible. useEffect(() => { const anyEnabled = tunnelEnabled || tsEnabled; if (!anyEnabled) return; const tunnelHealthy = !tunnelEnabled || tunnelReachable; const tsHealthy = !tsEnabled || tsReachable; const allHealthy = tunnelHealthy && tsHealthy; - const delay = allHealthy ? STATUS_POLL_SLOW_MS : STATUS_POLL_FAST_MS; - let timer = null; - const tick = () => { if (!document.hidden) syncTunnelStatus(); }; - timer = setInterval(tick, delay); const onVisible = () => { if (!document.hidden) syncTunnelStatus(); }; document.addEventListener("visibilitychange", onVisible); + if (allHealthy) return () => document.removeEventListener("visibilitychange", onVisible); + const timer = setInterval(() => { if (!document.hidden) syncTunnelStatus(); }, STATUS_POLL_FAST_MS); return () => { - if (timer) clearInterval(timer); + clearInterval(timer); document.removeEventListener("visibilitychange", onVisible); }; }, [tunnelEnabled, tsEnabled, tunnelReachable, tsReachable]); @@ -146,8 +158,8 @@ export default function APIPageClient({ machineId }) { useEffect(() => { const probeBoth = async () => { if (document.hidden) return; - if (tunnelEnabled && tunnelUrl) { - const ok = await clientPingUrl(tunnelUrl); + if (tunnelEnabled && (tunnelUrl || tunnelPublicUrl)) { + const ok = await clientPingAny(tunnelPublicUrl, tunnelUrl); tunnelClientReachableRef.current = ok; if (ok) { tunnelMissRef.current = 0; setTunnelReachable(true); if (!tunnelEverReachableRef.current) { tunnelEverReachableRef.current = true; setTunnelEverReachable(true); } } } else { @@ -161,21 +173,20 @@ export default function APIPageClient({ machineId }) { tsClientReachableRef.current = false; } }; - const anyEnabled = (tunnelEnabled && tunnelUrl) || (tsEnabled && tsUrl); + const anyEnabled = (tunnelEnabled && (tunnelUrl || tunnelPublicUrl)) || (tsEnabled && tsUrl); if (!anyEnabled) return; probeBoth(); const tunnelHealthy = !tunnelEnabled || tunnelReachable; const tsHealthy = !tsEnabled || tsReachable; - const allHealthy = tunnelHealthy && tsHealthy; - const delay = allHealthy ? CLIENT_PING_SLOW_MS : CLIENT_PING_FAST_MS; - const id = setInterval(probeBoth, delay); + if (tunnelHealthy && tsHealthy) return; + const id = setInterval(probeBoth, CLIENT_PING_FAST_MS); return () => clearInterval(id); - }, [tunnelEnabled, tunnelUrl, tsEnabled, tsUrl, tunnelReachable, tsReachable]); + }, [tunnelEnabled, tunnelUrl, tunnelPublicUrl, tsEnabled, tsUrl, tunnelReachable, tsReachable]); - // Effective reachable = serverReachable OR clientReachable (1 of 2 is enough). - // Miss-debounce: only flip to false after N consecutive misses on BOTH sides. - const updateReachable = useCallback((serverReachable, clientRef, missRef, setter, everRef, everSetter) => { - const reachable = serverReachable || clientRef.current; + // Client-side reachable only (server no longer probes; watchdog handles backend health). + // Miss-debounce: only flip to false after N consecutive misses. + const updateReachable = useCallback((_unused, clientRef, missRef, setter, everRef, everSetter) => { + const reachable = clientRef.current; if (reachable) { missRef.current = 0; setter(true); @@ -200,13 +211,13 @@ export default function APIPageClient({ machineId }) { setTunnelUrl(tUrl); setTunnelPublicUrl(data.tunnel?.publicUrl || ""); setTunnelEnabled(tEnabled); - updateReachable(!!data.tunnel?.reachable, tunnelClientReachableRef, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); + updateReachable(null, tunnelClientReachableRef, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false; const tsUrlVal = data.tailscale?.tunnelUrl || ""; setTsUrl(tsUrlVal); setTsEnabled(tsEn); - updateReachable(!!data.tailscale?.reachable, tsClientReachableRef, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); + updateReachable(null, tsClientReachableRef, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); } catch { /* ignore poll errors */ } }; @@ -234,13 +245,13 @@ export default function APIPageClient({ machineId }) { setTunnelUrl(tUrl); setTunnelPublicUrl(data.tunnel?.publicUrl || ""); setTunnelEnabled(tEnabled); - updateReachable(!!data.tunnel?.reachable, tunnelClientReachableRef, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); + updateReachable(null, tunnelClientReachableRef, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false; const tsUrlVal = data.tailscale?.tunnelUrl || ""; setTsUrl(tsUrlVal); setTsEnabled(tsEn); - updateReachable(!!data.tailscale?.reachable, tsClientReachableRef, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); + updateReachable(null, tsClientReachableRef, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); } } catch (error) { console.log("Error loading settings:", error); @@ -325,23 +336,25 @@ export default function APIPageClient({ machineId }) { }; // u2500u2500u2500 Cloudflare Tunnel handlers - // Ping tunnel health until reachable, also check backend status to detect process die - const pingTunnelHealth = async (url) => { + // Ping tunnel health until reachable. Race multiple URLs (shortlink + direct) — 1 OK is enough. + const pingTunnelHealth = async (...urls) => { setTunnelLoading(true); setTunnelProgress("Waiting for tunnel ready..."); - const healthUrl = `${url}/api/health`; + const targets = urls.filter(Boolean).map((u) => `${u}/api/health`); const start = Date.now(); while (Date.now() - start < TUNNEL_PING_MAX_MS) { await new Promise((r) => setTimeout(r, TUNNEL_PING_INTERVAL_MS)); - try { - const ping = await fetch(healthUrl, { mode: "no-cors", cache: "no-store" }); - if (ping.ok || ping.type === "opaque") { - setTunnelEnabled(true); - setTunnelLoading(false); - setTunnelProgress(""); - return true; - } - } catch { /* not ready yet */ } + const ok = await Promise.any(targets.map(async (h) => { + const p = await fetch(h, { mode: "no-cors", cache: "no-store" }); + if (p.ok || p.type === "opaque") return true; + throw new Error("not ready"); + })).catch(() => false); + if (ok) { + setTunnelEnabled(true); + setTunnelLoading(false); + setTunnelProgress(""); + return true; + } // Every 5 pings (~10s), check if backend process still alive if ((Date.now() - start) % 10000 < TUNNEL_PING_INTERVAL_MS) { try { @@ -407,7 +420,7 @@ export default function APIPageClient({ machineId }) { setTunnelUrl(url); setTunnelPublicUrl(data.publicUrl || ""); - await pingTunnelHealth(url); + await pingTunnelHealth(data.publicUrl, url); } catch (error) { setTunnelStatus({ type: "error", message: error.message }); } finally { diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflared.js index bfc55db3..644ba397 100644 --- a/src/lib/tunnel/cloudflared.js +++ b/src/lib/tunnel/cloudflared.js @@ -373,7 +373,6 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate) { cloudflaredProcess = null; clearPid(); console.log(`[Tunnel] cloudflared exit code=${code} signal=${signal}`); - if (logTail) console.log(`[Tunnel] cloudflared log tail:\n${logTail.slice(-1500)}`); if (!resolved) { resolved = true; clearTimeout(timeout); diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js index 46420f0e..9a89d505 100644 --- a/src/lib/tunnel/tunnelManager.js +++ b/src/lib/tunnel/tunnelManager.js @@ -1,4 +1,3 @@ -import crypto from "crypto"; import { loadState, saveState, generateShortId, clearPid } from "./state.js"; import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js"; import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js"; @@ -9,7 +8,6 @@ import { waitForHealth, probeUrlAlive } from "./networkProbe.js"; initDbHooks(getSettings, updateSettings); const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://abc-tunnel.us"; -const MACHINE_ID_SALT = "9router-tunnel-salt"; // Per-service state (independent: tunnel ≠ tailscale) const tunnelSvc = { @@ -33,43 +31,6 @@ export function isTunnelManuallyDisabled() { return tunnelSvc.cancelToken.cancel export function isTunnelReconnecting() { return tunnelSvc.spawnInProgress; } export function isTailscaleReconnecting() { return tailscaleSvc.spawnInProgress; } -// ─── Reachable cache: background probe of tunnel URL /api/health ───────────── -// UI uses this to know if the public URL actually serves content (not just process alive) -const REACHABLE_TTL_MS = 30000; -const tunnelReachable = { value: false, url: null, fetchedAt: 0, refreshing: false }; -const tailscaleReachable = { value: false, url: null, fetchedAt: 0, refreshing: false }; - -function bgRefreshReachable(cache, url) { - if (cache.refreshing) return; - if (!url) { cache.value = false; cache.url = null; cache.fetchedAt = Date.now(); return; } - cache.refreshing = true; - probeUrlAlive(url) - .then((ok) => { cache.value = ok; }) - .catch(() => { cache.value = false; }) - .finally(() => { - cache.url = url; - cache.fetchedAt = Date.now(); - cache.refreshing = false; - }); -} - -function readReachable(cache, url) { - // URL changed → invalidate - if (cache.url !== url) { cache.value = false; cache.fetchedAt = 0; } - if (Date.now() - cache.fetchedAt > REACHABLE_TTL_MS) bgRefreshReachable(cache, url); - return cache.value; -} - -function getMachineId() { - try { - const { machineIdSync } = require("node-machine-id"); - const raw = machineIdSync(); - return crypto.createHash("sha256").update(raw + MACHINE_ID_SALT).digest("hex").substring(0, 16); - } catch (e) { - return crypto.randomUUID().replace(/-/g, "").substring(0, 16); - } -} - // ─── Cloudflare Tunnel ─────────────────────────────────────────────────────── async function registerTunnelUrl(shortId, tunnelUrl) { @@ -105,7 +66,6 @@ export async function enableTunnel(localPort = 20128) { console.log("[Tunnel] killed existing cloudflared"); throwIfCancelled(token, "tunnel"); - const machineId = getMachineId(); const existing = loadState(); const shortId = existing?.shortId || generateShortId(); @@ -113,7 +73,7 @@ export async function enableTunnel(localPort = 20128) { if (token.cancelled) return; console.log(`[Tunnel] url updated: ${url}`); await registerTunnelUrl(shortId, url); - saveState({ shortId, machineId, tunnelUrl: url }); + saveState({ shortId, tunnelUrl: url }); await updateSettings({ tunnelEnabled: true, tunnelUrl: url }); }; @@ -123,7 +83,7 @@ export async function enableTunnel(localPort = 20128) { const publicUrl = `https://r${shortId}.abc-tunnel.us`; await registerTunnelUrl(shortId, tunnelUrl); - saveState({ shortId, machineId, tunnelUrl }); + saveState({ shortId, tunnelUrl }); await updateSettings({ tunnelEnabled: true, tunnelUrl }); console.log(`[Tunnel] registered shortId=${shortId} publicUrl=${publicUrl}`); @@ -137,11 +97,6 @@ export async function enableTunnel(localPort = 20128) { console.log("[Tunnel] direct URL healthy"); } - // Prime reachable cache so UI shows correct state immediately - tunnelReachable.value = true; - tunnelReachable.url = tunnelUrl; - tunnelReachable.fetchedAt = Date.now(); - console.log("[Tunnel] enable success"); return { success: true, tunnelUrl, shortId, publicUrl }; } catch (e) { @@ -162,10 +117,9 @@ export async function disableTunnel() { clearPid(); const state = loadState(); - if (state) saveState({ shortId: state.shortId, machineId: state.machineId, tunnelUrl: null }); + if (state) saveState({ shortId: state.shortId, tunnelUrl: null }); await updateSettings({ tunnelEnabled: false, tunnelUrl: "" }); - tunnelReachable.value = false; tunnelReachable.url = null; tunnelReachable.fetchedAt = Date.now(); // Force-clear flags so a subsequent enable is not blocked by a stuck spawnInProgress tunnelSvc.spawnInProgress = false; tunnelSvc.activeLocalPort = null; @@ -182,8 +136,6 @@ export async function getTunnelStatus() { // Lazy: skip PID probe entirely when user disabled tunnel const running = settingsEnabled ? isCloudflaredRunning() : false; - // Reachable: cached background probe (never blocks the request) - const reachable = settingsEnabled && running ? readReachable(tunnelReachable, tunnelUrl) : false; return { enabled: settingsEnabled && running, @@ -191,8 +143,7 @@ export async function getTunnelStatus() { tunnelUrl, shortId, publicUrl, - running, - reachable + running }; } @@ -272,12 +223,6 @@ export async function enableTailscale(localPort = 20128) { if (!he.message.startsWith("Health check timeout")) throw he; console.warn(`[Tailscale] health check timed out, will retry via watchdog`); } - - if (reachableNow) { - tailscaleReachable.value = true; - tailscaleReachable.url = result.tunnelUrl; - tailscaleReachable.fetchedAt = Date.now(); - } console.log(`[Tailscale] enable success (reachable=${reachableNow})`); return { success: true, tunnelUrl: result.tunnelUrl }; } catch (e) { @@ -293,7 +238,6 @@ export async function disableTailscale() { tailscaleSvc.cancelToken.cancelled = true; stopFunnel(); await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" }); - tailscaleReachable.value = false; tailscaleReachable.url = null; tailscaleReachable.fetchedAt = Date.now(); return { success: true }; } @@ -304,14 +248,11 @@ export async function getTailscaleStatus() { // Skip probes entirely when disabled; check login before running (device removed = not logged in) const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false; const running = loggedIn ? isTailscaleRunning() : false; - // Reachable: cached background probe (never blocks the request) - const reachable = settingsEnabled && running ? readReachable(tailscaleReachable, tunnelUrl) : false; return { enabled: settingsEnabled && running, settingsEnabled, tunnelUrl, running, - loggedIn, - reachable + loggedIn }; } diff --git a/src/shared/utils/machineId.js b/src/shared/utils/machineId.js index 68c6ae2a..92ceb210 100644 --- a/src/shared/utils/machineId.js +++ b/src/shared/utils/machineId.js @@ -5,7 +5,11 @@ import crypto from 'node:crypto'; import { DATA_DIR } from '@/lib/dataDir'; const MACHINE_ID_FILE = path.join(DATA_DIR, 'machine-id'); +const AUTH_DIR = path.join(DATA_DIR, 'auth'); +const CLI_SECRET_FILE = path.join(AUTH_DIR, 'cli-secret'); +const CLI_AUTH_SALT = '9r-cli-auth'; let cachedRawId = null; +let cachedCliSecret = null; // Persist raw machine ID to file → guarantees CLI/server/middleware see same value // even when machineIdSync fails or returns inconsistent values across runtimes. @@ -27,10 +31,26 @@ function loadRawMachineId() { return cachedRawId; } +// Random secret persisted on first run → unpredictable CLI token even when machineId leaks. +function loadCliSecret() { + if (cachedCliSecret) return cachedCliSecret; + try { + cachedCliSecret = fs.readFileSync(CLI_SECRET_FILE, 'utf8').trim(); + if (cachedCliSecret) return cachedCliSecret; + } catch {} + cachedCliSecret = crypto.randomBytes(32).toString('hex'); + try { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + fs.writeFileSync(CLI_SECRET_FILE, cachedCliSecret, { mode: 0o600 }); + } catch {} + return cachedCliSecret; +} + export async function getConsistentMachineId(salt = null) { const saltValue = salt || process.env.MACHINE_ID_SALT || 'endpoint-proxy-salt'; const raw = loadRawMachineId(); - return crypto.createHash('sha256').update(raw + saltValue).digest('hex').substring(0, 16); + const extra = saltValue === CLI_AUTH_SALT ? loadCliSecret() : ''; + return crypto.createHash('sha256').update(raw + saltValue + extra).digest('hex').substring(0, 16); } export async function getRawMachineId() {