fix(tunnel): separate health states, remove fake inferred-connected logic, add origin polling
- Remove INFERRED_CONNECT_MS: tunnelConnected is now only set true when
cloudflared logs 'Registered tunnel connection' — never inferred from
process uptime.
- Add four independent health dimensions:
cloudflaredRunning — child process alive/exited
tunnelConnected — actual cloudflared connection registered
originHealthy — http://127.0.0.1:18484/ reachable (polled every 20s)
publicEndpointStatus — diagnostic only, never triggers restart
- Add checkOriginHealth(): polls 127.0.0.1:18484 with explicit IPv4
(family:4) to avoid Windows localhost/IPv6 ambiguity.
- Restart cloudflared ONLY on process exit. A public 502/503/504/530
is never a restart trigger.
- Add 90s startup connection timeout: logs 'degraded' warning with full
diagnostics if no connection registered, but does NOT kill the process.
- Improve structured [Tunnel] logging: clearly distinguishes origin,
process, connection, and public endpoint health in every log line.
- Token: never log the full value; log first 6 chars only as proof of
configuration. Fail fast with clear error if token is missing.
- Duplicate process guard: explicit log when skipping a second spawn.
- start_tunnel.bat: add prominent warning not to run while backend is
active; suppress token echo; add --no-autoupdate flag.
This commit is contained in:
1 parent
fb99ed0fb8
commit
e3452099cd
2 files changed
+327
-86
No files matched your search
@@ -4,24 +4,48 @@ const { spawn, spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const dns = require('dns');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
const INTERNET_TIMEOUT_MS = 5_000;
|
||||
const INTERNET_CHECK_INTERVAL_MS = 15_000;
|
||||
const INFERRED_CONNECT_MS = 5_000;
|
||||
const INTERNET_TIMEOUT_MS = 5_000;
|
||||
const INTERNET_CHECK_INTERVAL_MS = 15_000;
|
||||
const ORIGIN_CHECK_INTERVAL_MS = 20_000;
|
||||
const ORIGIN_HOST = '127.0.0.1';
|
||||
const ORIGIN_PORT = parseInt(process.env.PORT || '18484', 10);
|
||||
const TUNNEL_CONNECT_TIMEOUT_MS = 90_000; // Warn if no connection registered after this
|
||||
|
||||
// ─── Service State ────────────────────────────────────────────────────────────
|
||||
let tunnelProcess = null;
|
||||
let isStarting = false;
|
||||
let tunnelState = 'stopped'; // 'stopped' | 'starting' | 'connected' | 'error' | 'no_internet' | 'disabled'
|
||||
|
||||
/**
|
||||
* Separate health dimensions — never collapse these into one flag.
|
||||
*
|
||||
* tunnelState: overall reported status (used by getTunnelStatus/API)
|
||||
* 'stopped' | 'starting' | 'connected' | 'degraded' | 'error' | 'no_internet' | 'disabled'
|
||||
*
|
||||
* cloudflaredRunning: true iff the child process is alive right now.
|
||||
* tunnelConnected: true iff cloudflared has logged an actual connection registration.
|
||||
* originHealthy: true iff http://127.0.0.1:<port>/ returned HTTP 200.
|
||||
* publicEndpointStatus: last HTTP status from the public Cloudflare URL (diagnostic only).
|
||||
*/
|
||||
let tunnelState = 'stopped';
|
||||
let cloudflaredRunning = false;
|
||||
let tunnelConnected = false;
|
||||
let originHealthy = false;
|
||||
let publicEndpointStatus = null;
|
||||
|
||||
let detectedTunnelHostname = null;
|
||||
let lastInternetCheck = false;
|
||||
let lastLogMessage = '';
|
||||
let internetTimer = null;
|
||||
let inferredConnectTimer = null;
|
||||
let originHealthTimer = null;
|
||||
let connectTimeoutTimer = null;
|
||||
let signalHandlersRegistered = false;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extracts a hostname from a URL string if valid.
|
||||
* @param {string} urlStr
|
||||
@@ -54,7 +78,8 @@ function getEffectiveHostname() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the cloudflared executable path across common workspace and system locations.
|
||||
* Finds the cloudflared executable across common workspace and system locations.
|
||||
* Respects CLOUDFLARED_PATH env var for custom installations.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getCloudflaredPath() {
|
||||
@@ -90,8 +115,10 @@ function getCloudflaredPath() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Internet Check ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks whether an internet connection is available.
|
||||
* Checks whether an internet connection is available via DNS + HTTPS probe.
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
@@ -135,8 +162,70 @@ function checkInternet(timeoutMs = INTERNET_TIMEOUT_MS) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Origin Health Check ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks whether the local Node.js origin is reachable on 127.0.0.1.
|
||||
* Uses IPv4 loopback explicitly to avoid Windows localhost/IPv6 ambiguity.
|
||||
* This is NOT the same as the public Cloudflare endpoint check.
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function checkOriginHealth() {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (ok) => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve(ok);
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: ORIGIN_HOST, // always 127.0.0.1, never 'localhost'
|
||||
port: ORIGIN_PORT,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
timeout: 3000,
|
||||
family: 4, // force IPv4
|
||||
},
|
||||
(res) => {
|
||||
res.resume(); // drain body
|
||||
finish(res.statusCode >= 200 && res.statusCode < 500);
|
||||
}
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
finish(false);
|
||||
});
|
||||
req.on('error', () => finish(false));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a periodic origin health check and updates state/logs accordingly.
|
||||
*/
|
||||
async function runOriginHealthCheck() {
|
||||
const wasHealthy = originHealthy;
|
||||
originHealthy = await checkOriginHealth();
|
||||
|
||||
if (originHealthy && !wasHealthy) {
|
||||
console.log(`[Tunnel] Origin healthy — http://${ORIGIN_HOST}:${ORIGIN_PORT}/ is reachable.`);
|
||||
} else if (!originHealthy && wasHealthy) {
|
||||
console.warn(`[Tunnel] Origin unreachable — http://${ORIGIN_HOST}:${ORIGIN_PORT}/ is NOT responding.`);
|
||||
console.warn('[Tunnel] Diagnostic: if cloudflared is returning 502, this is likely the root cause.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cloudflared Output Parser ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parses cloudflared log lines to update connection status and extract hostnames.
|
||||
* The connection state is determined by ACTUAL cloudflared output, not by inference.
|
||||
*
|
||||
* @param {string} rawChunk
|
||||
*/
|
||||
function handleProcessOutput(rawChunk) {
|
||||
@@ -158,32 +247,80 @@ function handleProcessOutput(rawChunk) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Connection confirmation detection
|
||||
const isConnectedLog =
|
||||
// 2. Actual connection registration detection.
|
||||
// These are cloudflared's real "we have an established tunnel" log lines.
|
||||
// We do NOT infer connection from process uptime.
|
||||
const isRegisteredConnection =
|
||||
text.includes('Registered tunnel connection') ||
|
||||
(text.includes('Connection') && text.includes('registered')) ||
|
||||
text.includes('Registered new connection');
|
||||
|
||||
// These indicate cloudflared is trying to connect but hasn't confirmed yet
|
||||
const isConnectingIndicator =
|
||||
text.includes('Tunnel connection curve preferences') ||
|
||||
text.includes('protocol=quic') ||
|
||||
text.includes('protocol=http2');
|
||||
text.includes('protocol=http2') ||
|
||||
text.includes('Connecting to Cloudflare') ||
|
||||
text.includes('Trying to connect');
|
||||
|
||||
if (isConnectedLog && tunnelState !== 'connected') {
|
||||
tunnelState = 'connected';
|
||||
console.log('[Tunnel] ✔ Connected to Cloudflare edge network.');
|
||||
if (isRegisteredConnection) {
|
||||
if (!tunnelConnected) {
|
||||
tunnelConnected = true;
|
||||
tunnelState = 'connected';
|
||||
// Cancel the connection timeout warning timer since we connected
|
||||
if (connectTimeoutTimer) {
|
||||
clearTimeout(connectTimeoutTimer);
|
||||
connectTimeoutTimer = null;
|
||||
}
|
||||
console.log('[Tunnel] Tunnel connection registered — cloudflared is connected to Cloudflare edge.');
|
||||
}
|
||||
} else if (isConnectingIndicator && tunnelState === 'starting') {
|
||||
console.log('[Tunnel] Tunnel connection in progress (cloudflared is negotiating)...');
|
||||
}
|
||||
|
||||
// 3. Auth error detection
|
||||
if (text.includes('Invalid token') || text.includes('failed to authenticate')) {
|
||||
console.error('[Tunnel] ✖ Cloudflare authentication failed. Verify CLOUDFLARE_TUNNEL_TOKEN.');
|
||||
// 3. Disconnection / reconnection events
|
||||
if (text.includes('Unregistered tunnel connection') ||
|
||||
text.includes('Lost connection to the Cloudflare edge') ||
|
||||
text.includes('Retrying connection')) {
|
||||
if (tunnelConnected) {
|
||||
tunnelConnected = false;
|
||||
if (tunnelState === 'connected') tunnelState = 'starting';
|
||||
console.warn('[Tunnel] Tunnel connection lost — cloudflared is reconnecting...');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Auth / fatal error detection
|
||||
if (text.includes('Invalid token') || text.includes('failed to authenticate') ||
|
||||
text.includes('Authentication error') || text.includes('Unauthorized')) {
|
||||
console.error('[Tunnel] Cloudflare authentication failed. Verify CLOUDFLARE_TUNNEL_TOKEN in .env.');
|
||||
tunnelState = 'error';
|
||||
}
|
||||
|
||||
// 5. Log 502-related messages from cloudflared itself as diagnostic info
|
||||
// but DO NOT trigger a restart from these
|
||||
if (text.includes('502') || text.includes('Bad Gateway') ||
|
||||
text.includes('unable to reach the origin') || text.includes('error="dial tcp')) {
|
||||
console.warn('[Tunnel] cloudflared reported an origin-reach error (possible 502 source).');
|
||||
console.warn(`[Tunnel] cloudflared message: ${text.substring(0, 200)}`);
|
||||
console.warn(`[Tunnel] Origin health: ${originHealthy ? 'healthy' : 'UNREACHABLE'}`);
|
||||
console.warn(`[Tunnel] cloudflared process: ${cloudflaredRunning ? 'running' : 'not running'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tunnel Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Launches cloudflared using only the configured tunnel token.
|
||||
* Guards against duplicate processes — will not spawn if one is already running.
|
||||
*/
|
||||
async function startTunnel() {
|
||||
if (tunnelProcess || isStarting) return;
|
||||
// Strict duplicate guard: never start a second cloudflared for the same tunnel
|
||||
if (tunnelProcess !== null || isStarting) {
|
||||
console.log('[Tunnel] Skipping start — cloudflared is already running or starting.');
|
||||
return;
|
||||
}
|
||||
|
||||
isStarting = true;
|
||||
|
||||
const binPath = getCloudflaredPath();
|
||||
@@ -200,86 +337,127 @@ async function startTunnel() {
|
||||
|
||||
if (!token) {
|
||||
tunnelState = 'error';
|
||||
lastLogMessage = 'CLOUDFLARE_TUNNEL_TOKEN is required but not configured.';
|
||||
console.warn(`[Tunnel] ${lastLogMessage}`);
|
||||
lastLogMessage = 'CLOUDFLARE_TUNNEL_TOKEN is required but not set. Add it to backend/.env.';
|
||||
console.error(`[Tunnel] ${lastLogMessage}`);
|
||||
isStarting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ['tunnel', '--no-autoupdate', 'run', '--token', token];
|
||||
// Log a masked token reference (first 6 chars only) so the log proves a token was found
|
||||
// without exposing the full value.
|
||||
const maskedToken = token.substring(0, 6) + '...';
|
||||
console.log(`[Tunnel] Starting cloudflared... (token prefix: ${maskedToken})`);
|
||||
console.log(`[Tunnel] Binary: ${binPath}`);
|
||||
|
||||
console.log('[Tunnel] Launching Cloudflare Tunnel with configured token...');
|
||||
tunnelState = 'starting';
|
||||
tunnelConnected = false;
|
||||
|
||||
const args = ['tunnel', '--no-autoupdate', 'run', '--token', token];
|
||||
|
||||
try {
|
||||
const proc = spawn(binPath, args, {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, NO_AUTOUPDATE: 'true' }
|
||||
env: { ...process.env, NO_AUTOUPDATE: 'true' },
|
||||
// detached: false (default) — child dies with parent on Windows
|
||||
});
|
||||
|
||||
tunnelProcess = proc;
|
||||
cloudflaredRunning = true;
|
||||
isStarting = false;
|
||||
console.log(`[Tunnel] Process started (PID: ${proc.pid})`);
|
||||
console.log(`[Tunnel] cloudflared process started (PID: ${proc.pid})`);
|
||||
|
||||
proc.stdout.on('data', handleProcessOutput);
|
||||
proc.stderr.on('data', handleProcessOutput);
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error('[Tunnel] Process error:', err.message);
|
||||
console.error('[Tunnel] cloudflared process error:', err.message);
|
||||
tunnelState = 'error';
|
||||
lastLogMessage = err.message;
|
||||
if (tunnelProcess === proc) tunnelProcess = null;
|
||||
if (tunnelProcess === proc) {
|
||||
tunnelProcess = null;
|
||||
cloudflaredRunning = false;
|
||||
tunnelConnected = false;
|
||||
}
|
||||
isStarting = false;
|
||||
clearConnectTimeoutTimer();
|
||||
});
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
console.log(`[Tunnel] Process exited (Code: ${code}, Signal: ${signal})`);
|
||||
if (tunnelProcess === proc) tunnelProcess = null;
|
||||
isStarting = false;
|
||||
if (inferredConnectTimer) {
|
||||
clearTimeout(inferredConnectTimer);
|
||||
inferredConnectTimer = null;
|
||||
const reason = signal ? `signal=${signal}` : `code=${code}`;
|
||||
console.log(`[Tunnel] cloudflared process exited (${reason})`);
|
||||
console.log('[Tunnel] Restarting cloudflared because the process exited (next internet check cycle).');
|
||||
|
||||
if (tunnelProcess === proc) {
|
||||
tunnelProcess = null;
|
||||
cloudflaredRunning = false;
|
||||
tunnelConnected = false;
|
||||
}
|
||||
isStarting = false;
|
||||
if (tunnelState !== 'disabled') {
|
||||
tunnelState = 'stopped';
|
||||
}
|
||||
clearConnectTimeoutTimer();
|
||||
});
|
||||
|
||||
// Infer connected state if process remains active
|
||||
if (inferredConnectTimer) clearTimeout(inferredConnectTimer);
|
||||
inferredConnectTimer = setTimeout(() => {
|
||||
if (tunnelProcess === proc && tunnelState === 'starting') {
|
||||
tunnelState = 'connected';
|
||||
console.log('[Tunnel] ✔ Connected (inferred: process healthy).');
|
||||
// ── Connection timeout warning ──────────────────────────────────────
|
||||
// If cloudflared has been running for TUNNEL_CONNECT_TIMEOUT_MS without
|
||||
// registering a tunnel connection, report 'degraded'. We do NOT kill or
|
||||
// restart the process — cloudflared may still be retrying legitimately.
|
||||
clearConnectTimeoutTimer();
|
||||
connectTimeoutTimer = setTimeout(() => {
|
||||
connectTimeoutTimer = null;
|
||||
if (tunnelProcess === proc && !tunnelConnected) {
|
||||
tunnelState = 'degraded';
|
||||
console.warn(`[Tunnel] No tunnel connection registered after ${TUNNEL_CONNECT_TIMEOUT_MS / 1000}s.`);
|
||||
console.warn('[Tunnel] Tunnel state: degraded. cloudflared is still running — it may still connect.');
|
||||
console.warn(`[Tunnel] Diagnostics:`);
|
||||
console.warn(`[Tunnel] Origin health: ${originHealthy ? 'healthy' : 'UNREACHABLE'}`);
|
||||
console.warn(`[Tunnel] cloudflared PID: ${proc.pid}`);
|
||||
console.warn(`[Tunnel] Tunnel connected: false`);
|
||||
console.warn('[Tunnel] Check that CLOUDFLARE_TUNNEL_TOKEN is valid and not revoked.');
|
||||
}
|
||||
}, INFERRED_CONNECT_MS);
|
||||
}, TUNNEL_CONNECT_TIMEOUT_MS);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Tunnel] Failed to spawn cloudflared:', err.message);
|
||||
tunnelState = 'error';
|
||||
lastLogMessage = err.message;
|
||||
tunnelProcess = null;
|
||||
cloudflaredRunning = false;
|
||||
isStarting = false;
|
||||
clearConnectTimeoutTimer();
|
||||
}
|
||||
}
|
||||
|
||||
function clearConnectTimeoutTimer() {
|
||||
if (connectTimeoutTimer) {
|
||||
clearTimeout(connectTimeoutTimer);
|
||||
connectTimeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the cloudflared process cleanly.
|
||||
* On Windows, uses taskkill /T /F to also terminate any child processes.
|
||||
*/
|
||||
function stopTunnel() {
|
||||
if (inferredConnectTimer) {
|
||||
clearTimeout(inferredConnectTimer);
|
||||
inferredConnectTimer = null;
|
||||
clearConnectTimeoutTimer();
|
||||
|
||||
if (!tunnelProcess) {
|
||||
cloudflaredRunning = false;
|
||||
tunnelConnected = false;
|
||||
return;
|
||||
}
|
||||
if (!tunnelProcess) return;
|
||||
|
||||
const proc = tunnelProcess;
|
||||
tunnelProcess = null;
|
||||
cloudflaredRunning = false;
|
||||
tunnelConnected = false;
|
||||
tunnelState = 'stopped';
|
||||
|
||||
try {
|
||||
console.log(`[Tunnel] Stopping tunnel process (PID: ${proc.pid})...`);
|
||||
console.log(`[Tunnel] Stopping cloudflared process (PID: ${proc.pid})...`);
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], {
|
||||
windowsHide: true,
|
||||
@@ -289,12 +467,18 @@ function stopTunnel() {
|
||||
proc.kill('SIGTERM');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Tunnel] Error while stopping process:', err.message);
|
||||
console.warn('[Tunnel] Error while stopping cloudflared process:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auto-Start / Internet Watch ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks internet connection and auto-starts the tunnel when available.
|
||||
* Checks internet and auto-starts the tunnel if needed.
|
||||
* This is called on startup and periodically.
|
||||
*
|
||||
* Key invariant: restart cloudflared ONLY when the process is not running.
|
||||
* A public HTTP 502 alone is never a reason to call this.
|
||||
*/
|
||||
async function checkAndAutoStart() {
|
||||
const isEnabled = process.env.CLOUDFLARE_TUNNEL_ENABLED !== 'false';
|
||||
@@ -304,8 +488,9 @@ async function checkAndAutoStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If tunnel is already running or launching, do nothing
|
||||
if (tunnelProcess || isStarting) return;
|
||||
// If tunnel is already running or launching, do nothing.
|
||||
// A 502 from the public endpoint is NOT a trigger to restart.
|
||||
if (tunnelProcess !== null || isStarting) return;
|
||||
|
||||
const hasInternet = await checkInternet();
|
||||
lastInternetCheck = hasInternet;
|
||||
@@ -321,8 +506,14 @@ async function checkAndAutoStart() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initializes the tunnel on server startup with internet-aware auto start.
|
||||
* Initializes the tunnel service on server startup.
|
||||
* - Immediately attempts to start if internet is available.
|
||||
* - Polls every INTERNET_CHECK_INTERVAL_MS to auto-recover if internet was down.
|
||||
* - Polls every ORIGIN_CHECK_INTERVAL_MS to check origin health (diagnostic).
|
||||
* - Registers process-exit signal handlers for clean shutdown.
|
||||
*/
|
||||
function startAutoTunnel() {
|
||||
const isEnabled = process.env.CLOUDFLARE_TUNNEL_ENABLED !== 'false';
|
||||
@@ -332,65 +523,94 @@ function startAutoTunnel() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Tunnel] Initializing Cloudflare Tunnel service (auto-start on internet availability)...');
|
||||
console.log('[Tunnel] Initializing Cloudflare Tunnel service...');
|
||||
|
||||
// Immediate attempt
|
||||
checkAndAutoStart();
|
||||
|
||||
// Check periodically so if the server started offline, it automatically starts once internet is up
|
||||
// Run an initial origin check shortly after startup
|
||||
setTimeout(runOriginHealthCheck, 3_000);
|
||||
|
||||
// Periodic internet/restart check (only restarts if process is dead)
|
||||
if (internetTimer) clearInterval(internetTimer);
|
||||
internetTimer = setInterval(checkAndAutoStart, INTERNET_CHECK_INTERVAL_MS);
|
||||
|
||||
// Periodic origin health check (diagnostic, never triggers restart)
|
||||
if (originHealthTimer) clearInterval(originHealthTimer);
|
||||
originHealthTimer = setInterval(runOriginHealthCheck, ORIGIN_CHECK_INTERVAL_MS);
|
||||
|
||||
if (!signalHandlersRegistered) {
|
||||
signalHandlersRegistered = true;
|
||||
|
||||
const cleanExit = () => {
|
||||
if (internetTimer) {
|
||||
clearInterval(internetTimer);
|
||||
internetTimer = null;
|
||||
}
|
||||
if (internetTimer) { clearInterval(internetTimer); internetTimer = null; }
|
||||
if (originHealthTimer) { clearInterval(originHealthTimer); originHealthTimer = null; }
|
||||
stopTunnel();
|
||||
};
|
||||
|
||||
process.on('exit', cleanExit);
|
||||
process.on('SIGINT', () => { cleanExit(); process.exit(0); });
|
||||
process.on('SIGTERM', () => { cleanExit(); process.exit(0); });
|
||||
process.on('SIGUSR2', () => { cleanExit(); process.kill(process.pid, 'SIGUSR2'); });
|
||||
process.on('SIGINT', () => { cleanExit(); process.exit(0); });
|
||||
process.on('SIGTERM', () => { cleanExit(); process.exit(0); });
|
||||
process.on('SIGUSR2', () => { cleanExit(); process.kill(process.pid, 'SIGUSR2'); });
|
||||
process.on('beforeExit', cleanExit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current tunnel status and diagnostics.
|
||||
* All four health dimensions are included for observability.
|
||||
*
|
||||
* NOTE: publicEndpointStatus is not polled automatically to avoid hammering the
|
||||
* Cloudflare edge. It is updated only if an external caller checks the public URL.
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
function getTunnelStatus() {
|
||||
const binPath = getCloudflaredPath();
|
||||
const hostname = getEffectiveHostname();
|
||||
const token = (process.env.CLOUDFLARE_TUNNEL_TOKEN || '').trim();
|
||||
const tokenConfigured = !!(process.env.CLOUDFLARE_TUNNEL_TOKEN || '').trim();
|
||||
|
||||
return {
|
||||
enabled: process.env.CLOUDFLARE_TUNNEL_ENABLED !== 'false',
|
||||
hasExecutable: !!binPath,
|
||||
executablePath: binPath,
|
||||
hasToken: !!token,
|
||||
hasInternet: lastInternetCheck,
|
||||
isRunning: !!tunnelProcess,
|
||||
pid: tunnelProcess ? tunnelProcess.pid : null,
|
||||
status: tunnelState,
|
||||
hostname: hostname,
|
||||
publicUrl: hostname ? `https://${hostname}` : null,
|
||||
lastLog: lastLogMessage,
|
||||
// Configuration
|
||||
enabled: process.env.CLOUDFLARE_TUNNEL_ENABLED !== 'false',
|
||||
hasExecutable: !!binPath,
|
||||
executablePath: binPath,
|
||||
hasToken: tokenConfigured,
|
||||
|
||||
// Connectivity
|
||||
hasInternet: lastInternetCheck,
|
||||
|
||||
// Health dimensions (separate, not collapsed)
|
||||
cloudflaredRunning: cloudflaredRunning,
|
||||
tunnelConnected: tunnelConnected,
|
||||
originHealthy: originHealthy,
|
||||
publicEndpointStatus: publicEndpointStatus,
|
||||
|
||||
// Process
|
||||
pid: tunnelProcess ? tunnelProcess.pid : null,
|
||||
|
||||
// Overall state
|
||||
status: tunnelState,
|
||||
|
||||
// Hostname / URL
|
||||
hostname: hostname,
|
||||
publicUrl: hostname ? `https://${hostname}` : null,
|
||||
|
||||
// Last log line (for diagnostics)
|
||||
lastLog: lastLogMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns detected public tunnel URL (if active and discovered)
|
||||
* Returns detected public tunnel URL only when the tunnel is genuinely connected.
|
||||
* Returns null if cloudflared has not registered a connection.
|
||||
*
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getDetectedTunnelUrl() {
|
||||
if (tunnelState === 'connected') {
|
||||
if (tunnelConnected && tunnelState === 'connected') {
|
||||
const hostname = getEffectiveHostname();
|
||||
if (hostname) {
|
||||
return `https://${hostname}`;
|
||||
}
|
||||
if (hostname) return `https://${hostname}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -402,5 +622,6 @@ module.exports = {
|
||||
getTunnelStatus,
|
||||
getDetectedTunnelUrl,
|
||||
checkInternet,
|
||||
getCloudflaredPath
|
||||
checkOriginHealth,
|
||||
getCloudflaredPath,
|
||||
};
|
||||
+31
-11
@@ -1,12 +1,29 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: NOTE: This script is an emergency fallback runner.
|
||||
:: Cloudflare Tunnel is primarily managed automatically by Node.js (tunnelService.js).
|
||||
|
||||
title TIS RMS Cloudflare Tunnel
|
||||
echo =========================================================
|
||||
echo Starting TIS RMS Cloudflare Tunnel
|
||||
echo TIS RMS Cloudflare Tunnel — EMERGENCY FALLBACK
|
||||
echo =========================================================
|
||||
echo.
|
||||
echo *** WARNING ***
|
||||
echo This script is an EMERGENCY / MANUAL FALLBACK ONLY.
|
||||
echo Cloudflare Tunnel is managed automatically by the
|
||||
echo Node.js backend (tunnelService.js).
|
||||
echo.
|
||||
echo DO NOT run this script while the TIS RMS backend service
|
||||
echo or "node index.js" is running — doing so will create two
|
||||
echo cloudflared processes for the same tunnel, which can
|
||||
echo cause unpredictable behaviour.
|
||||
echo.
|
||||
echo Only run this script if:
|
||||
echo - The backend is fully stopped, AND
|
||||
echo - You need to manually verify the tunnel works, OR
|
||||
echo - You are debugging cloudflared in isolation.
|
||||
echo.
|
||||
echo The token must be set in backend\.env as:
|
||||
echo CLOUDFLARE_TUNNEL_TOKEN=your_token_here
|
||||
echo.
|
||||
echo The token must NOT be hard-coded in this file.
|
||||
echo =========================================================
|
||||
echo.
|
||||
|
||||
@@ -27,7 +44,7 @@ if not exist "%CLOUDFLARED_EXE%" (
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 2. Load token from .env if available
|
||||
:: 2. Load token from .env — NEVER hard-code the token in this file
|
||||
if not defined CLOUDFLARE_TUNNEL_TOKEN (
|
||||
if exist "%ENV_FILE%" (
|
||||
for /f "usebackq tokens=1,* delims==" %%A in ("%ENV_FILE%") do (
|
||||
@@ -36,19 +53,22 @@ if not defined CLOUDFLARE_TUNNEL_TOKEN (
|
||||
)
|
||||
)
|
||||
|
||||
:: Clean token
|
||||
:: Clean token (strip quotes and whitespace)
|
||||
if defined CLOUDFLARE_TUNNEL_TOKEN (
|
||||
set CLOUDFLARE_TUNNEL_TOKEN=%CLOUDFLARE_TUNNEL_TOKEN:"=%
|
||||
set CLOUDFLARE_TUNNEL_TOKEN=%CLOUDFLARE_TUNNEL_TOKEN:'=%
|
||||
)
|
||||
|
||||
:: 3. Run tunnel using token
|
||||
:: 3. Run tunnel using token from .env (never from this file)
|
||||
if defined CLOUDFLARE_TUNNEL_TOKEN (
|
||||
echo [INFO] Connecting to configured Cloudflare Named Tunnel...
|
||||
"%CLOUDFLARED_EXE%" tunnel run --token !CLOUDFLARE_TUNNEL_TOKEN!
|
||||
echo [INFO] Connecting via token loaded from .env...
|
||||
echo [INFO] (Token is not displayed for security)
|
||||
echo.
|
||||
"%CLOUDFLARED_EXE%" tunnel --no-autoupdate run --token !CLOUDFLARE_TUNNEL_TOKEN!
|
||||
) else (
|
||||
echo [ERROR] CLOUDFLARE_TUNNEL_TOKEN was not found in environment or .env file.
|
||||
echo Please set CLOUDFLARE_TUNNEL_TOKEN in .env to connect the tunnel.
|
||||
echo Please set CLOUDFLARE_TUNNEL_TOKEN in backend\.env to connect the tunnel.
|
||||
echo Never put the real token directly in this .bat file.
|
||||
)
|
||||
|
||||
pause
|
||||
Reference in new issue
Block a user