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:
committed by
GitHub
co-authored by
Brian-Hwang
parent
a505378d79
commit
d5d0d35083
@@ -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/
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user