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/
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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