Refactor Tunnel
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
9
src/lib/tunnel/cloudflare/config.js
Normal file
9
src/lib/tunnel/cloudflare/config.js
Normal 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";
|
||||
29
src/lib/tunnel/cloudflare/healthCheck.js
Normal file
29
src/lib/tunnel/cloudflare/healthCheck.js
Normal 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`);
|
||||
}
|
||||
148
src/lib/tunnel/cloudflare/manager.js
Normal file
148
src/lib/tunnel/cloudflare/manager.js
Normal 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
|
||||
};
|
||||
}
|
||||
23
src/lib/tunnel/cloudflare/pid.js
Normal file
23
src/lib/tunnel/cloudflare/pid.js
Normal 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
46
src/lib/tunnel/index.js
Normal 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";
|
||||
@@ -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`);
|
||||
}
|
||||
17
src/lib/tunnel/shared/dnsResolver.js
Normal file
17
src/lib/tunnel/shared/dnsResolver.js
Normal 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));
|
||||
}
|
||||
26
src/lib/tunnel/shared/internetCheck.js
Normal file
26
src/lib/tunnel/shared/internetCheck.js
Normal 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); }
|
||||
});
|
||||
}
|
||||
41
src/lib/tunnel/shared/state.js
Normal file
41
src/lib/tunnel/shared/state.js
Normal 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 };
|
||||
5
src/lib/tunnel/shared/watchdogConfig.js
Normal file
5
src/lib/tunnel/shared/watchdogConfig.js
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
7
src/lib/tunnel/tailscale/config.js
Normal file
7
src/lib/tunnel/tailscale/config.js
Normal 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,
|
||||
};
|
||||
29
src/lib/tunnel/tailscale/healthCheck.js
Normal file
29
src/lib/tunnel/tailscale/healthCheck.js
Normal 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`);
|
||||
}
|
||||
129
src/lib/tunnel/tailscale/manager.js
Normal file
129
src/lib/tunnel/tailscale/manager.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
7
src/shared/services/bootstrap.js
vendored
Normal 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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user