diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js
index c449559f..63b0fdf9 100644
--- a/open-sse/services/tokenRefresh.js
+++ b/open-sse/services/tokenRefresh.js
@@ -7,35 +7,64 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js";
let _xaiServiceSingleton = null;
async function refreshXaiToken(refreshToken, log) {
if (!refreshToken) return null;
- try {
- if (!_xaiServiceSingleton) {
- const mod = await import("../../src/lib/oauth/services/xai.js");
- _xaiServiceSingleton = new mod.XaiService();
+ return dedupRefresh("xai", refreshToken, async () => {
+ try {
+ if (!_xaiServiceSingleton) {
+ const mod = await import("../../src/lib/oauth/services/xai.js");
+ _xaiServiceSingleton = new mod.XaiService();
+ }
+ const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken);
+ return {
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token || refreshToken,
+ expiresIn: tokens.expires_in,
+ idToken: tokens.id_token,
+ };
+ } catch (e) {
+ log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`);
+ const msg = String(e?.message || "");
+ if (msg.includes("invalid_grant") || msg.includes("invalid_request")) {
+ return { error: "invalid_grant" };
+ }
+ return null;
}
- const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken);
- return {
- accessToken: tokens.access_token,
- refreshToken: tokens.refresh_token || refreshToken,
- expiresIn: tokens.expires_in,
- idToken: tokens.id_token,
- };
- } catch (e) {
- log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`);
- const msg = String(e?.message || "");
- if (msg.includes("invalid_grant") || msg.includes("invalid_request")) {
- return { error: "invalid_grant" };
- }
- return null;
- }
+ }, log);
}
// Default token expiry buffer (refresh if expires within 5 minutes)
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
-// In-flight refresh dedup: prevents race condition that triggers refresh_token_reused → Auth0 family revoke
-const refreshPromiseCache = new Map();
-function getRefreshCacheKey(provider, refreshToken) {
- return `${provider}:${refreshToken}`;
+// Dedup: cache in-flight promise + recent result to prevent refresh_token_reused (Auth0 family revoke)
+const REFRESH_RESULT_TTL_MS = 10_000;
+const refreshDedupCache = new Map();
+
+async function dedupRefresh(provider, oldToken, fn, log) {
+ if (!oldToken) return fn();
+ const key = `${provider}:${oldToken}`;
+ const hit = refreshDedupCache.get(key);
+ if (hit) {
+ if (hit.promise) {
+ log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
+ return hit.promise;
+ }
+ if (hit.expiresAt > Date.now()) {
+ log?.info?.("TOKEN_REFRESH", `Reusing recent refresh result for ${provider}`);
+ return hit.result;
+ }
+ refreshDedupCache.delete(key);
+ }
+ const promise = (async () => {
+ try {
+ const result = await fn();
+ refreshDedupCache.set(key, { result, expiresAt: Date.now() + REFRESH_RESULT_TTL_MS });
+ return result;
+ } catch (err) {
+ refreshDedupCache.delete(key);
+ throw err;
+ }
+ })();
+ refreshDedupCache.set(key, { promise });
+ return promise;
}
// Check if refresh result indicates unrecoverable error (caller should stop retry, force re-auth)
@@ -71,6 +100,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
return null;
}
+ return dedupRefresh(provider, refreshToken, async () => {
try {
const response = await fetch(config.refreshUrl, {
method: "POST",
@@ -114,12 +144,15 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
});
return null;
}
+ }, log);
}
/**
* Specialized refresh for Claude OAuth tokens
*/
export async function refreshClaudeOAuthToken(refreshToken, log) {
+ if (!refreshToken) return null;
+ return dedupRefresh("claude", refreshToken, async () => {
try {
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
method: "POST",
@@ -147,12 +180,15 @@ export async function refreshClaudeOAuthToken(refreshToken, log) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
return null;
}
+ }, log);
}
/**
* Specialized refresh for Google providers (Gemini, Antigravity)
*/
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
+ if (!refreshToken) return null;
+ return dedupRefresh(`google:${clientId}`, refreshToken, async () => {
try {
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
method: "POST",
@@ -181,12 +217,15 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l
log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`);
return null;
}
+ }, log);
}
/**
* Specialized refresh for Qwen OAuth tokens
*/
export async function refreshQwenToken(refreshToken, log) {
+ if (!refreshToken) return null;
+ return dedupRefresh("qwen", refreshToken, async () => {
const endpoint = OAUTH_ENDPOINTS.qwen.token;
try {
@@ -235,6 +274,7 @@ export async function refreshQwenToken(refreshToken, log) {
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
return null;
+ }, log);
}
/**
@@ -244,6 +284,8 @@ export async function refreshQwenToken(refreshToken, log) {
* so callers stop retrying and request re-authentication.
*/
export async function refreshCodexToken(refreshToken, log) {
+ if (!refreshToken) return null;
+ return dedupRefresh("codex", refreshToken, async () => {
try {
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
method: "POST",
@@ -306,6 +348,7 @@ export async function refreshCodexToken(refreshToken, log) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
return null;
}
+ }, log);
}
/**
@@ -313,6 +356,8 @@ export async function refreshCodexToken(refreshToken, log) {
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
*/
export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) {
+ if (!refreshToken) return null;
+ return dedupRefresh("kiro", refreshToken, async () => {
const authMethod = providerSpecificData?.authMethod;
const clientId = providerSpecificData?.clientId;
const clientSecret = providerSpecificData?.clientSecret;
@@ -397,12 +442,15 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
};
+ }, log);
}
/**
* Specialized refresh for iFlow OAuth tokens
*/
export async function refreshIflowToken(refreshToken, log) {
+ if (!refreshToken) return null;
+ return dedupRefresh("iflow", refreshToken, async () => {
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
@@ -442,12 +490,15 @@ export async function refreshIflowToken(refreshToken, log) {
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
+ }, log);
}
/**
* Specialized refresh for GitHub Copilot OAuth tokens
*/
export async function refreshGitHubToken(refreshToken, log) {
+ if (!refreshToken) return null;
+ return dedupRefresh("github", refreshToken, async () => {
const params = {
grant_type: "refresh_token",
refresh_token: refreshToken,
@@ -488,12 +539,15 @@ export async function refreshGitHubToken(refreshToken, log) {
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
+ }, log);
}
/**
* Refresh GitHub Copilot token using GitHub access token
*/
export async function refreshCopilotToken(githubAccessToken, log) {
+ if (!githubAccessToken) return null;
+ return dedupRefresh("copilot", githubAccessToken, async () => {
try {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
headers: {
@@ -532,6 +586,7 @@ export async function refreshCopilotToken(githubAccessToken, log) {
});
return null;
}
+ }, log);
}
/**
@@ -544,20 +599,8 @@ export async function getAccessToken(provider, credentials, log) {
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
return null;
}
-
- const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
-
- if (refreshPromiseCache.has(cacheKey)) {
- log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
- return refreshPromiseCache.get(cacheKey);
- }
-
- const refreshPromise = _getAccessTokenInternal(provider, credentials, log).finally(() => {
- refreshPromiseCache.delete(cacheKey);
- });
-
- refreshPromiseCache.set(cacheKey, refreshPromise);
- return refreshPromise;
+ // Dedup is handled inside each refreshXxxToken function
+ return _getAccessTokenInternal(provider, credentials, log);
}
async function _getAccessTokenInternal(provider, credentials, log) {
diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
index ce081ea1..0c5fc563 100644
--- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
+++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
@@ -21,8 +21,9 @@ const CLIENT_PING_FAST_MS = 10000;
const CLIENT_PING_SLOW_MS = 60000;
const CLIENT_PING_TIMEOUT_MS = 5000;
-// Browser-side health probe: must reach origin (not just CF edge).
-// CORS mode → res.ok=false for 5xx (Cloudflare Error 1033 when origin dead).
+// Browser-side health probe: must reach origin (not just CF/TS edge).
+// cors mode → res.ok=false for 5xx (e.g. Cloudflare 530 when origin dead).
+// /api/health route sets Access-Control-Allow-Origin: * → CORS works through tunnel.
async function clientPingUrl(url) {
if (!url) return false;
try {
diff --git a/src/app/(dashboard)/dashboard/proxy-pools/page.js b/src/app/(dashboard)/dashboard/proxy-pools/page.js
index fcfa986d..8edf791f 100644
--- a/src/app/(dashboard)/dashboard/proxy-pools/page.js
+++ b/src/app/(dashboard)/dashboard/proxy-pools/page.js
@@ -577,9 +577,6 @@ export default function ProxyPoolsPage() {
Proxy Pools
-
- Manage reusable per-connection proxies and bind them to provider connections.
-
diff --git a/src/app/api/tunnel/disable/route.js b/src/app/api/tunnel/disable/route.js
index e0735f85..2d49e031 100644
--- a/src/app/api/tunnel/disable/route.js
+++ b/src/app/api/tunnel/disable/route.js
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
-import { disableTunnel } from "@/lib/tunnel/tunnelManager";
+import { disableTunnel } from "@/lib/tunnel";
export async function POST() {
try {
diff --git a/src/app/api/tunnel/enable/route.js b/src/app/api/tunnel/enable/route.js
index 2a2e08d3..910ad617 100644
--- a/src/app/api/tunnel/enable/route.js
+++ b/src/app/api/tunnel/enable/route.js
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
-import { enableTunnel } from "@/lib/tunnel/tunnelManager";
+import { enableTunnel } from "@/lib/tunnel";
const DNS_WARMUP_DELAY_MS = 8000;
diff --git a/src/app/api/tunnel/status/route.js b/src/app/api/tunnel/status/route.js
index 907cb77b..c94df58d 100644
--- a/src/app/api/tunnel/status/route.js
+++ b/src/app/api/tunnel/status/route.js
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
-import { getTunnelStatus, getTailscaleStatus } from "@/lib/tunnel/tunnelManager";
-import { getDownloadStatus } from "@/lib/tunnel/cloudflared";
+import { getTunnelStatus, getTailscaleStatus, getDownloadStatus } from "@/lib/tunnel";
export async function GET() {
try {
diff --git a/src/app/api/tunnel/tailscale-check/route.js b/src/app/api/tunnel/tailscale-check/route.js
index f7519214..25676479 100644
--- a/src/app/api/tunnel/tailscale-check/route.js
+++ b/src/app/api/tunnel/tailscale-check/route.js
@@ -2,7 +2,7 @@ import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
import { NextResponse } from "next/server";
-import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel/tailscale";
+import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel";
import { getCachedPassword, loadEncryptedPassword } from "@/mitm/manager";
const execAsync = promisify(exec);
diff --git a/src/app/api/tunnel/tailscale-disable/route.js b/src/app/api/tunnel/tailscale-disable/route.js
index 67ce1600..1a0dca08 100644
--- a/src/app/api/tunnel/tailscale-disable/route.js
+++ b/src/app/api/tunnel/tailscale-disable/route.js
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
-import { disableTailscale } from "@/lib/tunnel/tunnelManager";
+import { disableTailscale } from "@/lib/tunnel";
export async function POST() {
try {
diff --git a/src/app/api/tunnel/tailscale-enable/route.js b/src/app/api/tunnel/tailscale-enable/route.js
index 2115776a..61b7bda1 100644
--- a/src/app/api/tunnel/tailscale-enable/route.js
+++ b/src/app/api/tunnel/tailscale-enable/route.js
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
-import { enableTailscale } from "@/lib/tunnel/tunnelManager";
+import { enableTailscale } from "@/lib/tunnel";
export async function POST() {
try {
diff --git a/src/app/api/tunnel/tailscale-install/route.js b/src/app/api/tunnel/tailscale-install/route.js
index 93353e15..f53c5eec 100644
--- a/src/app/api/tunnel/tailscale-install/route.js
+++ b/src/app/api/tunnel/tailscale-install/route.js
@@ -2,10 +2,9 @@
import os from "os";
import { execSync } from "child_process";
-import { installTailscale } from "@/lib/tunnel/tailscale";
+import { installTailscale, loadState, generateShortId } from "@/lib/tunnel";
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
import { getSettings, updateSettings } from "@/lib/localDb";
-import { loadState, generateShortId } from "@/lib/tunnel/state.js";
initDbHooks(getSettings, updateSettings);
diff --git a/src/app/api/tunnel/tailscale-login/route.js b/src/app/api/tunnel/tailscale-login/route.js
deleted file mode 100644
index 516e4300..00000000
--- a/src/app/api/tunnel/tailscale-login/route.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { NextResponse } from "next/server";
-import { startLogin } from "@/lib/tunnel/tailscale";
-import { loadState, generateShortId } from "@/lib/tunnel/state.js";
-
-export async function POST() {
- try {
- const shortId = loadState()?.shortId || generateShortId();
- const result = await startLogin(shortId);
- return NextResponse.json(result);
- } catch (error) {
- console.error("Tailscale login error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
- }
-}
diff --git a/src/app/api/tunnel/tailscale-start-daemon/route.js b/src/app/api/tunnel/tailscale-start-daemon/route.js
deleted file mode 100644
index 826f8b03..00000000
--- a/src/app/api/tunnel/tailscale-start-daemon/route.js
+++ /dev/null
@@ -1,21 +0,0 @@
-"use server";
-
-import { NextResponse } from "next/server";
-import { startDaemonWithPassword } from "@/lib/tunnel/tailscale";
-import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
-import { getSettings, updateSettings } from "@/lib/localDb";
-
-initDbHooks(getSettings, updateSettings);
-
-export async function POST(request) {
- try {
- const body = await request.json().catch(() => ({}));
- // Use provided password, or fall back to cached/stored MITM password
- const password = body.sudoPassword || getCachedPassword() || await loadEncryptedPassword() || "";
- await startDaemonWithPassword(password);
- return NextResponse.json({ success: true });
- } catch (error) {
- console.error("Tailscale start daemon error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
- }
-}
diff --git a/src/app/layout.js b/src/app/layout.js
index 3e9d10e4..b6c125c9 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -3,6 +3,7 @@ import "material-symbols/outlined.css";
import "./globals.css";
import { ThemeProvider } from "@/shared/components/ThemeProvider";
import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env
+import "@/shared/services/bootstrap"; // Auto-run initializeApp (watchdog, auto-resume tunnel)
import { initConsoleLogCapture } from "@/lib/consoleLogBuffer";
import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider";
diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js
index 61af0939..989c6a1e 100644
--- a/src/dashboardGuard.js
+++ b/src/dashboardGuard.js
@@ -73,8 +73,6 @@ const LOCAL_ONLY_PATHS = [
"/api/tunnel/tailscale-install",
"/api/tunnel/tailscale-enable",
"/api/tunnel/tailscale-disable",
- "/api/tunnel/tailscale-login",
- "/api/tunnel/tailscale-start-daemon",
"/api/tunnel/tailscale-check",
"/api/tunnel/enable",
"/api/tunnel/disable",
diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflare/cloudflared.js
similarity index 99%
rename from src/lib/tunnel/cloudflared.js
rename to src/lib/tunnel/cloudflare/cloudflared.js
index 4eb2f207..9d90ca60 100644
--- a/src/lib/tunnel/cloudflared.js
+++ b/src/lib/tunnel/cloudflare/cloudflared.js
@@ -3,7 +3,7 @@ import path from "path";
import https from "https";
import os from "os";
import { execSync, spawn } from "child_process";
-import { savePid, loadPid, clearPid } from "./state.js";
+import { savePid, loadPid, clearPid } from "./pid.js";
import { DATA_DIR } from "@/lib/dataDir.js";
const BIN_DIR = path.join(DATA_DIR, "bin");
diff --git a/src/lib/tunnel/cloudflare/config.js b/src/lib/tunnel/cloudflare/config.js
new file mode 100644
index 00000000..2cc3f9db
--- /dev/null
+++ b/src/lib/tunnel/cloudflare/config.js
@@ -0,0 +1,9 @@
+// Cloudflare quick tunnel: DNS propagates fast, short timeouts OK
+export const HEALTH_CHECK = {
+ intervalMs: 2000,
+ timeoutMs: 60000,
+ fetchTimeoutMs: 5000,
+ dnsTimeoutMs: 2000,
+};
+
+export const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://abc-tunnel.us";
diff --git a/src/lib/tunnel/cloudflare/healthCheck.js b/src/lib/tunnel/cloudflare/healthCheck.js
new file mode 100644
index 00000000..428b351d
--- /dev/null
+++ b/src/lib/tunnel/cloudflare/healthCheck.js
@@ -0,0 +1,29 @@
+import { resolveDns } from "../shared/dnsResolver.js";
+import { HEALTH_CHECK } from "./config.js";
+
+export async function probeUrlAlive(url) {
+ if (!url) return false;
+ let hostname;
+ try { hostname = new URL(url).hostname; } catch { return false; }
+
+ if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false;
+
+ try {
+ const res = await fetch(`${url}/api/health`, {
+ signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs),
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+export async function waitForHealth(url, cancelToken = { cancelled: false }) {
+ const start = Date.now();
+ while (Date.now() - start < HEALTH_CHECK.timeoutMs) {
+ if (cancelToken.cancelled) throw new Error("cancelled");
+ if (await probeUrlAlive(url)) return true;
+ await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs));
+ }
+ throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`);
+}
diff --git a/src/lib/tunnel/cloudflare/manager.js b/src/lib/tunnel/cloudflare/manager.js
new file mode 100644
index 00000000..44f66ae3
--- /dev/null
+++ b/src/lib/tunnel/cloudflare/manager.js
@@ -0,0 +1,148 @@
+import { loadState, saveState, generateShortId } from "../shared/state.js";
+import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js";
+import { clearPid } from "./pid.js";
+import { waitForHealth, probeUrlAlive } from "./healthCheck.js";
+import { WORKER_URL } from "./config.js";
+import { getSettings, updateSettings } from "@/lib/localDb";
+
+const svc = {
+ cancelToken: { cancelled: false },
+ spawnInProgress: false,
+ lastRestartAt: 0,
+ activeLocalPort: null,
+};
+
+export function getTunnelService() { return svc; }
+export function isTunnelManuallyDisabled() { return svc.cancelToken.cancelled; }
+export function isTunnelReconnecting() { return svc.spawnInProgress; }
+
+let onUnexpectedExit = null;
+export function setTunnelUnexpectedExitCallback(cb) { onUnexpectedExit = cb; }
+
+async function registerTunnelUrl(shortId, tunnelUrl) {
+ await fetch(`${WORKER_URL}/api/tunnel/register`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ shortId, tunnelUrl })
+ });
+}
+
+function throwIfCancelled(token) {
+ if (token.cancelled) throw new Error("tunnel cancelled");
+}
+
+export async function enableTunnel(localPort = 20128) {
+ console.log(`[Tunnel] enable start (port=${localPort})`);
+ svc.cancelToken = { cancelled: false };
+ svc.activeLocalPort = localPort;
+ svc.spawnInProgress = true;
+ const token = svc.cancelToken;
+
+ try {
+ if (isCloudflaredRunning()) {
+ const existing = loadState();
+ if (existing?.tunnelUrl && existing?.shortId) {
+ const publicUrl = `https://r${existing.shortId}.abc-tunnel.us`;
+ // Reuse only if BOTH direct + public URL alive (avoid stale socket after network change)
+ const [directOk, publicOk] = await Promise.all([
+ probeUrlAlive(existing.tunnelUrl),
+ probeUrlAlive(publicUrl),
+ ]);
+ if (directOk && publicOk) {
+ console.log(`[Tunnel] already running, reuse: ${existing.tunnelUrl}`);
+ return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true };
+ }
+ console.log(`[Tunnel] stale (direct=${directOk} public=${publicOk}), respawn`);
+ }
+ }
+
+ killCloudflared(localPort);
+ console.log("[Tunnel] killed existing cloudflared");
+ throwIfCancelled(token);
+
+ const existing = loadState();
+ const shortId = existing?.shortId || generateShortId();
+
+ const onUrlUpdate = async (url) => {
+ if (token.cancelled) return;
+ console.log(`[Tunnel] url updated: ${url}`);
+ await registerTunnelUrl(shortId, url);
+ saveState({ shortId, tunnelUrl: url });
+ await updateSettings({ tunnelEnabled: true, tunnelUrl: url });
+ };
+
+ // Register exit handler BEFORE spawn so it fires even on early exit
+ setUnexpectedExitHandler(() => {
+ console.warn("[Tunnel] cloudflared exited unexpectedly, scheduling respawn");
+ if (onUnexpectedExit) onUnexpectedExit();
+ });
+
+ const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate);
+ console.log(`[Tunnel] spawned: ${tunnelUrl}`);
+ throwIfCancelled(token);
+
+ const publicUrl = `https://r${shortId}.abc-tunnel.us`;
+ await registerTunnelUrl(shortId, tunnelUrl);
+ saveState({ shortId, tunnelUrl });
+ await updateSettings({ tunnelEnabled: true, tunnelUrl });
+ console.log(`[Tunnel] registered shortId=${shortId} publicUrl=${publicUrl}`);
+
+ // Verify publicUrl first (worker route is reliable; direct *.trycloudflare.com DNS may lag)
+ await waitForHealth(publicUrl, token);
+ console.log("[Tunnel] public URL healthy");
+ // Direct tunnel probe is best-effort: DNS for *.trycloudflare.com can be slow/blocked
+ if (!(await probeUrlAlive(tunnelUrl))) {
+ console.warn("[Tunnel] direct URL not reachable yet, continuing via publicUrl");
+ } else {
+ console.log("[Tunnel] direct URL healthy");
+ }
+
+ console.log("[Tunnel] enable success");
+ return { success: true, tunnelUrl, shortId, publicUrl };
+ } catch (e) {
+ console.error(`[Tunnel] enable error: ${e.message}`);
+ throw e;
+ } finally {
+ svc.spawnInProgress = false;
+ }
+}
+
+export async function disableTunnel() {
+ console.log("[Tunnel] disable");
+ // Abort any in-flight enable so it cannot resurrect state after we clear it
+ svc.cancelToken.cancelled = true;
+ setUnexpectedExitHandler(null);
+
+ try { killCloudflared(svc.activeLocalPort); } catch (e) { console.warn(`[Tunnel] kill warn: ${e.message}`); }
+ clearPid();
+
+ const state = loadState();
+ if (state) saveState({ shortId: state.shortId, tunnelUrl: null });
+
+ await updateSettings({ tunnelEnabled: false, tunnelUrl: "" });
+ // Force-clear flags so a subsequent enable is not blocked by a stuck spawnInProgress
+ svc.spawnInProgress = false;
+ svc.activeLocalPort = null;
+ return { success: true };
+}
+
+export async function getTunnelStatus() {
+ const settings = await getSettings();
+ const settingsEnabled = settings.tunnelEnabled === true;
+ const state = loadState();
+ const shortId = state?.shortId || "";
+ const publicUrl = shortId ? `https://r${shortId}.abc-tunnel.us` : "";
+ const tunnelUrl = state?.tunnelUrl || "";
+
+ // Lazy: skip PID probe entirely when user disabled tunnel
+ const running = settingsEnabled ? isCloudflaredRunning() : false;
+
+ return {
+ enabled: settingsEnabled && running,
+ settingsEnabled,
+ tunnelUrl,
+ shortId,
+ publicUrl,
+ running
+ };
+}
diff --git a/src/lib/tunnel/cloudflare/pid.js b/src/lib/tunnel/cloudflare/pid.js
new file mode 100644
index 00000000..919837c5
--- /dev/null
+++ b/src/lib/tunnel/cloudflare/pid.js
@@ -0,0 +1,23 @@
+import fs from "fs";
+import path from "path";
+import { TUNNEL_DIR, ensureTunnelDir } from "../shared/state.js";
+
+const PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid");
+
+export function savePid(pid) {
+ ensureTunnelDir();
+ fs.writeFileSync(PID_FILE, pid.toString());
+}
+
+export function loadPid() {
+ try {
+ if (fs.existsSync(PID_FILE)) return parseInt(fs.readFileSync(PID_FILE, "utf8"));
+ } catch { /* ignore */ }
+ return null;
+}
+
+export function clearPid() {
+ try {
+ if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
+ } catch { /* ignore */ }
+}
diff --git a/src/lib/tunnel/index.js b/src/lib/tunnel/index.js
new file mode 100644
index 00000000..19459e17
--- /dev/null
+++ b/src/lib/tunnel/index.js
@@ -0,0 +1,46 @@
+// Cloudflare service
+export {
+ enableTunnel,
+ disableTunnel,
+ getTunnelStatus,
+ isTunnelManuallyDisabled,
+ isTunnelReconnecting,
+ getTunnelService,
+ setTunnelUnexpectedExitCallback,
+} from "./cloudflare/manager.js";
+export {
+ killCloudflared,
+ isCloudflaredRunning,
+ ensureCloudflared,
+ getDownloadStatus,
+} from "./cloudflare/cloudflared.js";
+export { probeUrlAlive as probeCloudflareAlive } from "./cloudflare/healthCheck.js";
+
+// Tailscale service
+export {
+ enableTailscale,
+ disableTailscale,
+ getTailscaleStatus,
+ isTailscaleReconnecting,
+ getTailscaleService,
+} from "./tailscale/manager.js";
+export {
+ isTailscaleInstalled,
+ isTailscaleRunning,
+ isTailscaleLoggedIn,
+ installTailscale,
+ startLogin,
+ startDaemonWithPassword,
+ TAILSCALE_SOCKET,
+} from "./tailscale/tailscale.js";
+export { probeUrlAlive as probeTailscaleAlive } from "./tailscale/healthCheck.js";
+
+// Shared
+export { loadState, generateShortId } from "./shared/state.js";
+export { checkInternet } from "./shared/internetCheck.js";
+export {
+ RESTART_COOLDOWN_MS,
+ NETWORK_SETTLE_MS,
+ WATCHDOG_INTERVAL_MS,
+ NETWORK_CHECK_INTERVAL_MS,
+} from "./shared/watchdogConfig.js";
diff --git a/src/lib/tunnel/networkProbe.js b/src/lib/tunnel/networkProbe.js
deleted file mode 100644
index c7b6c4fb..00000000
--- a/src/lib/tunnel/networkProbe.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import net from "net";
-import dns from "dns";
-import { INTERNET_CHECK, HEALTH_CHECK } from "./tunnelConfig.js";
-
-// Force public DNS to bypass OS negative cache (mDNSResponder holds NXDOMAIN)
-const resolver = new dns.promises.Resolver();
-resolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]);
-
-export function checkInternet() {
- return new Promise((resolve) => {
- const socket = new net.Socket();
- let done = false;
- const finish = (ok) => {
- if (done) return;
- done = true;
- try { socket.destroy(); } catch { /* ignore */ }
- resolve(ok);
- };
- socket.setTimeout(INTERNET_CHECK.timeoutMs);
- socket.once("connect", () => finish(true));
- socket.once("timeout", () => finish(false));
- socket.once("error", () => finish(false));
- try { socket.connect(INTERNET_CHECK.port, INTERNET_CHECK.host); }
- catch { finish(false); }
- });
-}
-
-async function resolveDns(hostname, timeoutMs) {
- // Try custom public DNS first (bypasses negative-cached NXDOMAIN on macOS).
- // Fall back to OS resolver for hostnames blocked or unsupported by Cloudflare DNS
- // (e.g. *.ts.net not always resolvable via 1.1.1.1).
- const tryResolver = (fn) => Promise.race([
- fn(),
- new Promise((_, rej) => setTimeout(() => rej(new Error("dns timeout")), timeoutMs)),
- ]).then(() => true).catch(() => false);
-
- if (await tryResolver(() => resolver.resolve4(hostname))) return true;
- return tryResolver(() => dns.promises.resolve4(hostname));
-}
-
-// Single health probe: DNS via 1.1.1.1 → fetch /api/health
-export async function probeUrlAlive(url) {
- if (!url) return false;
- let hostname;
- try { hostname = new URL(url).hostname; } catch { return false; }
-
- if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false;
-
- try {
- const res = await fetch(`${url}/api/health`, {
- signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs),
- });
- return res.ok;
- } catch {
- return false;
- }
-}
-
-// Poll until tunnel responds /api/health, or timeout. Cancellable via token.
-export async function waitForHealth(url, cancelToken = { cancelled: false }) {
- const start = Date.now();
- while (Date.now() - start < HEALTH_CHECK.timeoutMs) {
- if (cancelToken.cancelled) throw new Error("cancelled");
- if (await probeUrlAlive(url)) return true;
- await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs));
- }
- throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`);
-}
diff --git a/src/lib/tunnel/shared/dnsResolver.js b/src/lib/tunnel/shared/dnsResolver.js
new file mode 100644
index 00000000..e8fde7d3
--- /dev/null
+++ b/src/lib/tunnel/shared/dnsResolver.js
@@ -0,0 +1,17 @@
+import dns from "dns";
+
+// Force public DNS to bypass OS negative cache (mDNSResponder holds NXDOMAIN)
+const resolver = new dns.promises.Resolver();
+resolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]);
+
+// Try custom public DNS first, fall back to OS resolver
+// (Cloudflare DNS may not resolve all hostnames, e.g. *.ts.net)
+export async function resolveDns(hostname, timeoutMs) {
+ const tryResolver = (fn) => Promise.race([
+ fn(),
+ new Promise((_, rej) => setTimeout(() => rej(new Error("dns timeout")), timeoutMs)),
+ ]).then(() => true).catch(() => false);
+
+ if (await tryResolver(() => resolver.resolve4(hostname))) return true;
+ return tryResolver(() => dns.promises.resolve4(hostname));
+}
diff --git a/src/lib/tunnel/shared/internetCheck.js b/src/lib/tunnel/shared/internetCheck.js
new file mode 100644
index 00000000..756640d9
--- /dev/null
+++ b/src/lib/tunnel/shared/internetCheck.js
@@ -0,0 +1,26 @@
+import net from "net";
+
+const INTERNET_CHECK = {
+ host: "1.1.1.1",
+ port: 443,
+ timeoutMs: 3000,
+};
+
+export function checkInternet() {
+ return new Promise((resolve) => {
+ const socket = new net.Socket();
+ let done = false;
+ const finish = (ok) => {
+ if (done) return;
+ done = true;
+ try { socket.destroy(); } catch { /* ignore */ }
+ resolve(ok);
+ };
+ socket.setTimeout(INTERNET_CHECK.timeoutMs);
+ socket.once("connect", () => finish(true));
+ socket.once("timeout", () => finish(false));
+ socket.once("error", () => finish(false));
+ try { socket.connect(INTERNET_CHECK.port, INTERNET_CHECK.host); }
+ catch { finish(false); }
+ });
+}
diff --git a/src/lib/tunnel/shared/state.js b/src/lib/tunnel/shared/state.js
new file mode 100644
index 00000000..6a161814
--- /dev/null
+++ b/src/lib/tunnel/shared/state.js
@@ -0,0 +1,41 @@
+import fs from "fs";
+import path from "path";
+import { DATA_DIR } from "@/lib/dataDir.js";
+
+const TUNNEL_DIR = path.join(DATA_DIR, "tunnel");
+const STATE_FILE = path.join(TUNNEL_DIR, "state.json");
+
+const SHORT_ID_LENGTH = 6;
+const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
+
+export function ensureTunnelDir() {
+ if (!fs.existsSync(TUNNEL_DIR)) fs.mkdirSync(TUNNEL_DIR, { recursive: true });
+}
+
+export function loadState() {
+ try {
+ if (fs.existsSync(STATE_FILE)) return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
+ } catch { /* ignore corrupt state */ }
+ return null;
+}
+
+export function saveState(state) {
+ ensureTunnelDir();
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
+}
+
+export function clearState() {
+ try {
+ if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
+ } catch { /* ignore */ }
+}
+
+export function generateShortId() {
+ let result = "";
+ for (let i = 0; i < SHORT_ID_LENGTH; i++) {
+ result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length));
+ }
+ return result;
+}
+
+export { TUNNEL_DIR };
diff --git a/src/lib/tunnel/shared/watchdogConfig.js b/src/lib/tunnel/shared/watchdogConfig.js
new file mode 100644
index 00000000..bcef4934
--- /dev/null
+++ b/src/lib/tunnel/shared/watchdogConfig.js
@@ -0,0 +1,5 @@
+// Watchdog + network monitor timings (shared by both services)
+export const RESTART_COOLDOWN_MS = 120000;
+export const NETWORK_SETTLE_MS = 2500;
+export const WATCHDOG_INTERVAL_MS = 60000;
+export const NETWORK_CHECK_INTERVAL_MS = 5000;
diff --git a/src/lib/tunnel/state.js b/src/lib/tunnel/state.js
deleted file mode 100644
index 72fcfdbc..00000000
--- a/src/lib/tunnel/state.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import fs from "fs";
-import path from "path";
-import { DATA_DIR } from "@/lib/dataDir.js";
-
-const TUNNEL_DIR = path.join(DATA_DIR, "tunnel");
-const STATE_FILE = path.join(TUNNEL_DIR, "state.json");
-const CLOUDFLARED_PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid");
-const TAILSCALE_PID_FILE = path.join(TUNNEL_DIR, "tailscale.pid");
-
-function ensureDir() {
- if (!fs.existsSync(TUNNEL_DIR)) {
- fs.mkdirSync(TUNNEL_DIR, { recursive: true });
- }
-}
-
-export function loadState() {
- try {
- if (fs.existsSync(STATE_FILE)) {
- return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
- }
- } catch (e) { /* ignore corrupt state */ }
- return null;
-}
-
-export function saveState(state) {
- ensureDir();
- fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
-}
-
-export function clearState() {
- try {
- if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
- } catch (e) { /* ignore */ }
-}
-
-// Cloudflare-specific PID
-export function savePid(pid) {
- ensureDir();
- fs.writeFileSync(CLOUDFLARED_PID_FILE, pid.toString());
-}
-
-export function loadPid() {
- try {
- if (fs.existsSync(CLOUDFLARED_PID_FILE)) {
- return parseInt(fs.readFileSync(CLOUDFLARED_PID_FILE, "utf8"));
- }
- } catch (e) { /* ignore */ }
- return null;
-}
-
-export function clearPid() {
- try {
- if (fs.existsSync(CLOUDFLARED_PID_FILE)) fs.unlinkSync(CLOUDFLARED_PID_FILE);
- } catch (e) { /* ignore */ }
-}
-
-// Tailscale-specific PID
-export function saveTailscalePid(pid) {
- ensureDir();
- fs.writeFileSync(TAILSCALE_PID_FILE, pid.toString());
-}
-
-export function loadTailscalePid() {
- try {
- if (fs.existsSync(TAILSCALE_PID_FILE)) {
- return parseInt(fs.readFileSync(TAILSCALE_PID_FILE, "utf8"));
- }
- } catch (e) { /* ignore */ }
- return null;
-}
-
-export function clearTailscalePid() {
- try {
- if (fs.existsSync(TAILSCALE_PID_FILE)) fs.unlinkSync(TAILSCALE_PID_FILE);
- } catch (e) { /* ignore */ }
-}
-
-const SHORT_ID_LENGTH = 6;
-const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
-
-export function generateShortId() {
- let result = "";
- for (let i = 0; i < SHORT_ID_LENGTH; i++) {
- result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length));
- }
- return result;
-}
diff --git a/src/lib/tunnel/tailscale/config.js b/src/lib/tunnel/tailscale/config.js
new file mode 100644
index 00000000..3195ad69
--- /dev/null
+++ b/src/lib/tunnel/tailscale/config.js
@@ -0,0 +1,7 @@
+// Tailscale Funnel: cert provisioning + *.ts.net DNS propagation slower → longer timeouts
+export const HEALTH_CHECK = {
+ intervalMs: 2000,
+ timeoutMs: 180000,
+ fetchTimeoutMs: 8000,
+ dnsTimeoutMs: 3000,
+};
diff --git a/src/lib/tunnel/tailscale/healthCheck.js b/src/lib/tunnel/tailscale/healthCheck.js
new file mode 100644
index 00000000..428b351d
--- /dev/null
+++ b/src/lib/tunnel/tailscale/healthCheck.js
@@ -0,0 +1,29 @@
+import { resolveDns } from "../shared/dnsResolver.js";
+import { HEALTH_CHECK } from "./config.js";
+
+export async function probeUrlAlive(url) {
+ if (!url) return false;
+ let hostname;
+ try { hostname = new URL(url).hostname; } catch { return false; }
+
+ if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false;
+
+ try {
+ const res = await fetch(`${url}/api/health`, {
+ signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs),
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+export async function waitForHealth(url, cancelToken = { cancelled: false }) {
+ const start = Date.now();
+ while (Date.now() - start < HEALTH_CHECK.timeoutMs) {
+ if (cancelToken.cancelled) throw new Error("cancelled");
+ if (await probeUrlAlive(url)) return true;
+ await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs));
+ }
+ throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`);
+}
diff --git a/src/lib/tunnel/tailscale/manager.js b/src/lib/tunnel/tailscale/manager.js
new file mode 100644
index 00000000..4415f759
--- /dev/null
+++ b/src/lib/tunnel/tailscale/manager.js
@@ -0,0 +1,129 @@
+import { loadState, generateShortId } from "../shared/state.js";
+import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js";
+import { waitForHealth } from "./healthCheck.js";
+import { getSettings, updateSettings } from "@/lib/localDb";
+import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
+
+initDbHooks(getSettings, updateSettings);
+
+const svc = {
+ cancelToken: { cancelled: false },
+ spawnInProgress: false,
+ lastRestartAt: 0,
+ activeLocalPort: null,
+};
+
+export function getTailscaleService() { return svc; }
+export function isTailscaleReconnecting() { return svc.spawnInProgress; }
+
+function throwIfCancelled(token) {
+ if (token.cancelled) throw new Error("tailscale cancelled");
+}
+
+export async function enableTailscale(localPort = 20128) {
+ console.log(`[Tailscale] enable start (port=${localPort})`);
+ svc.cancelToken = { cancelled: false };
+ svc.activeLocalPort = localPort;
+ svc.spawnInProgress = true;
+ const token = svc.cancelToken;
+
+ try {
+ const sudoPass = getCachedPassword() || await loadEncryptedPassword() || "";
+ await startDaemonWithPassword(sudoPass);
+ console.log("[Tailscale] daemon ready");
+ throwIfCancelled(token);
+
+ const existing = loadState();
+ const shortId = existing?.shortId || generateShortId();
+ const tsHostname = shortId;
+
+ const loggedIn = isTailscaleLoggedIn();
+ console.log(`[Tailscale] loggedIn=${loggedIn}`);
+ if (!loggedIn) {
+ const loginResult = await startLogin(tsHostname);
+ if (loginResult.authUrl) {
+ console.log(`[Tailscale] needs login, authUrl=${loginResult.authUrl}`);
+ return { success: false, needsLogin: true, authUrl: loginResult.authUrl };
+ }
+ console.log("[Tailscale] login resolved alreadyLoggedIn");
+ }
+ throwIfCancelled(token);
+
+ stopFunnel();
+ let result;
+ try {
+ console.log("[Tailscale] starting funnel");
+ result = await startFunnel(localPort);
+ } catch (e) {
+ console.error(`[Tailscale] funnel error: ${e.message}`);
+ // Daemon not logged in / not ready → auto-trigger login flow so user stays in-app
+ if (/NoState|unexpected state|not logged in|Logged ?out|NeedsLogin/i.test(e.message || "")) {
+ console.log("[Tailscale] retry via startLogin");
+ const loginResult = await startLogin(tsHostname);
+ if (loginResult.authUrl) return { success: false, needsLogin: true, authUrl: loginResult.authUrl };
+ }
+ throw e;
+ }
+ throwIfCancelled(token);
+
+ if (result.funnelNotEnabled) {
+ console.log(`[Tailscale] funnel not enabled, enableUrl=${result.enableUrl}`);
+ return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl };
+ }
+
+ // Strict probe: bypass cache so we don't false-negative on first invocation
+ if (!isTailscaleLoggedIn() || !isTailscaleRunningStrict()) {
+ console.error("[Tailscale] strict probe failed (device removed?)");
+ stopFunnel();
+ return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." };
+ }
+
+ await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl });
+ console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`);
+
+ // Provision TLS cert so Funnel can serve HTTPS (non-fatal if fails)
+ const hostname = new URL(result.tunnelUrl).hostname;
+ await provisionCert(hostname);
+
+ // Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating)
+ let reachableNow = false;
+ try {
+ await waitForHealth(result.tunnelUrl, token);
+ reachableNow = true;
+ } catch (he) {
+ if (!he.message.startsWith("Health check timeout")) throw he;
+ console.warn(`[Tailscale] health check timed out, will retry via watchdog`);
+ }
+ console.log(`[Tailscale] enable success (reachable=${reachableNow})`);
+ return { success: true, tunnelUrl: result.tunnelUrl };
+ } catch (e) {
+ console.error(`[Tailscale] enable error: ${e.message}`);
+ throw e;
+ } finally {
+ svc.spawnInProgress = false;
+ }
+}
+
+export async function disableTailscale() {
+ console.log("[Tailscale] disable");
+ svc.cancelToken.cancelled = true;
+ stopFunnel();
+ await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" });
+ return { success: true };
+}
+
+export async function getTailscaleStatus() {
+ const settings = await getSettings();
+ const settingsEnabled = settings.tailscaleEnabled === true;
+ const tunnelUrl = settings.tailscaleUrl || "";
+ // Skip probes entirely when disabled; check login before running (device removed = not logged in)
+ const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false;
+ const running = loggedIn ? isTailscaleRunning() : false;
+ return {
+ enabled: settingsEnabled && running,
+ settingsEnabled,
+ tunnelUrl,
+ running,
+ loggedIn
+ };
+}
diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale/tailscale.js
similarity index 99%
rename from src/lib/tunnel/tailscale.js
rename to src/lib/tunnel/tailscale/tailscale.js
index 88245a6a..bd636c37 100644
--- a/src/lib/tunnel/tailscale.js
+++ b/src/lib/tunnel/tailscale/tailscale.js
@@ -5,7 +5,6 @@ import crypto from "crypto";
import { execSync, exec, spawn } from "child_process";
import { promisify } from "util";
import { execWithPassword } from "@/mitm/dns/dnsConfig";
-import { saveTailscalePid, loadTailscalePid, clearTailscalePid } from "./state.js";
import { DATA_DIR } from "@/lib/dataDir.js";
const execAsync = promisify(exec);
diff --git a/src/lib/tunnel/tunnelConfig.js b/src/lib/tunnel/tunnelConfig.js
deleted file mode 100644
index d64ab588..00000000
--- a/src/lib/tunnel/tunnelConfig.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// Tunnel + Tailscale shared config (all values in ms)
-export const HEALTH_CHECK = {
- intervalMs: 2000,
- timeoutMs: 180000,
- fetchTimeoutMs: 5000,
- dnsTimeoutMs: 2000,
-};
-
-export const INTERNET_CHECK = {
- host: "1.1.1.1",
- port: 443,
- timeoutMs: 3000,
-};
-
-export const RESTART_COOLDOWN_MS = 120000;
-export const NETWORK_SETTLE_MS = 2500;
-export const WATCHDOG_INTERVAL_MS = 60000;
-export const NETWORK_CHECK_INTERVAL_MS = 5000;
diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js
deleted file mode 100644
index bb1a4e55..00000000
--- a/src/lib/tunnel/tunnelManager.js
+++ /dev/null
@@ -1,276 +0,0 @@
-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";
-import { getSettings, updateSettings } from "@/lib/localDb";
-import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
-import { waitForHealth, probeUrlAlive } from "./networkProbe.js";
-
-initDbHooks(getSettings, updateSettings);
-
-const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://abc-tunnel.us";
-
-// Per-service state (independent: tunnel ≠ tailscale)
-const tunnelSvc = {
- cancelToken: { cancelled: false },
- spawnInProgress: false,
- lastRestartAt: 0,
- activeLocalPort: null,
-};
-
-const tailscaleSvc = {
- cancelToken: { cancelled: false },
- spawnInProgress: false,
- lastRestartAt: 0,
- activeLocalPort: null,
-};
-
-export function getTunnelService() { return tunnelSvc; }
-export function getTailscaleService() { return tailscaleSvc; }
-
-export function isTunnelManuallyDisabled() { return tunnelSvc.cancelToken.cancelled; }
-export function isTunnelReconnecting() { return tunnelSvc.spawnInProgress; }
-export function isTailscaleReconnecting() { return tailscaleSvc.spawnInProgress; }
-
-// Callback invoked when cloudflared exits unexpectedly (set by initializeApp)
-let onTunnelUnexpectedExit = null;
-export function setTunnelUnexpectedExitCallback(cb) { onTunnelUnexpectedExit = cb; }
-
-// ─── Cloudflare Tunnel ───────────────────────────────────────────────────────
-
-async function registerTunnelUrl(shortId, tunnelUrl) {
- await fetch(`${WORKER_URL}/api/tunnel/register`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ shortId, tunnelUrl })
- });
-}
-
-function throwIfCancelled(token, label) {
- if (token.cancelled) throw new Error(`${label} cancelled`);
-}
-
-export async function enableTunnel(localPort = 20128) {
- console.log(`[Tunnel] enable start (port=${localPort})`);
- tunnelSvc.cancelToken = { cancelled: false };
- tunnelSvc.activeLocalPort = localPort;
- tunnelSvc.spawnInProgress = true;
- const token = tunnelSvc.cancelToken;
-
- try {
- if (isCloudflaredRunning()) {
- const existing = loadState();
- if (existing?.tunnelUrl && existing?.shortId) {
- const publicUrl = `https://r${existing.shortId}.abc-tunnel.us`;
- // Reuse only if BOTH direct + public URL alive (avoid stale socket after network change)
- const [directOk, publicOk] = await Promise.all([
- probeUrlAlive(existing.tunnelUrl),
- probeUrlAlive(publicUrl),
- ]);
- if (directOk && publicOk) {
- console.log(`[Tunnel] already running, reuse: ${existing.tunnelUrl}`);
- return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true };
- }
- console.log(`[Tunnel] stale (direct=${directOk} public=${publicOk}), respawn`);
- }
- }
-
- killCloudflared(localPort);
- console.log("[Tunnel] killed existing cloudflared");
- throwIfCancelled(token, "tunnel");
-
- const existing = loadState();
- const shortId = existing?.shortId || generateShortId();
-
- const onUrlUpdate = async (url) => {
- if (token.cancelled) return;
- console.log(`[Tunnel] url updated: ${url}`);
- await registerTunnelUrl(shortId, url);
- saveState({ shortId, tunnelUrl: url });
- await updateSettings({ tunnelEnabled: true, tunnelUrl: url });
- };
-
- // Register exit handler BEFORE spawn so it fires even on early exit
- setUnexpectedExitHandler(() => {
- console.warn("[Tunnel] cloudflared exited unexpectedly, scheduling respawn");
- if (onTunnelUnexpectedExit) onTunnelUnexpectedExit();
- });
-
- const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate);
- console.log(`[Tunnel] spawned: ${tunnelUrl}`);
- throwIfCancelled(token, "tunnel");
-
- const publicUrl = `https://r${shortId}.abc-tunnel.us`;
- await registerTunnelUrl(shortId, tunnelUrl);
- saveState({ shortId, tunnelUrl });
- await updateSettings({ tunnelEnabled: true, tunnelUrl });
- console.log(`[Tunnel] registered shortId=${shortId} publicUrl=${publicUrl}`);
-
- // Verify publicUrl first (worker route is reliable; direct *.trycloudflare.com DNS may lag)
- await waitForHealth(publicUrl, token);
- console.log("[Tunnel] public URL healthy");
- // Direct tunnel probe is best-effort: DNS for *.trycloudflare.com can be slow/blocked on some networks
- if (!(await probeUrlAlive(tunnelUrl))) {
- console.warn("[Tunnel] direct URL not reachable yet, continuing via publicUrl");
- } else {
- console.log("[Tunnel] direct URL healthy");
- }
-
- console.log("[Tunnel] enable success");
- return { success: true, tunnelUrl, shortId, publicUrl };
- } catch (e) {
- console.error(`[Tunnel] enable error: ${e.message}`);
- throw e;
- } finally {
- tunnelSvc.spawnInProgress = false;
- }
-}
-
-export async function disableTunnel() {
- console.log("[Tunnel] disable");
- // Abort any in-flight enable so it cannot resurrect state after we clear it
- tunnelSvc.cancelToken.cancelled = true;
- setUnexpectedExitHandler(null);
-
- try { killCloudflared(tunnelSvc.activeLocalPort); } catch (e) { console.warn(`[Tunnel] kill warn: ${e.message}`); }
- clearPid();
-
- const state = loadState();
- if (state) saveState({ shortId: state.shortId, tunnelUrl: null });
-
- await updateSettings({ tunnelEnabled: false, tunnelUrl: "" });
- // Force-clear flags so a subsequent enable is not blocked by a stuck spawnInProgress
- tunnelSvc.spawnInProgress = false;
- tunnelSvc.activeLocalPort = null;
- return { success: true };
-}
-
-export async function getTunnelStatus() {
- const settings = await getSettings();
- const settingsEnabled = settings.tunnelEnabled === true;
- const state = loadState();
- const shortId = state?.shortId || "";
- const publicUrl = shortId ? `https://r${shortId}.abc-tunnel.us` : "";
- const tunnelUrl = state?.tunnelUrl || "";
-
- // Lazy: skip PID probe entirely when user disabled tunnel
- const running = settingsEnabled ? isCloudflaredRunning() : false;
-
- return {
- enabled: settingsEnabled && running,
- settingsEnabled,
- tunnelUrl,
- shortId,
- publicUrl,
- running
- };
-}
-
-// ─── Tailscale Funnel ─────────────────────────────────────────────────────────
-
-export async function enableTailscale(localPort = 20128) {
- console.log(`[Tailscale] enable start (port=${localPort})`);
- tailscaleSvc.cancelToken = { cancelled: false };
- tailscaleSvc.activeLocalPort = localPort;
- tailscaleSvc.spawnInProgress = true;
- const token = tailscaleSvc.cancelToken;
-
- try {
- const sudoPass = getCachedPassword() || await loadEncryptedPassword() || "";
- await startDaemonWithPassword(sudoPass);
- console.log("[Tailscale] daemon ready");
- throwIfCancelled(token, "tailscale");
-
- const existing = loadState();
- const shortId = existing?.shortId || generateShortId();
- const tsHostname = shortId;
-
- const loggedIn = isTailscaleLoggedIn();
- console.log(`[Tailscale] loggedIn=${loggedIn}`);
- if (!loggedIn) {
- const loginResult = await startLogin(tsHostname);
- if (loginResult.authUrl) {
- console.log(`[Tailscale] needs login, authUrl=${loginResult.authUrl}`);
- return { success: false, needsLogin: true, authUrl: loginResult.authUrl };
- }
- console.log("[Tailscale] login resolved alreadyLoggedIn");
- }
- throwIfCancelled(token, "tailscale");
-
- stopFunnel();
- let result;
- try {
- console.log("[Tailscale] starting funnel");
- result = await startFunnel(localPort);
- } catch (e) {
- console.error(`[Tailscale] funnel error: ${e.message}`);
- // Daemon not logged in / not ready → auto-trigger login flow so user stays in-app
- if (/NoState|unexpected state|not logged in|Logged ?out|NeedsLogin/i.test(e.message || "")) {
- console.log("[Tailscale] retry via startLogin");
- const loginResult = await startLogin(tsHostname);
- if (loginResult.authUrl) return { success: false, needsLogin: true, authUrl: loginResult.authUrl };
- }
- throw e;
- }
- throwIfCancelled(token, "tailscale");
-
- if (result.funnelNotEnabled) {
- console.log(`[Tailscale] funnel not enabled, enableUrl=${result.enableUrl}`);
- return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl };
- }
-
- // Strict probe: bypass cache so we don't false-negative on first invocation
- if (!isTailscaleLoggedIn() || !isTailscaleRunningStrict()) {
- console.error("[Tailscale] strict probe failed (device removed?)");
- stopFunnel();
- return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." };
- }
-
- await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl });
- console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`);
-
- // Provision TLS cert so Funnel can serve HTTPS (non-fatal if fails)
- const hostname = new URL(result.tunnelUrl).hostname;
- await provisionCert(hostname);
-
- // Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating)
- let reachableNow = false;
- try {
- await waitForHealth(result.tunnelUrl, token);
- reachableNow = true;
- } catch (he) {
- if (!he.message.startsWith("Health check timeout")) throw he;
- console.warn(`[Tailscale] health check timed out, will retry via watchdog`);
- }
- console.log(`[Tailscale] enable success (reachable=${reachableNow})`);
- return { success: true, tunnelUrl: result.tunnelUrl };
- } catch (e) {
- console.error(`[Tailscale] enable error: ${e.message}`);
- throw e;
- } finally {
- tailscaleSvc.spawnInProgress = false;
- }
-}
-
-export async function disableTailscale() {
- console.log("[Tailscale] disable");
- tailscaleSvc.cancelToken.cancelled = true;
- stopFunnel();
- await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" });
- return { success: true };
-}
-
-export async function getTailscaleStatus() {
- const settings = await getSettings();
- const settingsEnabled = settings.tailscaleEnabled === true;
- const tunnelUrl = settings.tailscaleUrl || "";
- // Skip probes entirely when disabled; check login before running (device removed = not logged in)
- const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false;
- const running = loggedIn ? isTailscaleRunning() : false;
- return {
- enabled: settingsEnabled && running,
- settingsEnabled,
- tunnelUrl,
- running,
- loggedIn
- };
-}
diff --git a/src/server-init.js b/src/server-init.js
deleted file mode 100644
index b1d7cd47..00000000
--- a/src/server-init.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import initializeApp from "./shared/services/initializeApp.js";
-
-async function startServer() {
- console.log("Starting server...");
-
- try {
- await initializeApp();
- console.log("Server initialized");
- } catch (error) {
- console.log("Error initializing server:", error);
- process.exit(1);
- }
-}
-
-startServer().catch(console.log);
-
-export default startServer;
diff --git a/src/shared/services/bootstrap.js b/src/shared/services/bootstrap.js
new file mode 100644
index 00000000..8080c3b2
--- /dev/null
+++ b/src/shared/services/bootstrap.js
@@ -0,0 +1,7 @@
+import initializeApp from "./initializeApp.js";
+
+// Server-only singleton: guard via global so HMR / re-imports don't double-init
+if (typeof window === "undefined" && !global.__appBootstrapped) {
+ global.__appBootstrapped = true;
+ initializeApp().catch((e) => console.error("[Bootstrap] init failed:", e.message));
+}
diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js
index 30c7cff0..41e41a23 100644
--- a/src/shared/services/initializeApp.js
+++ b/src/shared/services/initializeApp.js
@@ -7,15 +7,14 @@ import {
enableTunnel, enableTailscale,
isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
getTunnelService, getTailscaleService, setTunnelUnexpectedExitCallback,
-} from "@/lib/tunnel/tunnelManager";
-import { killCloudflared, isCloudflaredRunning, ensureCloudflared } from "@/lib/tunnel/cloudflared";
-import { isTailscaleRunning } from "@/lib/tunnel/tailscale";
-import { loadState } from "@/lib/tunnel/state";
-import { checkInternet, probeUrlAlive } from "@/lib/tunnel/networkProbe";
-import {
+ killCloudflared, isCloudflaredRunning, ensureCloudflared,
+ isTailscaleRunning,
+ loadState,
+ checkInternet,
+ probeCloudflareAlive, probeTailscaleAlive,
RESTART_COOLDOWN_MS, NETWORK_SETTLE_MS,
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS,
-} from "@/lib/tunnel/tunnelConfig";
+} from "@/lib/tunnel";
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
@@ -130,6 +129,10 @@ async function autoStartMitm() {
}
}
+// Cooldown only applies to repeating watchdog ticks (anti hammer-loop).
+// Network/exit events are one-shot transitions → bypass to recover fast.
+const FORCE_RESTART_REASONS = /^(startup|netchange|sleep|sleep\+netchange|online|unexpected-exit)$/;
+
// ─── Safe restart (4 guards: spawn / cooldown / alive / internet) ────────────
async function safeRestartTunnel(reason) {
@@ -138,27 +141,33 @@ async function safeRestartTunnel(reason) {
if (!settings.tunnelEnabled) return;
if (svc.cancelToken.cancelled) return;
if (svc.spawnInProgress) return;
- // Bypass cooldown when process is dead (real respawn, not restart-loop guard)
- const processDead = !isCloudflaredRunning();
- if (!processDead && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return;
- // Alive check: process up + BOTH direct & public URL respond → skip
+ // Alive check FIRST: probe URLs to decide health (process up but tunnel 530 = dead)
+ let alive = false;
if (isCloudflaredRunning()) {
const state = loadState();
const publicUrl = state?.shortId ? `https://r${state.shortId}.abc-tunnel.us` : null;
const directUrl = state?.tunnelUrl || null;
if (publicUrl && directUrl) {
const [publicOk, directOk] = await Promise.all([
- probeUrlAlive(publicUrl),
- probeUrlAlive(directUrl),
+ probeCloudflareAlive(publicUrl),
+ probeCloudflareAlive(directUrl),
]);
- if (publicOk && directOk) return;
+ alive = publicOk && directOk;
}
}
+ if (alive) return;
+ // Degraded/dead → cooldown only prevents hammer loop after a recent restart attempt.
+ // Bypass for network transitions (one-shot events) so user recovers fast after wifi change.
+ const force = FORCE_RESTART_REASONS.test(reason);
+ if (!force && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) {
+ console.log(`[Tunnel] degraded but cooldown active, skip (${reason})`);
+ return;
+ }
if (!await checkInternet()) return;
- console.log(`[Tunnel] safeRestart (${reason})`);
+ console.log(`[Tunnel] safeRestart (${reason}) — tunnel unreachable${force ? " [force]" : ""}`);
try {
await enableTunnel();
svc.lastRestartAt = Date.now();
@@ -174,15 +183,22 @@ async function safeRestartTailscale(reason) {
if (!settings.tailscaleEnabled) return;
if (svc.cancelToken.cancelled) return;
if (svc.spawnInProgress) return;
- if (Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return;
+ // Alive check FIRST: daemon up + URL responds = healthy
+ let alive = false;
if (isTailscaleRunning() && settings.tailscaleUrl) {
- if (await probeUrlAlive(settings.tailscaleUrl)) return;
+ alive = await probeTailscaleAlive(settings.tailscaleUrl);
}
+ if (alive) return;
+ const force = FORCE_RESTART_REASONS.test(reason);
+ if (!force && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) {
+ console.log(`[Tailscale] degraded but cooldown active, skip (${reason})`);
+ return;
+ }
if (!await checkInternet()) return;
- console.log(`[Tailscale] safeRestart (${reason})`);
+ console.log(`[Tailscale] safeRestart (${reason}) — tunnel unreachable${force ? " [force]" : ""}`);
try {
await enableTailscale();
svc.lastRestartAt = Date.now();