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,7 +9,13 @@ import { fileURLToPath } from 'url';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import { createUiAuth } from './lib/opencode/ui-auth.js';
|
||||
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
||||
import { createTunnelAuth } from './lib/opencode/tunnel-auth.js';
|
||||
import {
|
||||
startCloudflareQuickTunnel,
|
||||
startCloudflareNamedTunnel,
|
||||
printTunnelWarning,
|
||||
checkCloudflaredAvailable,
|
||||
} from './lib/cloudflare-tunnel.js';
|
||||
import { prepareNotificationLastMessage } from './lib/notifications/index.js';
|
||||
import {
|
||||
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||
@@ -36,6 +42,14 @@ const MODELS_METADATA_CACHE_TTL = 5 * 60 * 1000;
|
||||
const CLIENT_RELOAD_DELAY_MS = 800;
|
||||
const OPEN_CODE_READY_GRACE_MS = 12000;
|
||||
const LONG_REQUEST_TIMEOUT_MS = 4 * 60 * 1000;
|
||||
const TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS = 30 * 60 * 1000;
|
||||
const TUNNEL_BOOTSTRAP_TTL_MIN_MS = 60 * 1000;
|
||||
const TUNNEL_BOOTSTRAP_TTL_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
const TUNNEL_SESSION_TTL_DEFAULT_MS = 8 * 60 * 60 * 1000;
|
||||
const TUNNEL_SESSION_TTL_MIN_MS = 5 * 60 * 1000;
|
||||
const TUNNEL_SESSION_TTL_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
const TUNNEL_MODE_QUICK = 'quick';
|
||||
const TUNNEL_MODE_NAMED = 'named';
|
||||
const OPENCHAMBER_VERSION = (() => {
|
||||
try {
|
||||
const packagePath = path.resolve(__dirname, '..', 'package.json');
|
||||
@@ -96,6 +110,106 @@ const MAX_THEME_JSON_BYTES = 512 * 1024;
|
||||
|
||||
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const normalizeTunnelBootstrapTtlMs = (value) => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS;
|
||||
}
|
||||
return clampNumber(Math.round(value), TUNNEL_BOOTSTRAP_TTL_MIN_MS, TUNNEL_BOOTSTRAP_TTL_MAX_MS);
|
||||
};
|
||||
|
||||
const normalizeTunnelSessionTtlMs = (value) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return TUNNEL_SESSION_TTL_DEFAULT_MS;
|
||||
}
|
||||
return clampNumber(Math.round(value), TUNNEL_SESSION_TTL_MIN_MS, TUNNEL_SESSION_TTL_MAX_MS);
|
||||
};
|
||||
|
||||
const normalizeTunnelMode = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return TUNNEL_MODE_QUICK;
|
||||
}
|
||||
const mode = value.trim().toLowerCase();
|
||||
if (mode === TUNNEL_MODE_NAMED) {
|
||||
return TUNNEL_MODE_NAMED;
|
||||
}
|
||||
return TUNNEL_MODE_QUICK;
|
||||
};
|
||||
|
||||
const normalizeNamedTunnelHostname = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = (() => {
|
||||
try {
|
||||
if (trimmed.includes('://')) {
|
||||
return new URL(trimmed);
|
||||
}
|
||||
return new URL(`https://${trimmed}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const hostname = parsed?.hostname?.trim().toLowerCase() || '';
|
||||
if (!hostname) {
|
||||
return undefined;
|
||||
}
|
||||
return hostname;
|
||||
};
|
||||
|
||||
const normalizeNamedTunnelPresets = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
const seenIds = new Set();
|
||||
const seenHostnames = new Set();
|
||||
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const candidate = entry;
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
||||
const hostname = normalizeNamedTunnelHostname(candidate.hostname);
|
||||
if (!id || !name || !hostname) continue;
|
||||
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
|
||||
seenIds.add(id);
|
||||
seenHostnames.add(hostname);
|
||||
result.push({ id, name, hostname });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeNamedTunnelPresetTokens = (value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const [rawId, rawToken] of Object.entries(value)) {
|
||||
const id = typeof rawId === 'string' ? rawId.trim() : '';
|
||||
const token = typeof rawToken === 'string' ? rawToken.trim() : '';
|
||||
if (!id || !token) {
|
||||
continue;
|
||||
}
|
||||
result[id] = token;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
};
|
||||
|
||||
const isValidThemeColor = (value) => isNonEmptyString(value);
|
||||
|
||||
const normalizeThemeJson = (raw) => {
|
||||
@@ -1021,6 +1135,8 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||
: path.join(os.homedir(), '.config', 'openchamber');
|
||||
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
|
||||
const CLOUDFLARE_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json');
|
||||
const CLOUDFLARE_NAMED_TUNNELS_VERSION = 1;
|
||||
const PROJECT_ICONS_DIR_PATH = path.join(OPENCHAMBER_DATA_DIR, 'project-icons');
|
||||
const PROJECT_ICON_MIME_TO_EXTENSION = {
|
||||
'image/png': 'png',
|
||||
@@ -1154,6 +1270,7 @@ const writeSettingsToDisk = async (settings) => {
|
||||
|
||||
const PUSH_SUBSCRIPTIONS_VERSION = 1;
|
||||
let persistPushSubscriptionsLock = Promise.resolve();
|
||||
let persistNamedTunnelConfigLock = Promise.resolve();
|
||||
|
||||
const readPushSubscriptionsFromDisk = async () => {
|
||||
try {
|
||||
@@ -1201,6 +1318,167 @@ const persistPushSubscriptionUpdate = async (mutate) => {
|
||||
return persistPushSubscriptionsLock;
|
||||
};
|
||||
|
||||
const sanitizeNamedTunnelConfigEntries = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = [];
|
||||
const seenIds = new Set();
|
||||
const seenHostnames = new Set();
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
||||
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
|
||||
const hostname = normalizeNamedTunnelHostname(entry.hostname);
|
||||
const token = typeof entry.token === 'string' ? entry.token.trim() : '';
|
||||
const updatedAt = Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now();
|
||||
|
||||
if (!id || !name || !hostname || !token) {
|
||||
continue;
|
||||
}
|
||||
if (seenIds.has(id) || seenHostnames.has(hostname)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenHostnames.add(hostname);
|
||||
result.push({ id, name, hostname, token, updatedAt });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const readNamedTunnelConfigFromDisk = async () => {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(CLOUDFLARE_NAMED_TUNNELS_FILE_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return { version: CLOUDFLARE_NAMED_TUNNELS_VERSION, tunnels: [] };
|
||||
}
|
||||
|
||||
const version = parsed.version === CLOUDFLARE_NAMED_TUNNELS_VERSION
|
||||
? CLOUDFLARE_NAMED_TUNNELS_VERSION
|
||||
: CLOUDFLARE_NAMED_TUNNELS_VERSION;
|
||||
|
||||
return {
|
||||
version,
|
||||
tunnels: sanitizeNamedTunnelConfigEntries(parsed.tunnels),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return { version: CLOUDFLARE_NAMED_TUNNELS_VERSION, tunnels: [] };
|
||||
}
|
||||
console.warn('Failed to read named tunnel config file:', error);
|
||||
return { version: CLOUDFLARE_NAMED_TUNNELS_VERSION, tunnels: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const writeNamedTunnelConfigToDisk = async (data) => {
|
||||
await fsPromises.mkdir(path.dirname(CLOUDFLARE_NAMED_TUNNELS_FILE_PATH), { recursive: true });
|
||||
await fsPromises.writeFile(CLOUDFLARE_NAMED_TUNNELS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const updateNamedTunnelConfig = async (mutate) => {
|
||||
persistNamedTunnelConfigLock = persistNamedTunnelConfigLock.then(async () => {
|
||||
const current = await readNamedTunnelConfigFromDisk();
|
||||
const next = mutate({
|
||||
version: CLOUDFLARE_NAMED_TUNNELS_VERSION,
|
||||
tunnels: sanitizeNamedTunnelConfigEntries(current.tunnels),
|
||||
});
|
||||
|
||||
await writeNamedTunnelConfigToDisk({
|
||||
version: CLOUDFLARE_NAMED_TUNNELS_VERSION,
|
||||
tunnels: sanitizeNamedTunnelConfigEntries(next?.tunnels),
|
||||
});
|
||||
});
|
||||
|
||||
return persistNamedTunnelConfigLock;
|
||||
};
|
||||
|
||||
const syncNamedTunnelConfigWithPresets = async (presets) => {
|
||||
const sanitizedPresets = normalizeNamedTunnelPresets(presets) || [];
|
||||
|
||||
await updateNamedTunnelConfig((current) => {
|
||||
const byId = new Map(current.tunnels.map((entry) => [entry.id, entry]));
|
||||
const byHostname = new Map(current.tunnels.map((entry) => [entry.hostname, entry]));
|
||||
|
||||
const nextTunnels = [];
|
||||
for (const preset of sanitizedPresets) {
|
||||
const existing = byId.get(preset.id) || byHostname.get(preset.hostname) || null;
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextTunnels.push({
|
||||
...existing,
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
hostname: preset.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version: CLOUDFLARE_NAMED_TUNNELS_VERSION,
|
||||
tunnels: nextTunnels,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const upsertNamedTunnelToken = async ({ id, name, hostname, token }) => {
|
||||
if (typeof id !== 'string' || typeof name !== 'string' || typeof hostname !== 'string' || typeof token !== 'string') {
|
||||
return;
|
||||
}
|
||||
const normalizedId = id.trim();
|
||||
const normalizedName = name.trim();
|
||||
const normalizedHostname = normalizeNamedTunnelHostname(hostname);
|
||||
const normalizedToken = token.trim();
|
||||
if (!normalizedId || !normalizedName || !normalizedHostname || !normalizedToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateNamedTunnelConfig((current) => {
|
||||
const withoutConflicts = current.tunnels.filter((entry) => entry.id !== normalizedId && entry.hostname !== normalizedHostname);
|
||||
withoutConflicts.push({
|
||||
id: normalizedId,
|
||||
name: normalizedName,
|
||||
hostname: normalizedHostname,
|
||||
token: normalizedToken,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
version: CLOUDFLARE_NAMED_TUNNELS_VERSION,
|
||||
tunnels: withoutConflicts,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const resolveNamedTunnelToken = async ({ presetId, hostname }) => {
|
||||
const normalizedPresetId = typeof presetId === 'string' ? presetId.trim() : '';
|
||||
const normalizedHostname = normalizeNamedTunnelHostname(hostname);
|
||||
const config = await readNamedTunnelConfigFromDisk();
|
||||
|
||||
if (normalizedPresetId) {
|
||||
const byId = config.tunnels.find((entry) => entry.id === normalizedPresetId);
|
||||
if (byId?.token) {
|
||||
return byId.token;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedHostname) {
|
||||
const byHostname = config.tunnels.find((entry) => entry.hostname === normalizedHostname);
|
||||
if (byHostname?.token) {
|
||||
return byHostname.token;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveDirectoryCandidate = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
@@ -1612,6 +1890,38 @@ const sanitizeSettingsUpdate = (payload) => {
|
||||
const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays)));
|
||||
result.autoDeleteAfterDays = normalizedDays;
|
||||
}
|
||||
if (candidate.tunnelBootstrapTtlMs === null) {
|
||||
result.tunnelBootstrapTtlMs = null;
|
||||
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
|
||||
result.tunnelBootstrapTtlMs = normalizeTunnelBootstrapTtlMs(candidate.tunnelBootstrapTtlMs);
|
||||
}
|
||||
if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
|
||||
result.tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(candidate.tunnelSessionTtlMs);
|
||||
}
|
||||
if (typeof candidate.tunnelMode === 'string') {
|
||||
result.tunnelMode = normalizeTunnelMode(candidate.tunnelMode);
|
||||
}
|
||||
if (typeof candidate.namedTunnelHostname === 'string') {
|
||||
const hostname = normalizeNamedTunnelHostname(candidate.namedTunnelHostname);
|
||||
result.namedTunnelHostname = hostname;
|
||||
}
|
||||
if (candidate.namedTunnelToken === null) {
|
||||
result.namedTunnelToken = null;
|
||||
} else if (typeof candidate.namedTunnelToken === 'string') {
|
||||
result.namedTunnelToken = candidate.namedTunnelToken.trim();
|
||||
}
|
||||
const namedTunnelPresets = normalizeNamedTunnelPresets(candidate.namedTunnelPresets);
|
||||
if (namedTunnelPresets) {
|
||||
result.namedTunnelPresets = namedTunnelPresets;
|
||||
}
|
||||
const namedTunnelPresetTokens = normalizeNamedTunnelPresetTokens(candidate.namedTunnelPresetTokens);
|
||||
if (namedTunnelPresetTokens) {
|
||||
result.namedTunnelPresetTokens = namedTunnelPresetTokens;
|
||||
}
|
||||
if (typeof candidate.namedTunnelSelectedPresetId === 'string') {
|
||||
const id = candidate.namedTunnelSelectedPresetId.trim();
|
||||
result.namedTunnelSelectedPresetId = id || undefined;
|
||||
}
|
||||
|
||||
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
|
||||
if (typography) {
|
||||
@@ -1892,11 +2202,14 @@ const mergePersistedSettings = (current, changes) => {
|
||||
|
||||
const formatSettingsResponse = (settings) => {
|
||||
const sanitized = sanitizeSettingsUpdate(settings);
|
||||
delete sanitized.namedTunnelToken;
|
||||
const approved = normalizeStringArray(settings.approvedDirectories);
|
||||
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
||||
const hasNamedTunnelToken = typeof settings?.namedTunnelToken === 'string' && settings.namedTunnelToken.trim().length > 0;
|
||||
|
||||
return {
|
||||
...sanitized,
|
||||
hasNamedTunnelToken,
|
||||
approvedDirectories: approved,
|
||||
securityScopedBookmarks: bookmarks,
|
||||
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
||||
@@ -2846,6 +3159,32 @@ const persistSettings = async (changes) => {
|
||||
next = { ...next, activeProjectId: undefined };
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(sanitized, 'namedTunnelPresets')) {
|
||||
await syncNamedTunnelConfigWithPresets(next.namedTunnelPresets);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(sanitized, 'namedTunnelPresetTokens') && sanitized.namedTunnelPresetTokens) {
|
||||
const presetsById = new Map((next.namedTunnelPresets || []).map((entry) => [entry.id, entry]));
|
||||
const updates = Object.entries(sanitized.namedTunnelPresetTokens)
|
||||
.map(([presetId, token]) => {
|
||||
const preset = presetsById.get(presetId);
|
||||
if (!preset || typeof token !== 'string' || token.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
hostname: preset.hostname,
|
||||
token: token.trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const update of updates) {
|
||||
await upsertNamedTunnelToken(update);
|
||||
}
|
||||
}
|
||||
|
||||
await writeSettingsToDisk(next);
|
||||
console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`);
|
||||
return formatSettingsResponse(next);
|
||||
@@ -2904,6 +3243,9 @@ let isExternalOpenCode = false;
|
||||
let exitOnShutdown = true;
|
||||
let uiAuthController = null;
|
||||
let cloudflareTunnelController = null;
|
||||
const tunnelAuthController = createTunnelAuth();
|
||||
let runtimeNamedTunnelToken = '';
|
||||
let runtimeNamedTunnelHostname = '';
|
||||
let terminalInputWsServer = null;
|
||||
const userProvidedOpenCodePassword =
|
||||
typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0
|
||||
@@ -5869,6 +6211,7 @@ async function gracefulShutdown(options = {}) {
|
||||
console.log('Stopping Cloudflare tunnel...');
|
||||
cloudflareTunnelController.stop();
|
||||
cloudflareTunnelController = null;
|
||||
tunnelAuthController.clearActiveTunnel();
|
||||
}
|
||||
|
||||
console.log('Graceful shutdown complete');
|
||||
@@ -6003,7 +6346,8 @@ async function main(options = {}) {
|
||||
req.path.startsWith('/api/opencode') ||
|
||||
req.path.startsWith('/api/push') ||
|
||||
req.path.startsWith('/api/voice') ||
|
||||
req.path.startsWith('/api/tts')
|
||||
req.path.startsWith('/api/tts') ||
|
||||
req.path.startsWith('/api/openchamber/tunnel')
|
||||
) {
|
||||
|
||||
express.json({ limit: '50mb' })(req, res, next);
|
||||
@@ -6029,16 +6373,65 @@ async function main(options = {}) {
|
||||
}
|
||||
|
||||
app.get('/auth/session', async (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
const tunnelSession = tunnelAuthController.getTunnelSessionFromRequest(req);
|
||||
if (tunnelSession) {
|
||||
return res.json({ authenticated: true, scope: 'tunnel' });
|
||||
}
|
||||
tunnelAuthController.clearTunnelSessionCookie(req, res);
|
||||
return res.status(401).json({ authenticated: false, locked: true, tunnelLocked: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await uiAuthController.handleSessionStatus(req, res);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
app.post('/auth/session', (req, res) => uiAuthController.handleSessionCreate(req, res));
|
||||
app.post('/auth/session', (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Password login is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
return uiAuthController.handleSessionCreate(req, res);
|
||||
});
|
||||
|
||||
app.get('/connect', async (req, res) => {
|
||||
try {
|
||||
const token = typeof req.query?.t === 'string' ? req.query.t : '';
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
|
||||
|
||||
const exchange = tunnelAuthController.exchangeBootstrapToken({
|
||||
req,
|
||||
res,
|
||||
token,
|
||||
sessionTtlMs: tunnelSessionTtlMs,
|
||||
});
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
if (!exchange.ok) {
|
||||
if (exchange.reason === 'rate-limited') {
|
||||
res.setHeader('Retry-After', String(exchange.retryAfter || 60));
|
||||
return res.status(429).type('text/plain').send('Too many attempts. Please try again later.');
|
||||
}
|
||||
return res.status(401).type('text/plain').send('Connection link is invalid or expired.');
|
||||
}
|
||||
|
||||
return res.redirect(302, '/');
|
||||
} catch (error) {
|
||||
return res.status(500).type('text/plain').send('Failed to process connect request.');
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api', async (req, res, next) => {
|
||||
try {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return tunnelAuthController.requireTunnelSession(req, res, next);
|
||||
}
|
||||
await uiAuthController.requireAuth(req, res, next);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -6726,6 +7119,250 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cloudflare Tunnel API ──────────────────────────────────────────
|
||||
|
||||
app.get('/api/openchamber/tunnel/check', async (_req, res) => {
|
||||
try {
|
||||
const result = await checkCloudflaredAvailable();
|
||||
res.json({ available: result.available, version: result.version || null });
|
||||
} catch (error) {
|
||||
console.warn('Cloudflare tunnel check failed:', error);
|
||||
res.json({ available: false, version: null });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/openchamber/tunnel/status', async (_req, res) => {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const mode = normalizeTunnelMode(settings?.tunnelMode);
|
||||
const namedHostname = normalizeNamedTunnelHostname(settings?.namedTunnelHostname);
|
||||
const namedTunnelConfig = await readNamedTunnelConfigFromDisk();
|
||||
const hasLegacyNamedToken = typeof settings?.namedTunnelToken === 'string' && settings.namedTunnelToken.trim().length > 0;
|
||||
const hasNamedTunnelToken = runtimeNamedTunnelToken.length > 0 || namedTunnelConfig.tunnels.length > 0 || hasLegacyNamedToken;
|
||||
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
|
||||
? null
|
||||
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
|
||||
const sessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
|
||||
const activeSessions = tunnelAuthController.listTunnelSessions();
|
||||
|
||||
const publicUrl = cloudflareTunnelController?.getPublicUrl?.() ?? null;
|
||||
if (!publicUrl) {
|
||||
return res.json({
|
||||
active: false,
|
||||
url: null,
|
||||
mode,
|
||||
hasNamedTunnelToken,
|
||||
namedTunnelHostname: namedHostname || null,
|
||||
namedTunnelTokenPresetIds: namedTunnelConfig.tunnels.map((entry) => entry.id),
|
||||
hasBootstrapToken: false,
|
||||
bootstrapExpiresAt: null,
|
||||
policy: 'tunnel-gated',
|
||||
activeTunnelMode: tunnelAuthController.getActiveTunnelMode() || null,
|
||||
activeSessions,
|
||||
localPort: activePort,
|
||||
ttlConfig: {
|
||||
bootstrapTtlMs,
|
||||
sessionTtlMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const activeMode = cloudflareTunnelController?.mode === TUNNEL_MODE_NAMED ? TUNNEL_MODE_NAMED : TUNNEL_MODE_QUICK;
|
||||
|
||||
if (!tunnelAuthController.getActiveTunnelId() || !tunnelAuthController.getActiveTunnelHost()) {
|
||||
tunnelAuthController.setActiveTunnel({ tunnelId: crypto.randomUUID(), publicUrl, mode: activeMode });
|
||||
}
|
||||
|
||||
const bootstrapStatus = tunnelAuthController.getBootstrapStatus();
|
||||
|
||||
return res.json({
|
||||
active: true,
|
||||
url: publicUrl,
|
||||
mode: activeMode,
|
||||
hasNamedTunnelToken,
|
||||
namedTunnelHostname: namedHostname || null,
|
||||
namedTunnelTokenPresetIds: namedTunnelConfig.tunnels.map((entry) => entry.id),
|
||||
hasBootstrapToken: bootstrapStatus.hasBootstrapToken,
|
||||
bootstrapExpiresAt: bootstrapStatus.bootstrapExpiresAt,
|
||||
policy: 'tunnel-gated',
|
||||
activeTunnelMode: activeMode,
|
||||
activeSessions: tunnelAuthController.listTunnelSessions(),
|
||||
localPort: activePort,
|
||||
ttlConfig: {
|
||||
bootstrapTtlMs,
|
||||
sessionTtlMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Failed to get tunnel status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/openchamber/tunnel/named-token', async (req, res) => {
|
||||
try {
|
||||
const presetId = typeof req?.body?.presetId === 'string' ? req.body.presetId.trim() : '';
|
||||
const presetName = typeof req?.body?.presetName === 'string' ? req.body.presetName.trim() : '';
|
||||
const namedTunnelHostname = normalizeNamedTunnelHostname(req?.body?.namedTunnelHostname);
|
||||
const namedTunnelToken = typeof req?.body?.namedTunnelToken === 'string' ? req.body.namedTunnelToken.trim() : '';
|
||||
|
||||
if (!presetId || !presetName || !namedTunnelHostname || !namedTunnelToken) {
|
||||
return res.status(400).json({ ok: false, error: 'presetId, presetName, namedTunnelHostname and namedTunnelToken are required' });
|
||||
}
|
||||
|
||||
await upsertNamedTunnelToken({
|
||||
id: presetId,
|
||||
name: presetName,
|
||||
hostname: namedTunnelHostname,
|
||||
token: namedTunnelToken,
|
||||
});
|
||||
|
||||
const namedTunnelConfig = await readNamedTunnelConfigFromDisk();
|
||||
return res.json({ ok: true, namedTunnelTokenPresetIds: namedTunnelConfig.tunnels.map((entry) => entry.id) });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ ok: false, error: 'Failed to save named tunnel token' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/tunnel/start', async (_req, res) => {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const mode = normalizeTunnelMode(_req?.body?.mode ?? settings?.tunnelMode);
|
||||
const selectedPresetId = typeof _req?.body?.namedTunnelPresetId === 'string' ? _req.body.namedTunnelPresetId.trim() : '';
|
||||
const selectedPresetName = typeof _req?.body?.namedTunnelPresetName === 'string' ? _req.body.namedTunnelPresetName.trim() : '';
|
||||
const requestNamedHostname = normalizeNamedTunnelHostname(_req?.body?.namedTunnelHostname);
|
||||
const namedHostname = requestNamedHostname || normalizeNamedTunnelHostname(settings?.namedTunnelHostname);
|
||||
const requestNamedToken = typeof _req?.body?.namedTunnelToken === 'string' ? _req.body.namedTunnelToken.trim() : '';
|
||||
const legacyNamedToken = typeof settings?.namedTunnelToken === 'string' ? settings.namedTunnelToken.trim() : '';
|
||||
const configNamedToken = await resolveNamedTunnelToken({ presetId: selectedPresetId, hostname: namedHostname });
|
||||
const namedToken = requestNamedToken
|
||||
|| ((runtimeNamedTunnelHostname && namedHostname && runtimeNamedTunnelHostname === namedHostname) ? runtimeNamedTunnelToken : '')
|
||||
|| configNamedToken
|
||||
|| legacyNamedToken
|
||||
;
|
||||
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
|
||||
? null
|
||||
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
|
||||
const sessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
|
||||
|
||||
let publicUrl = cloudflareTunnelController?.getPublicUrl?.() ?? null;
|
||||
const activeMode = cloudflareTunnelController?.mode === TUNNEL_MODE_NAMED ? TUNNEL_MODE_NAMED : TUNNEL_MODE_QUICK;
|
||||
|
||||
if (publicUrl && activeMode !== mode) {
|
||||
cloudflareTunnelController.stop();
|
||||
cloudflareTunnelController = null;
|
||||
tunnelAuthController.clearActiveTunnel();
|
||||
publicUrl = null;
|
||||
}
|
||||
|
||||
if (!publicUrl) {
|
||||
const cfCheck = await checkCloudflaredAvailable();
|
||||
if (!cfCheck.available) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: 'cloudflared is not installed. Install it with: brew install cloudflared',
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === TUNNEL_MODE_NAMED) {
|
||||
if (!namedHostname) {
|
||||
return res.status(400).json({ ok: false, error: 'Named tunnel hostname is required' });
|
||||
}
|
||||
if (!namedToken) {
|
||||
return res.status(400).json({ ok: false, error: 'Named tunnel token is required' });
|
||||
}
|
||||
|
||||
runtimeNamedTunnelHostname = namedHostname;
|
||||
runtimeNamedTunnelToken = namedToken;
|
||||
|
||||
if (requestNamedToken && namedHostname) {
|
||||
await upsertNamedTunnelToken({
|
||||
id: selectedPresetId || namedHostname,
|
||||
name: selectedPresetName || namedHostname,
|
||||
hostname: namedHostname,
|
||||
token: requestNamedToken,
|
||||
});
|
||||
}
|
||||
|
||||
cloudflareTunnelController = await startCloudflareNamedTunnel({
|
||||
token: namedToken,
|
||||
hostname: namedHostname,
|
||||
});
|
||||
} else {
|
||||
const originUrl = `http://127.0.0.1:${activePort}`;
|
||||
cloudflareTunnelController = await startCloudflareQuickTunnel({ originUrl, port: activePort });
|
||||
}
|
||||
|
||||
publicUrl = cloudflareTunnelController.getPublicUrl();
|
||||
|
||||
if (!publicUrl) {
|
||||
cloudflareTunnelController.stop();
|
||||
cloudflareTunnelController = null;
|
||||
tunnelAuthController.clearActiveTunnel();
|
||||
return res.status(500).json({ ok: false, error: 'Tunnel started but no public URL was assigned' });
|
||||
}
|
||||
|
||||
if (mode === TUNNEL_MODE_QUICK) {
|
||||
printTunnelWarning();
|
||||
}
|
||||
console.log(`Cloudflare tunnel active: ${publicUrl}`);
|
||||
}
|
||||
|
||||
if (!tunnelAuthController.getActiveTunnelId() || !tunnelAuthController.getActiveTunnelHost()) {
|
||||
tunnelAuthController.setActiveTunnel({ tunnelId: crypto.randomUUID(), publicUrl, mode });
|
||||
}
|
||||
|
||||
const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs });
|
||||
const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`;
|
||||
const namedTunnelConfig = await readNamedTunnelConfigFromDisk();
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
url: publicUrl,
|
||||
mode,
|
||||
namedTunnelHostname: namedHostname || null,
|
||||
namedTunnelTokenPresetIds: namedTunnelConfig.tunnels.map((entry) => entry.id),
|
||||
connectUrl,
|
||||
bootstrapExpiresAt: bootstrapToken.expiresAt,
|
||||
policy: 'tunnel-gated',
|
||||
activeTunnelMode: mode,
|
||||
activeSessions: tunnelAuthController.listTunnelSessions(),
|
||||
localPort: activePort,
|
||||
ttlConfig: {
|
||||
bootstrapTtlMs,
|
||||
sessionTtlMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start Cloudflare tunnel:', error);
|
||||
cloudflareTunnelController = null;
|
||||
tunnelAuthController.clearActiveTunnel();
|
||||
return res.status(500).json({ ok: false, error: error?.message || 'Failed to start tunnel' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/tunnel/stop', (_req, res) => {
|
||||
let revokedBootstrapCount = 0;
|
||||
let invalidatedSessionCount = 0;
|
||||
const activeTunnelId = tunnelAuthController.getActiveTunnelId();
|
||||
|
||||
if (activeTunnelId) {
|
||||
const revoked = tunnelAuthController.revokeTunnelArtifacts(activeTunnelId);
|
||||
revokedBootstrapCount = revoked.revokedBootstrapCount;
|
||||
invalidatedSessionCount = revoked.invalidatedSessionCount;
|
||||
}
|
||||
|
||||
if (cloudflareTunnelController) {
|
||||
console.log('Stopping Cloudflare tunnel (user requested)...');
|
||||
cloudflareTunnelController.stop();
|
||||
cloudflareTunnelController = null;
|
||||
}
|
||||
|
||||
tunnelAuthController.clearActiveTunnel();
|
||||
res.json({ ok: true, revokedBootstrapCount, invalidatedSessionCount });
|
||||
});
|
||||
|
||||
// ── End Cloudflare Tunnel API ─────────────────────────────────────
|
||||
|
||||
app.get('/api/global/event', async (req, res) => {
|
||||
let targetUrl;
|
||||
try {
|
||||
@@ -7071,7 +7708,6 @@ async function main(options = {}) {
|
||||
|
||||
app.put('/api/config/settings', async (req, res) => {
|
||||
console.log(`[API:PUT /api/config/settings] Received request`);
|
||||
console.log(`[API:PUT /api/config/settings] Request body:`, JSON.stringify(req.body, null, 2));
|
||||
try {
|
||||
const updated = await persistSettings(req.body ?? {});
|
||||
console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`);
|
||||
@@ -12176,10 +12812,22 @@ async function main(options = {}) {
|
||||
if (cfCheck.available) {
|
||||
try {
|
||||
const originUrl = `http://localhost:${activePort}`;
|
||||
cloudflareTunnelController = await startCloudflareTunnel({ originUrl, port: activePort });
|
||||
cloudflareTunnelController = await startCloudflareQuickTunnel({ originUrl, port: activePort });
|
||||
printTunnelWarning();
|
||||
const tunnelUrl = cloudflareTunnelController.getPublicUrl();
|
||||
if (tunnelUrl) {
|
||||
tunnelAuthController.setActiveTunnel({
|
||||
tunnelId: crypto.randomUUID(),
|
||||
publicUrl: tunnelUrl,
|
||||
mode: TUNNEL_MODE_QUICK,
|
||||
});
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
|
||||
? null
|
||||
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
|
||||
tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs });
|
||||
}
|
||||
if (onTunnelReady) {
|
||||
const tunnelUrl = cloudflareTunnelController.getPublicUrl();
|
||||
if (tunnelUrl) {
|
||||
onTunnelReady(tunnelUrl);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user