From 6c3024513443d0c17ef6953db043a76c1e4f2093 Mon Sep 17 00:00:00 2001 From: Alexis Okuwa Date: Wed, 31 Dec 2025 05:43:18 -0800 Subject: [PATCH] feat(web): add Cloudflare Quick Tunnel support for remote access (#85) * feat(web): add Cloudflare Quick Tunnel support for remote access Implement --try-cf-tunnel flag that creates a temporary public URL routing to the locally running server via cloudflared. Features: - Auto-detect and spawn cloudflared subprocess - Extract and display *.trycloudflare.com URL - Proper cleanup on server shutdown - Dynamic port handling (tunnel routes to actual listening port) - Isolated HOME dir to avoid config conflicts - Installation help and limitations warning * docs: add Cloudflare tunnel feature to README - Document --try-cf-tunnel flag in CLI examples - Add Cloudflare Quick Tunnel to Web/PWA features * docs: add cloudflared as prerequisite for --try-cf-tunnel * feat(web): auto-generate password for Cloudflare tunnel When using --try-cf-tunnel without --ui-password, automatically generate a secure random password to protect the publicly exposed service. Features: - 16-character random password (easy to read, no ambiguous characters) - Password is displayed after tunnel is established - Warning to save the password (not shown again) - Works in both foreground and daemon modes - Uses same password for subsequent restarts (stored in instance file) * fix(web): improve Cloudflare tunnel UX and fix spawn ENAMETOOLONG - Generate and display password before tunnel starts (green colored) - Display tunnel URL in cyan via onTunnelReady callback - Fix ENAMETOOLONG by using env:undefined instead of copying process.env - Pass tryCfTunnel flag from CLI to main function - Remove duplicate tunnel URL output - Update warning message to reflect password protection - Change start script to use CLI wrapper for proper initialization --------- Co-authored-by: aptdnfapt <197602950+aptdnfapt@users.noreply.github.com> --- README.md | 3 + packages/web/bin/cli.js | 71 +++++-- packages/web/package.json | 2 +- packages/web/server/index.js | 53 ++++- packages/web/server/lib/cloudflare-tunnel.js | 196 +++++++++++++++++++ 5 files changed, 301 insertions(+), 24 deletions(-) create mode 100644 packages/web/server/lib/cloudflare-tunnel.js diff --git a/README.md b/README.md index 3510e7d5..b771d6d7 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ The whole project was built entirely with AI coding agents under my supervision. - Mobile-first UI with gestures and optimized terminal controls - Self-serve web updates (no CLI required) - Update and restart keeps previous server settings (port/password) +- Cloudflare Quick Tunnel support for easy remote access (`--try-cf-tunnel`) ### Desktop (macOS) @@ -91,6 +92,7 @@ openchamber # Start on port 3000 openchamber --port 8080 # Custom port openchamber --daemon # Background mode openchamber --ui-password secret # Password-protect UI +openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access openchamber stop # Stop server openchamber update # Update to latest version ``` @@ -103,6 +105,7 @@ Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). - [OpenCode CLI](https://opencode.ai) installed - Node.js 20+ (for web version) +- [cloudflared](https://github.com/cloudflare/cloudflared/releases) (required for `--try-cf-tunnel`) See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 402da671..637c5fe7 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -11,10 +11,20 @@ const __dirname = path.dirname(__filename); const DEFAULT_PORT = 3000; const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); +function generateRandomPassword(length = 16) { + const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; + let password = ''; + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * charset.length); + password += charset[randomIndex]; + } + return password; +} + function parseArgs() { const args = process.argv.slice(2); const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined; - const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword }; + const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword, tryCfTunnel: false }; let command = 'serve'; const consumeValue = (currentIndex, inlineValue) => { @@ -57,6 +67,9 @@ function parseArgs() { case 'd': options.daemon = true; break; + case 'try-cf-tunnel': + options.tryCfTunnel = true; + break; case 'ui-password': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; @@ -97,11 +110,12 @@ COMMANDS: update Check for and install updates OPTIONS: - -p, --port Web server port (default: ${DEFAULT_PORT}) - --ui-password Protect browser UI with single password - -d, --daemon Run in background (serve command) - -h, --help Show help - -v, --version Show version + -p, --port Web server port (default: ${DEFAULT_PORT}) + --ui-password Protect browser UI with single password + --try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access + -d, --daemon Run in background (serve command) + -h, --help Show help + -v, --version Show version ENVIRONMENT: OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag @@ -110,6 +124,7 @@ EXAMPLES: openchamber # Start on default port 3000 openchamber --port 8080 # Start on port 8080 openchamber serve --daemon # Start in background + openchamber --try-cf-tunnel # Start with Cloudflare Quick Tunnel openchamber stop # Stop all running instances openchamber stop --port 3000 # Stop specific instance openchamber status # Check status @@ -353,9 +368,20 @@ const commands = { const serverPath = path.join(__dirname, '..', 'server', 'index.js'); + let effectiveUiPassword = options.uiPassword; + let showAutoGeneratedPassword = false; + + if (options.tryCfTunnel && typeof effectiveUiPassword !== 'string') { + effectiveUiPassword = generateRandomPassword(16); + showAutoGeneratedPassword = true; + } + const serverArgs = [serverPath, '--port', options.port.toString()]; - if (typeof options.uiPassword === 'string') { - serverArgs.push('--ui-password', options.uiPassword); + if (typeof effectiveUiPassword === 'string') { + serverArgs.push('--ui-password', effectiveUiPassword); + } + if (options.tryCfTunnel) { + serverArgs.push('--try-cf-tunnel'); } if (options.daemon) { @@ -367,7 +393,8 @@ const commands = { ...process.env, OPENCHAMBER_PORT: options.port.toString(), OPENCODE_BINARY: opencodeBinary, - ...(typeof options.uiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: options.uiPassword } : {}) + ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), + OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false', } }); @@ -376,10 +403,14 @@ const commands = { setTimeout(() => { if (isProcessRunning(child.pid)) { writePidFile(pidFilePath, child.pid); - writeInstanceOptions(instanceFilePath, options); + writeInstanceOptions(instanceFilePath, { ...options, uiPassword: effectiveUiPassword }); console.log(`OpenChamber started in daemon mode on port ${options.port}`); console.log(`PID: ${child.pid}`); console.log(`Visit: http://localhost:${options.port}`); + if (showAutoGeneratedPassword) { + console.log(`\nšŸ” Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`); + console.log('āš ļø Save this password - it won\'t be shown again!\n'); + } } else { console.error('Failed to start server in daemon mode'); process.exit(1); @@ -389,16 +420,26 @@ const commands = { } else { process.env.OPENCODE_BINARY = opencodeBinary; - if (typeof options.uiPassword === 'string') { - process.env.OPENCHAMBER_UI_PASSWORD = options.uiPassword; + if (typeof effectiveUiPassword === 'string') { + process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword; } - writeInstanceOptions(instanceFilePath, options); + if (showAutoGeneratedPassword) { + console.log(`\nšŸ” Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`); + console.log('āš ļø Save this password - it won\'t be shown again!\n'); + } + + writeInstanceOptions(instanceFilePath, { ...options, uiPassword: effectiveUiPassword }); + const { startWebUiServer } = await import(serverPath); - await startWebUiServer({ + const server = await startWebUiServer({ port: options.port, attachSignals: true, exitOnShutdown: true, - uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : null + uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null, + tryCfTunnel: options.tryCfTunnel, + onTunnelReady: (url) => { + console.log(`\n🌐 Tunnel URL: \x1b[36m${url}\x1b[0m\n`); + }, }); } }, diff --git a/packages/web/package.json b/packages/web/package.json index 51ecfed3..491974ba 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,7 +19,7 @@ "build:watch": "vite build --watch", "type-check": "tsc --noEmit", "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js", - "start": "node server/index.js" + "start": "node bin/cli.js serve" }, "dependencies": { "@fontsource/ibm-plex-mono": "^5.2.7", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 70cf3ebf..a4085029 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -7,6 +7,7 @@ import http from 'http'; import { fileURLToPath } from 'url'; import os from 'os'; import { createUiAuth } from './lib/ui-auth.js'; +import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -578,6 +579,7 @@ let isOpenCodeReady = false; let openCodeNotReadySince = 0; let exitOnShutdown = true; let uiAuthController = null; +let cloudflareTunnelController = null; // Sync helper - call after modifying any HMR state variable const syncToHmrState = () => { @@ -713,21 +715,18 @@ function resolveBinaryFromPath(binaryName, searchPath) { } function getOpencodeSpawnConfig() { - const envPath = buildAugmentedPath(); - const resolvedEnv = { ...process.env, PATH: envPath }; - if (OPENCODE_BINARY_ENV) { - const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, envPath); + const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, process.env.PATH); if (explicit) { console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`); - return { command: explicit, env: resolvedEnv }; + return { command: explicit, env: undefined }; } console.warn( `OPENCODE_BINARY path "${OPENCODE_BINARY_ENV}" not found. Falling back to search.` ); } - return { command: 'opencode', env: resolvedEnv }; + return { command: 'opencode', env: undefined }; } const ENV_CONFIGURED_OPENCODE_PORT = (() => { @@ -1151,7 +1150,8 @@ function parseArgs(argv = process.argv.slice(2)) { process.env.OPENCHAMBER_UI_PASSWORD || process.env.OPENCODE_UI_PASSWORD || null; - const options = { port: DEFAULT_PORT, uiPassword: envPassword }; + const envCfTunnel = process.env.OPENCHAMBER_TRY_CF_TUNNEL === 'true'; + const options = { port: DEFAULT_PORT, uiPassword: envPassword, tryCfTunnel: envCfTunnel }; const consumeValue = (currentIndex, inlineValue) => { if (typeof inlineValue === 'string') { @@ -1188,6 +1188,11 @@ function parseArgs(argv = process.argv.slice(2)) { options.uiPassword = typeof value === 'string' ? value : ''; continue; } + + if (optionName === 'try-cf-tunnel') { + options.tryCfTunnel = true; + continue; + } } return options; @@ -1835,6 +1840,12 @@ async function gracefulShutdown(options = {}) { uiAuthController = null; } + if (cloudflareTunnelController) { + console.log('Stopping Cloudflare tunnel...'); + cloudflareTunnelController.stop(); + cloudflareTunnelController = null; + } + console.log('Graceful shutdown complete'); if (exitProcess) { process.exit(0); @@ -1843,7 +1854,9 @@ async function gracefulShutdown(options = {}) { async function main(options = {}) { const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT; + const tryCfTunnel = options.tryCfTunnel === true; const attachSignals = options.attachSignals !== false; + const onTunnelReady = typeof options.onTunnelReady === 'function' ? options.onTunnelReady : null; if (typeof options.exitOnShutdown === 'boolean') { exitOnShutdown = options.exitOnShutdown; } @@ -4086,13 +4099,35 @@ async function main(options = {}) { reject(error); }; server.once('error', onError); - server.listen(port, () => { + server.listen(port, async () => { server.off('error', onError); const addressInfo = server.address(); activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port; console.log(`OpenChamber server running on port ${activePort}`); console.log(`Health check: http://localhost:${activePort}/health`); console.log(`Web interface: http://localhost:${activePort}`); + + if (tryCfTunnel) { + console.log('\nInitializing Cloudflare Quick Tunnel...'); + const cfCheck = await checkCloudflaredAvailable(); + if (cfCheck.available) { + try { + const originUrl = `http://localhost:${activePort}`; + cloudflareTunnelController = await startCloudflareTunnel({ originUrl, port: activePort }); + printTunnelWarning(); + if (onTunnelReady) { + const tunnelUrl = cloudflareTunnelController.getPublicUrl(); + if (tunnelUrl) { + onTunnelReady(tunnelUrl); + } + } + } catch (error) { + console.error(`Failed to start Cloudflare tunnel: ${error.message}`); + console.log('Continuing without tunnel...'); + } + } + } + resolve(); }); }); @@ -4119,6 +4154,7 @@ async function main(options = {}) { httpServer: server, getPort: () => activePort, getOpenCodePort: () => openCodePort, + getTunnelUrl: () => cloudflareTunnelController?.getPublicUrl() ?? null, isReady: () => isOpenCodeReady, restartOpenCode: () => restartOpenCode(), stop: (shutdownOptions = {}) => @@ -4133,6 +4169,7 @@ if (isCliExecution) { exitOnShutdown = true; main({ port: cliOptions.port, + tryCfTunnel: cliOptions.tryCfTunnel, attachSignals: true, exitOnShutdown: true, uiPassword: cliOptions.uiPassword diff --git a/packages/web/server/lib/cloudflare-tunnel.js b/packages/web/server/lib/cloudflare-tunnel.js new file mode 100644 index 00000000..3efa2f35 --- /dev/null +++ b/packages/web/server/lib/cloudflare-tunnel.js @@ -0,0 +1,196 @@ +import { spawn, spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const TRY_CF_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i; + +async function searchPathFor(command) { + const pathValue = process.env.PATH || ''; + const segments = pathValue.split(path.delimiter).filter(Boolean); + const WINDOWS_EXTENSIONS = process.platform === 'win32' + ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM') + .split(';') + .map((ext) => ext.trim().toLowerCase()) + .filter(Boolean) + .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)) + : ['']; + + for (const dir of segments) { + for (const ext of WINDOWS_EXTENSIONS) { + const fileName = process.platform === 'win32' ? `${command}${ext}` : command; + const candidate = path.join(dir, fileName); + try { + const stats = fs.statSync(candidate); + if (stats.isFile()) { + if (process.platform !== 'win32') { + try { + fs.accessSync(candidate, fs.constants.X_OK); + } catch { + continue; + } + } + return candidate; + } + } catch { + continue; + } + } + } + return null; +} + +export async function checkCloudflaredAvailable() { + const cfPath = await searchPathFor('cloudflared'); + if (cfPath) { + try { + const result = spawnSync(cfPath, ['--version'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + if (result.status === 0) { + return { available: true, path: cfPath, version: result.stdout.trim() }; + } + } catch { + // Ignore + } + } + return { available: false, path: null, version: null }; +} + +export function printCloudflareTunnelInstallHelp() { + const platform = process.platform; + let installCmd = ''; + + if (platform === 'darwin') { + installCmd = 'brew install cloudflared'; + } else if (platform === 'win32') { + installCmd = 'winget install --id Cloudflare.cloudflared'; + } else { + installCmd = 'Download from https://github.com/cloudflare/cloudflared/releases'; + } + + console.log(` +╔══════════════════════════════════════════════════════════════════╗ +ā•‘ Cloudflare tunnel requires 'cloudflared' to be installed ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +Install instructions for your platform: + + macOS: brew install cloudflared + Windows: winget install --id Cloudflare.cloudflared + Linux: Download from https://github.com/cloudflare/cloudflared/releases + +Or visit: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/ +`); +} + +export async function startCloudflareTunnel({ originUrl, port }) { + const cfCheck = await checkCloudflaredAvailable(); + + if (!cfCheck.available) { + printCloudflareTunnelInstallHelp(); + throw new Error('cloudflared is not installed'); + } + + console.log(`Using cloudflared: ${cfCheck.path} (${cfCheck.version})`); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cf-')); + + const child = spawn('cloudflared', ['tunnel', '--url', originUrl], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + HOME: tempDir, + CF_TELEMETRY_DISABLE: '1', + }, + killSignal: 'SIGINT', + }); + + let publicUrl = null; + let tunnelReady = false; + + const onData = (chunk, isStderr) => { + const text = chunk.toString('utf8'); + + if (!tunnelReady) { + const match = text.match(TRY_CF_URL_REGEX); + if (match) { + publicUrl = match[0]; + tunnelReady = true; + } + } + + process.stderr.write(isStderr ? text : ''); + }; + + child.stdout.on('data', (chunk) => onData(chunk, false)); + child.stderr.on('data', (chunk) => onData(chunk, true)); + + child.on('error', (error) => { + console.error(`Cloudflared error: ${error.message}`); + cleanupTempDir(); + }); + + const cleanupTempDir = () => { + try { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } catch { + // Ignore cleanup errors + } + }; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!publicUrl) { + reject(new Error('Tunnel URL not received within 30 seconds')); + } + }, 30000); + + const checkReady = setInterval(() => { + if (publicUrl) { + clearTimeout(timeout); + clearInterval(checkReady); + resolve(null); + } + }, 100); + + child.on('exit', (code) => { + clearTimeout(timeout); + clearInterval(checkReady); + cleanupTempDir(); + if (code !== null && code !== 0) { + reject(new Error(`Cloudflared exited with code ${code}`)); + } + }); + }); + + return { + stop: () => { + try { + child.kill('SIGINT'); + } catch { + // Ignore + } + }, + process: child, + getPublicUrl: () => publicUrl, + }; +} + +export function printTunnelWarning() { + console.log(` +āš ļø Cloudflare Quick Tunnel Limitations: + + • Maximum 200 concurrent requests + • Server-Sent Events (SSE) are NOT supported + • URLs are temporary and will expire when the tunnel stops + • Password protection is required for tunnel access + + For production use, set up a named Cloudflare Tunnel: + https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/ +`); +} \ No newline at end of file