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:
ssbg04 committed 2026-09-01 16:49:10 +08:00
1 parent fb99ed0fb8
commit e3452099cd
2 files changed
+327 -86

No files matched your search

+296 -75
View File
@@ -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
View File
@@ -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