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>
This commit is contained in:
Alexis Okuwa
2025-12-31 15:43:18 +02:00
committed by GitHub
co-authored by aptdnfapt
parent fa2223a0e7
commit 6c30245134
5 changed files with 301 additions and 24 deletions
+45 -8
View File
@@ -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
@@ -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/
`);
}