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/
`);
}
}
@@ -0,0 +1,491 @@
import crypto from 'crypto';
const BOOTSTRAP_TOKEN_COOKIE_SAFE_BYTES = 32;
const TUNNEL_SESSION_COOKIE_NAME = 'oc_tunnel_session';
const CONNECT_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const CONNECT_RATE_LIMIT_LOCK_MS = 10 * 60 * 1000;
const CONNECT_RATE_LIMIT_MAX_ATTEMPTS = 20;
const CONNECT_RATE_LIMIT_NO_IP_MAX_ATTEMPTS = 5;
const parseCookies = (cookieHeader) => {
if (!cookieHeader || typeof cookieHeader !== 'string') {
return {};
}
return cookieHeader.split(';').reduce((acc, segment) => {
const [name, ...rest] = segment.split('=');
if (!name) {
return acc;
}
const key = name.trim();
if (!key) {
return acc;
}
const value = rest.join('=').trim();
acc[key] = decodeURIComponent(value || '');
return acc;
}, {});
};
const isSecureRequest = (req) => {
if (req.secure) {
return true;
}
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
return firstProto === 'https';
}
return false;
};
const buildCookie = ({ name, value, maxAge, secure }) => {
const attributes = [
`${name}=${value}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
];
if (typeof maxAge === 'number') {
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
}
const expires = maxAge === 0
? 'Thu, 01 Jan 1970 00:00:00 GMT'
: new Date(Date.now() + maxAge * 1000).toUTCString();
attributes.push(`Expires=${expires}`);
if (secure) {
attributes.push('Secure');
}
return attributes.join('; ');
};
const nowTs = () => Date.now();
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
const normalizeHost = (candidate) => {
if (typeof candidate !== 'string') {
return null;
}
const trimmed = candidate.trim().toLowerCase();
if (!trimmed) {
return null;
}
return trimmed.replace(/:\d+$/, '');
};
const isLocalHost = (host) => {
if (!host) {
return false;
}
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
};
const getClientIp = (req) => {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
const ip = forwarded.split(',')[0].trim();
if (ip.startsWith('::ffff:')) {
return ip.substring(7);
}
return ip;
}
const ip = req.ip || req.connection?.remoteAddress;
if (ip) {
if (ip.startsWith('::ffff:')) {
return ip.substring(7);
}
return ip;
}
return null;
};
const getRateLimitKey = (req) => {
const ip = getClientIp(req);
if (ip) {
return ip;
}
return 'connect-rate-limit:no-ip';
};
const rateLimitMaxForKey = (key) => {
if (key === 'connect-rate-limit:no-ip') {
return CONNECT_RATE_LIMIT_NO_IP_MAX_ATTEMPTS;
}
return CONNECT_RATE_LIMIT_MAX_ATTEMPTS;
};
export const createTunnelAuth = () => {
let activeTunnelId = null;
let activeTunnelHost = null;
let activeTunnelMode = null;
let activeTunnelPublicUrl = null;
let bootstrapRecord = null;
const tunnelSessions = new Map();
const connectRateLimiter = new Map();
const clearTunnelSessionCookie = (req, res) => {
const secure = isSecureRequest(req);
const header = buildCookie({
name: TUNNEL_SESSION_COOKIE_NAME,
value: '',
maxAge: 0,
secure,
});
res.setHeader('Set-Cookie', header);
};
const setTunnelSessionCookie = (req, res, sessionId, ttlMs) => {
const secure = isSecureRequest(req);
const maxAge = Math.max(0, Math.floor(ttlMs / 1000));
const header = buildCookie({
name: TUNNEL_SESSION_COOKIE_NAME,
value: encodeURIComponent(sessionId),
maxAge,
secure,
});
res.setHeader('Set-Cookie', header);
};
const classifyRequestScope = (req) => {
const hostHeader = normalizeHost(typeof req.headers.host === 'string' ? req.headers.host : '');
const reqHost = normalizeHost(typeof req.hostname === 'string' ? req.hostname : '') || hostHeader;
if (activeTunnelHost && reqHost === activeTunnelHost) {
return 'tunnel';
}
if (isLocalHost(reqHost)) {
return 'local';
}
return 'unknown-public';
};
const revokeBootstrapToken = () => {
if (!bootstrapRecord) {
return 0;
}
if (bootstrapRecord.revokedAt) {
return 0;
}
if (!bootstrapRecord.revokedAt) {
bootstrapRecord.revokedAt = nowTs();
}
return 1;
};
const invalidateTunnelSessions = (tunnelId, reason = 'tunnel-stopped') => {
const revokedAt = nowTs();
let count = 0;
for (const record of tunnelSessions.values()) {
if (record.tunnelId === tunnelId && !record.revokedAt) {
record.revokedAt = revokedAt;
record.revokedReason = reason;
count += 1;
}
}
return count;
};
const revokeTunnelArtifacts = (tunnelId) => {
const revokedBootstrapCount = bootstrapRecord && bootstrapRecord.tunnelId === tunnelId
? revokeBootstrapToken()
: 0;
const invalidatedSessionCount = invalidateTunnelSessions(tunnelId, 'tunnel-revoked');
return { revokedBootstrapCount, invalidatedSessionCount };
};
const setActiveTunnel = ({ tunnelId, publicUrl, mode = null }) => {
activeTunnelId = tunnelId;
activeTunnelMode = mode;
activeTunnelPublicUrl = publicUrl || null;
try {
activeTunnelHost = normalizeHost(new URL(publicUrl).host);
} catch {
activeTunnelHost = null;
}
};
const clearActiveTunnel = () => {
if (activeTunnelId) {
revokeTunnelArtifacts(activeTunnelId);
}
activeTunnelId = null;
activeTunnelHost = null;
activeTunnelMode = null;
activeTunnelPublicUrl = null;
bootstrapRecord = null;
};
const isBootstrapRecordUsable = (record) => {
if (!record || record.revokedAt || record.usedAt) {
return false;
}
if (typeof record.expiresAt === 'number' && nowTs() >= record.expiresAt) {
return false;
}
return true;
};
const issueBootstrapToken = ({ ttlMs }) => {
if (!activeTunnelId) {
throw new Error('Tunnel is not active');
}
revokeBootstrapToken();
const token = crypto.randomBytes(BOOTSTRAP_TOKEN_COOKIE_SAFE_BYTES).toString('base64url');
const issuedAt = nowTs();
const expiresAt = Number.isFinite(ttlMs) && ttlMs > 0 ? issuedAt + ttlMs : null;
bootstrapRecord = {
id: crypto.randomUUID(),
tunnelId: activeTunnelId,
tokenHash: hashToken(token),
issuedAt,
expiresAt,
usedAt: null,
revokedAt: null,
};
return {
token,
expiresAt,
};
};
const getBootstrapStatus = () => {
if (!isBootstrapRecordUsable(bootstrapRecord)) {
return {
hasBootstrapToken: false,
bootstrapExpiresAt: null,
};
}
return {
hasBootstrapToken: true,
bootstrapExpiresAt: bootstrapRecord.expiresAt,
};
};
const checkConnectRateLimit = (req) => {
const key = getRateLimitKey(req);
const now = nowTs();
const maxAttempts = rateLimitMaxForKey(key);
const record = connectRateLimiter.get(key);
if (record?.lockedUntil && now < record.lockedUntil) {
return {
allowed: false,
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
};
}
if (!record || now - record.lastAttempt > CONNECT_RATE_LIMIT_WINDOW_MS) {
return { allowed: true, retryAfter: 0 };
}
if (record.count >= maxAttempts) {
const lockedUntil = now + CONNECT_RATE_LIMIT_LOCK_MS;
connectRateLimiter.set(key, {
count: record.count + 1,
lastAttempt: now,
lockedUntil,
});
return {
allowed: false,
retryAfter: Math.ceil(CONNECT_RATE_LIMIT_LOCK_MS / 1000),
};
}
return { allowed: true, retryAfter: 0 };
};
const recordConnectFailedAttempt = (req) => {
const key = getRateLimitKey(req);
const now = nowTs();
const record = connectRateLimiter.get(key);
if (!record || now - record.lastAttempt > CONNECT_RATE_LIMIT_WINDOW_MS) {
connectRateLimiter.set(key, { count: 1, lastAttempt: now, lockedUntil: null });
return;
}
connectRateLimiter.set(key, {
count: record.count + 1,
lastAttempt: now,
lockedUntil: record.lockedUntil || null,
});
};
const clearConnectRateLimit = (req) => {
const key = getRateLimitKey(req);
connectRateLimiter.delete(key);
};
const getTunnelSessionFromRequest = (req) => {
const cookies = parseCookies(req.headers.cookie);
const token = cookies[TUNNEL_SESSION_COOKIE_NAME];
if (!token) {
return null;
}
const session = tunnelSessions.get(token);
if (!session) {
return null;
}
if (session.revokedAt) {
return null;
}
if (session.expiresAt <= nowTs()) {
if (!session.expiredAt) {
session.expiredAt = nowTs();
}
return null;
}
if (session.tunnelId !== activeTunnelId) {
return null;
}
session.lastSeenAt = nowTs();
return session;
};
const requireTunnelSession = (req, res, next) => {
const session = getTunnelSessionFromRequest(req);
if (session) {
return next();
}
clearTunnelSessionCookie(req, res);
res.status(401).json({
error: 'Tunnel authentication required',
locked: true,
tunnelLocked: true,
});
};
const exchangeBootstrapToken = ({ req, res, token, sessionTtlMs }) => {
const rateLimit = checkConnectRateLimit(req);
if (!rateLimit.allowed) {
return {
ok: false,
reason: 'rate-limited',
retryAfter: rateLimit.retryAfter,
};
}
if (!activeTunnelId || !bootstrapRecord) {
recordConnectFailedAttempt(req);
return { ok: false, reason: 'inactive' };
}
if (!token || typeof token !== 'string') {
recordConnectFailedAttempt(req);
return { ok: false, reason: 'missing-token' };
}
if (!isBootstrapRecordUsable(bootstrapRecord)) {
recordConnectFailedAttempt(req);
return { ok: false, reason: 'expired' };
}
if (bootstrapRecord.tunnelId !== activeTunnelId) {
recordConnectFailedAttempt(req);
return { ok: false, reason: 'tunnel-mismatch' };
}
const incomingHash = hashToken(token);
const expected = bootstrapRecord.tokenHash;
const validHash = incomingHash.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(incomingHash), Buffer.from(expected));
if (!validHash) {
recordConnectFailedAttempt(req);
return { ok: false, reason: 'invalid-token' };
}
bootstrapRecord.usedAt = nowTs();
clearConnectRateLimit(req);
const sessionId = crypto.randomBytes(32).toString('base64url');
const createdAt = nowTs();
const expiresAt = createdAt + sessionTtlMs;
tunnelSessions.set(sessionId, {
sessionId,
tunnelId: activeTunnelId,
mode: activeTunnelMode,
publicUrl: activeTunnelPublicUrl,
createdAt,
lastSeenAt: createdAt,
expiresAt,
revokedAt: null,
revokedReason: null,
expiredAt: null,
});
setTunnelSessionCookie(req, res, sessionId, sessionTtlMs);
return {
ok: true,
sessionExpiresAt: expiresAt,
};
};
const listTunnelSessions = () => {
const now = nowTs();
const sessions = [];
for (const record of tunnelSessions.values()) {
const isExpired = record.expiresAt <= now;
if (isExpired && !record.expiredAt) {
record.expiredAt = now;
}
const active = !record.revokedAt && !isExpired && record.tunnelId === activeTunnelId;
const status = active ? 'active' : 'inactive';
const inactiveReason = record.revokedAt ? (record.revokedReason || 'revoked') : (isExpired ? 'expired' : 'inactive');
sessions.push({
sessionId: record.sessionId,
tunnelId: record.tunnelId,
mode: record.mode,
publicUrl: record.publicUrl,
createdAt: record.createdAt,
lastSeenAt: record.lastSeenAt,
expiresAt: record.expiresAt,
revokedAt: record.revokedAt,
status,
inactiveReason: status === 'inactive' ? inactiveReason : null,
});
}
sessions.sort((a, b) => b.createdAt - a.createdAt);
return sessions;
};
return {
classifyRequestScope,
setActiveTunnel,
clearActiveTunnel,
revokeTunnelArtifacts,
issueBootstrapToken,
getBootstrapStatus,
requireTunnelSession,
getTunnelSessionFromRequest,
exchangeBootstrapToken,
listTunnelSessions,
clearTunnelSessionCookie,
getActiveTunnelId: () => activeTunnelId,
getActiveTunnelHost: () => activeTunnelHost,
getActiveTunnelMode: () => activeTunnelMode,
};
};