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:
committed by
GitHub
co-authored by
aptdnfapt
parent
fa2223a0e7
commit
6c30245134
@@ -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
|
- Mobile-first UI with gestures and optimized terminal controls
|
||||||
- Self-serve web updates (no CLI required)
|
- Self-serve web updates (no CLI required)
|
||||||
- Update and restart keeps previous server settings (port/password)
|
- Update and restart keeps previous server settings (port/password)
|
||||||
|
- Cloudflare Quick Tunnel support for easy remote access (`--try-cf-tunnel`)
|
||||||
|
|
||||||
### Desktop (macOS)
|
### Desktop (macOS)
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ openchamber # Start on port 3000
|
|||||||
openchamber --port 8080 # Custom port
|
openchamber --port 8080 # Custom port
|
||||||
openchamber --daemon # Background mode
|
openchamber --daemon # Background mode
|
||||||
openchamber --ui-password secret # Password-protect UI
|
openchamber --ui-password secret # Password-protect UI
|
||||||
|
openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access
|
||||||
openchamber stop # Stop server
|
openchamber stop # Stop server
|
||||||
openchamber update # Update to latest version
|
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
|
- [OpenCode CLI](https://opencode.ai) installed
|
||||||
- Node.js 20+ (for web version)
|
- 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.
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
|
|||||||
+56
-15
@@ -11,10 +11,20 @@ const __dirname = path.dirname(__filename);
|
|||||||
const DEFAULT_PORT = 3000;
|
const DEFAULT_PORT = 3000;
|
||||||
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
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() {
|
function parseArgs() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined;
|
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';
|
let command = 'serve';
|
||||||
|
|
||||||
const consumeValue = (currentIndex, inlineValue) => {
|
const consumeValue = (currentIndex, inlineValue) => {
|
||||||
@@ -57,6 +67,9 @@ function parseArgs() {
|
|||||||
case 'd':
|
case 'd':
|
||||||
options.daemon = true;
|
options.daemon = true;
|
||||||
break;
|
break;
|
||||||
|
case 'try-cf-tunnel':
|
||||||
|
options.tryCfTunnel = true;
|
||||||
|
break;
|
||||||
case 'ui-password': {
|
case 'ui-password': {
|
||||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||||
i = nextIndex;
|
i = nextIndex;
|
||||||
@@ -97,11 +110,12 @@ COMMANDS:
|
|||||||
update Check for and install updates
|
update Check for and install updates
|
||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
-p, --port Web server port (default: ${DEFAULT_PORT})
|
-p, --port Web server port (default: ${DEFAULT_PORT})
|
||||||
--ui-password Protect browser UI with single password
|
--ui-password Protect browser UI with single password
|
||||||
-d, --daemon Run in background (serve command)
|
--try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access
|
||||||
-h, --help Show help
|
-d, --daemon Run in background (serve command)
|
||||||
-v, --version Show version
|
-h, --help Show help
|
||||||
|
-v, --version Show version
|
||||||
|
|
||||||
ENVIRONMENT:
|
ENVIRONMENT:
|
||||||
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
|
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
|
||||||
@@ -110,6 +124,7 @@ EXAMPLES:
|
|||||||
openchamber # Start on default port 3000
|
openchamber # Start on default port 3000
|
||||||
openchamber --port 8080 # Start on port 8080
|
openchamber --port 8080 # Start on port 8080
|
||||||
openchamber serve --daemon # Start in background
|
openchamber serve --daemon # Start in background
|
||||||
|
openchamber --try-cf-tunnel # Start with Cloudflare Quick Tunnel
|
||||||
openchamber stop # Stop all running instances
|
openchamber stop # Stop all running instances
|
||||||
openchamber stop --port 3000 # Stop specific instance
|
openchamber stop --port 3000 # Stop specific instance
|
||||||
openchamber status # Check status
|
openchamber status # Check status
|
||||||
@@ -353,9 +368,20 @@ const commands = {
|
|||||||
|
|
||||||
const serverPath = path.join(__dirname, '..', 'server', 'index.js');
|
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()];
|
const serverArgs = [serverPath, '--port', options.port.toString()];
|
||||||
if (typeof options.uiPassword === 'string') {
|
if (typeof effectiveUiPassword === 'string') {
|
||||||
serverArgs.push('--ui-password', options.uiPassword);
|
serverArgs.push('--ui-password', effectiveUiPassword);
|
||||||
|
}
|
||||||
|
if (options.tryCfTunnel) {
|
||||||
|
serverArgs.push('--try-cf-tunnel');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.daemon) {
|
if (options.daemon) {
|
||||||
@@ -367,7 +393,8 @@ const commands = {
|
|||||||
...process.env,
|
...process.env,
|
||||||
OPENCHAMBER_PORT: options.port.toString(),
|
OPENCHAMBER_PORT: options.port.toString(),
|
||||||
OPENCODE_BINARY: opencodeBinary,
|
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(() => {
|
setTimeout(() => {
|
||||||
if (isProcessRunning(child.pid)) {
|
if (isProcessRunning(child.pid)) {
|
||||||
writePidFile(pidFilePath, 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(`OpenChamber started in daemon mode on port ${options.port}`);
|
||||||
console.log(`PID: ${child.pid}`);
|
console.log(`PID: ${child.pid}`);
|
||||||
console.log(`Visit: http://localhost:${options.port}`);
|
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 {
|
} else {
|
||||||
console.error('Failed to start server in daemon mode');
|
console.error('Failed to start server in daemon mode');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -389,16 +420,26 @@ const commands = {
|
|||||||
} else {
|
} else {
|
||||||
|
|
||||||
process.env.OPENCODE_BINARY = opencodeBinary;
|
process.env.OPENCODE_BINARY = opencodeBinary;
|
||||||
if (typeof options.uiPassword === 'string') {
|
if (typeof effectiveUiPassword === 'string') {
|
||||||
process.env.OPENCHAMBER_UI_PASSWORD = options.uiPassword;
|
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);
|
const { startWebUiServer } = await import(serverPath);
|
||||||
await startWebUiServer({
|
const server = await startWebUiServer({
|
||||||
port: options.port,
|
port: options.port,
|
||||||
attachSignals: true,
|
attachSignals: true,
|
||||||
exitOnShutdown: 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`);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"build:watch": "vite build --watch",
|
"build:watch": "vite build --watch",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
|
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
|
||||||
"start": "node server/index.js"
|
"start": "node bin/cli.js serve"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import http from 'http';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { createUiAuth } from './lib/ui-auth.js';
|
import { createUiAuth } from './lib/ui-auth.js';
|
||||||
|
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -578,6 +579,7 @@ let isOpenCodeReady = false;
|
|||||||
let openCodeNotReadySince = 0;
|
let openCodeNotReadySince = 0;
|
||||||
let exitOnShutdown = true;
|
let exitOnShutdown = true;
|
||||||
let uiAuthController = null;
|
let uiAuthController = null;
|
||||||
|
let cloudflareTunnelController = null;
|
||||||
|
|
||||||
// Sync helper - call after modifying any HMR state variable
|
// Sync helper - call after modifying any HMR state variable
|
||||||
const syncToHmrState = () => {
|
const syncToHmrState = () => {
|
||||||
@@ -713,21 +715,18 @@ function resolveBinaryFromPath(binaryName, searchPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getOpencodeSpawnConfig() {
|
function getOpencodeSpawnConfig() {
|
||||||
const envPath = buildAugmentedPath();
|
|
||||||
const resolvedEnv = { ...process.env, PATH: envPath };
|
|
||||||
|
|
||||||
if (OPENCODE_BINARY_ENV) {
|
if (OPENCODE_BINARY_ENV) {
|
||||||
const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, envPath);
|
const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, process.env.PATH);
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`);
|
console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`);
|
||||||
return { command: explicit, env: resolvedEnv };
|
return { command: explicit, env: undefined };
|
||||||
}
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`OPENCODE_BINARY path "${OPENCODE_BINARY_ENV}" not found. Falling back to search.`
|
`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 = (() => {
|
const ENV_CONFIGURED_OPENCODE_PORT = (() => {
|
||||||
@@ -1151,7 +1150,8 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|||||||
process.env.OPENCHAMBER_UI_PASSWORD ||
|
process.env.OPENCHAMBER_UI_PASSWORD ||
|
||||||
process.env.OPENCODE_UI_PASSWORD ||
|
process.env.OPENCODE_UI_PASSWORD ||
|
||||||
null;
|
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) => {
|
const consumeValue = (currentIndex, inlineValue) => {
|
||||||
if (typeof inlineValue === 'string') {
|
if (typeof inlineValue === 'string') {
|
||||||
@@ -1188,6 +1188,11 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|||||||
options.uiPassword = typeof value === 'string' ? value : '';
|
options.uiPassword = typeof value === 'string' ? value : '';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (optionName === 'try-cf-tunnel') {
|
||||||
|
options.tryCfTunnel = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
@@ -1835,6 +1840,12 @@ async function gracefulShutdown(options = {}) {
|
|||||||
uiAuthController = null;
|
uiAuthController = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cloudflareTunnelController) {
|
||||||
|
console.log('Stopping Cloudflare tunnel...');
|
||||||
|
cloudflareTunnelController.stop();
|
||||||
|
cloudflareTunnelController = null;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Graceful shutdown complete');
|
console.log('Graceful shutdown complete');
|
||||||
if (exitProcess) {
|
if (exitProcess) {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -1843,7 +1854,9 @@ async function gracefulShutdown(options = {}) {
|
|||||||
|
|
||||||
async function main(options = {}) {
|
async function main(options = {}) {
|
||||||
const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT;
|
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 attachSignals = options.attachSignals !== false;
|
||||||
|
const onTunnelReady = typeof options.onTunnelReady === 'function' ? options.onTunnelReady : null;
|
||||||
if (typeof options.exitOnShutdown === 'boolean') {
|
if (typeof options.exitOnShutdown === 'boolean') {
|
||||||
exitOnShutdown = options.exitOnShutdown;
|
exitOnShutdown = options.exitOnShutdown;
|
||||||
}
|
}
|
||||||
@@ -4086,13 +4099,35 @@ async function main(options = {}) {
|
|||||||
reject(error);
|
reject(error);
|
||||||
};
|
};
|
||||||
server.once('error', onError);
|
server.once('error', onError);
|
||||||
server.listen(port, () => {
|
server.listen(port, async () => {
|
||||||
server.off('error', onError);
|
server.off('error', onError);
|
||||||
const addressInfo = server.address();
|
const addressInfo = server.address();
|
||||||
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
||||||
console.log(`OpenChamber server running on port ${activePort}`);
|
console.log(`OpenChamber server running on port ${activePort}`);
|
||||||
console.log(`Health check: http://localhost:${activePort}/health`);
|
console.log(`Health check: http://localhost:${activePort}/health`);
|
||||||
console.log(`Web interface: http://localhost:${activePort}`);
|
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();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4119,6 +4154,7 @@ async function main(options = {}) {
|
|||||||
httpServer: server,
|
httpServer: server,
|
||||||
getPort: () => activePort,
|
getPort: () => activePort,
|
||||||
getOpenCodePort: () => openCodePort,
|
getOpenCodePort: () => openCodePort,
|
||||||
|
getTunnelUrl: () => cloudflareTunnelController?.getPublicUrl() ?? null,
|
||||||
isReady: () => isOpenCodeReady,
|
isReady: () => isOpenCodeReady,
|
||||||
restartOpenCode: () => restartOpenCode(),
|
restartOpenCode: () => restartOpenCode(),
|
||||||
stop: (shutdownOptions = {}) =>
|
stop: (shutdownOptions = {}) =>
|
||||||
@@ -4133,6 +4169,7 @@ if (isCliExecution) {
|
|||||||
exitOnShutdown = true;
|
exitOnShutdown = true;
|
||||||
main({
|
main({
|
||||||
port: cliOptions.port,
|
port: cliOptions.port,
|
||||||
|
tryCfTunnel: cliOptions.tryCfTunnel,
|
||||||
attachSignals: true,
|
attachSignals: true,
|
||||||
exitOnShutdown: true,
|
exitOnShutdown: true,
|
||||||
uiPassword: cliOptions.uiPassword
|
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/
|
||||||
|
`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user