feat: redesign remote tunnel settings and named tunnel workflow (#546)

* feat: add Cloudflare Tunnel settings for desktop app

Add a 'Remote Tunnel' section in Settings (desktop-only) that lets users
start/stop a Cloudflare quick tunnel on demand, with auto-generated
password protection and a QR code for easy mobile access.

- Server: 4 new API endpoints (check/status/start/stop) reusing the
  existing cloudflare-tunnel module
- UI: TunnelSettings component with full state machine
  (checking → idle/not-available → starting → active → stopping)
- QR code rendered via the qrcode package for in-app display
- Hidden from VS Code extension (desktop/web only)

* fix: use ?token= instead of ?p= in tunnel password URLs

REST API endpoints were building passwordUrl with ?p=<token> but
SessionAuthGate reads the ?token= query param, causing QR code
auto-login to fail — the password was never extracted from the URL.

Standardize all three tunnel URL construction sites to use ?token=
so scanning the QR code correctly pre-fills and submits the password.

* feat: secure remote tunnel access with one-time connect links

* feat: redesign remote tunnel settings and access flow

* fix: cleaned up unused desktop close code path

* feat: overhaul named tunnel setup and persistence flow

* chore: align codemirror language dependency resolution

---------

Co-authored-by: Brian-Hwang <brian.hwang@cornelisnetworks.com>
This commit is contained in:
Iuliia Ivashko
2026-02-28 04:21:46 +02:00
committed by GitHub
co-authored by Brian-Hwang
parent a505378d79
commit d5d0d35083
15 changed files with 2853 additions and 150 deletions
+90 -12
View File
@@ -9,6 +9,8 @@ const __dirname = path.dirname(__filename);
const TRY_CF_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
const DEFAULT_STARTUP_TIMEOUT_MS = 30000;
async function searchPathFor(command) {
const pathValue = process.env.PATH || '';
const segments = pathValue.split(path.delimiter).filter(Boolean);
@@ -86,7 +88,17 @@ Or visit: https://developers.cloudflare.com/cloudflare-one/networks/connectors/c
`);
}
export async function startCloudflareTunnel({ originUrl, port }) {
const spawnCloudflared = (args, envOverrides = {}) => spawn('cloudflared', args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CF_TELEMETRY_DISABLE: '1',
...envOverrides,
},
killSignal: 'SIGINT',
});
export async function startCloudflareQuickTunnel({ originUrl }) {
const cfCheck = await checkCloudflaredAvailable();
if (!cfCheck.available) {
@@ -98,15 +110,7 @@ export async function startCloudflareTunnel({ originUrl, port }) {
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',
});
const child = spawnCloudflared(['tunnel', '--url', originUrl], { HOME: tempDir });
let publicUrl = null;
let tunnelReady = false;
@@ -148,7 +152,7 @@ export async function startCloudflareTunnel({ originUrl, port }) {
if (!publicUrl) {
reject(new Error('Tunnel URL not received within 30 seconds'));
}
}, 30000);
}, DEFAULT_STARTUP_TIMEOUT_MS);
const checkReady = setInterval(() => {
if (publicUrl) {
@@ -169,6 +173,7 @@ export async function startCloudflareTunnel({ originUrl, port }) {
});
return {
mode: 'quick',
stop: () => {
try {
child.kill('SIGINT');
@@ -181,6 +186,79 @@ export async function startCloudflareTunnel({ originUrl, port }) {
};
}
export async function startCloudflareNamedTunnel({ token, hostname }) {
const cfCheck = await checkCloudflaredAvailable();
if (!cfCheck.available) {
printCloudflareTunnelInstallHelp();
throw new Error('cloudflared is not installed');
}
const normalizedToken = typeof token === 'string' ? token.trim() : '';
const normalizedHost = typeof hostname === 'string' ? hostname.trim().toLowerCase() : '';
if (!normalizedToken) {
throw new Error('Named tunnel token is required');
}
if (!normalizedHost) {
throw new Error('Named tunnel hostname is required');
}
const child = spawnCloudflared(['tunnel', 'run', '--token', normalizedToken]);
const publicUrl = `https://${normalizedHost}`;
let exitedEarly = false;
let earlyExitCode = null;
child.stdout.on('data', () => {
// Keep stream drained, but avoid logging potentially sensitive output.
});
child.stderr.on('data', (chunk) => {
const text = chunk.toString('utf8');
process.stderr.write(text);
});
child.on('error', (error) => {
console.error(`Cloudflared error: ${error.message}`);
});
await new Promise((resolve, reject) => {
const readyTimer = setTimeout(() => {
if (exitedEarly) {
reject(new Error(`Cloudflared exited early with code ${earlyExitCode ?? 'unknown'}`));
} else {
resolve(null);
}
}, 2000);
child.once('exit', (code) => {
exitedEarly = true;
earlyExitCode = code;
clearTimeout(readyTimer);
reject(new Error(`Cloudflared exited with code ${code ?? 'unknown'}`));
});
});
return {
mode: 'named',
stop: () => {
try {
child.kill('SIGINT');
} catch {
// Ignore
}
},
process: child,
getPublicUrl: () => publicUrl,
};
}
export async function startCloudflareTunnel({ originUrl, port }) {
void port;
return startCloudflareQuickTunnel({ originUrl });
}
export function printTunnelWarning() {
console.log(`
⚠️ Cloudflare Quick Tunnel Limitations:
@@ -193,4 +271,4 @@ export function printTunnelWarning() {
For production use, set up a named Cloudflare Tunnel:
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
`);
}
}