Refactor Tunnel

This commit is contained in:
decolua
2026-05-25 16:15:34 +07:00
parent 96a9a2b36d
commit b876e0225a
35 changed files with 642 additions and 574 deletions

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -577,9 +577,6 @@ export default function ProxyPoolsPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-xl font-semibold sm:text-2xl">Proxy Pools</h1>
<p className="text-sm text-text-muted mt-1">
Manage reusable per-connection proxies and bind them to provider connections.
</p>
</div>
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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";

View File

@@ -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",

View File

@@ -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");

View File

@@ -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";

View File

@@ -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`);
}

View File

@@ -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
};
}

View File

@@ -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 */ }
}

46
src/lib/tunnel/index.js Normal file
View File

@@ -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";

View File

@@ -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`);
}

View File

@@ -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));
}

View File

@@ -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); }
});
}

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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`);
}

View File

@@ -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
};
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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
};
}

View File

@@ -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;

7
src/shared/services/bootstrap.js vendored Normal file
View File

@@ -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));
}

View File

@@ -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();