import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { getTunnelProfilesFilePath, getLegacyCloudflareManagedRemoteFilePath, } from './cli-paths.js'; const TUNNEL_PROFILES_VERSION = 1; const MAX_TOKEN_FILE_BYTES = 8 * 1024; function normalizeProfileProvider(value) { if (typeof value !== 'string') return ''; return value.trim().toLowerCase(); } function normalizeProfileMode(value) { if (typeof value !== 'string') return ''; return value.trim().toLowerCase(); } function normalizeProfileName(value) { if (typeof value !== 'string') return ''; return value.trim(); } function normalizeProfileHostname(value) { if (typeof value !== 'string') return ''; return value.trim(); } function normalizeProfileToken(value) { if (typeof value !== 'string') return ''; return value.trim(); } function suggestProfileNameFromHostname(hostname) { const normalizedHost = normalizeProfileHostname(hostname); if (!normalizedHost) return 'prod-main'; const firstLabel = normalizedHost.split('.')[0] || normalizedHost; const sanitized = firstLabel.replace(/[^a-zA-Z0-9-_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); return sanitized || 'prod-main'; } function maskToken(token) { if (typeof token !== 'string' || token.length === 0) { return '***'; } if (token.length <= 4) { return '*'.repeat(token.length); } return `${'*'.repeat(Math.max(4, token.length - 4))}${token.slice(-4)}`; } function readTokenFromFileSafely(tokenFilePath) { const absolutePath = path.resolve(tokenFilePath); let realPath; try { realPath = fs.realpathSync(absolutePath); } catch (error) { if (error?.code === 'ENOENT') { throw new Error(`Token file '${absolutePath}' not found.`); } if (error?.code === 'EACCES') { throw new Error(`Token file '${absolutePath}' is not readable. Check file permissions.`); } throw error; } let stats; try { stats = fs.statSync(realPath); } catch (error) { if (error?.code === 'EACCES') { throw new Error(`Token file '${absolutePath}' is not readable. Check file permissions.`); } throw error; } if (!stats.isFile()) { throw new Error(`Token file '${absolutePath}' must be a regular file.`); } if (stats.size <= 0) { throw new Error(`Token file '${absolutePath}' is empty.`); } if (stats.size > MAX_TOKEN_FILE_BYTES) { throw new Error(`Token file '${absolutePath}' is too large (max ${MAX_TOKEN_FILE_BYTES} bytes).`); } const raw = fs.readFileSync(realPath, 'utf8'); if (raw.includes('\u0000')) { throw new Error(`Token file '${absolutePath}' appears to be binary. Use a plain text token file.`); } const value = raw.trim(); if (!value) { throw new Error(`Token file '${absolutePath}' is empty.`); } return value; } function resolveToken(options) { const sources = [ options.tokenStdin ? 'stdin' : null, options.tokenFile ? 'file' : null, options.token ? 'flag' : null, ].filter(Boolean); if (sources.length > 1) { throw new Error(`Multiple token sources specified (${sources.join(', ')}). Use only one of --token, --token-file, or --token-stdin.`); } if (options.tokenStdin) { const fd = fs.openSync('/dev/stdin', 'r'); try { const buf = Buffer.alloc(65536); const bytesRead = fs.readSync(fd, buf, 0, buf.length, null); const value = buf.slice(0, bytesRead).toString('utf8').trim(); if (!value) { throw new Error('No token received from stdin.'); } return value; } finally { fs.closeSync(fd); } } if (options.tokenFile) { return readTokenFromFileSafely(options.tokenFile); } return typeof options.token === 'string' ? options.token.trim() : undefined; } function redactProfileForOutput(profile, showSecrets = false) { if (!profile || typeof profile !== 'object') { return profile; } return { ...profile, token: showSecrets ? profile.token : maskToken(profile.token), }; } function redactProfilesForOutput(profiles, showSecrets = false) { if (!Array.isArray(profiles)) { return profiles; } return profiles.map((entry) => redactProfileForOutput(entry, showSecrets)); } function formatProfileTokenStatus(profile, showSecrets = false) { const token = typeof profile?.token === 'string' ? profile.token.trim() : ''; if (!token) { return 'token:missing'; } if (showSecrets) { return `token:${token}`; } return 'token:present'; } function sanitizeTunnelProfilesData(data) { const parsed = data && typeof data === 'object' ? data : {}; const list = Array.isArray(parsed.profiles) ? parsed.profiles : []; const seen = new Set(); const profiles = []; for (const entry of list) { if (!entry || typeof entry !== 'object') continue; const id = typeof entry.id === 'string' && entry.id.trim().length > 0 ? entry.id.trim() : crypto.randomUUID(); const provider = normalizeProfileProvider(entry.provider); const mode = normalizeProfileMode(entry.mode); const name = normalizeProfileName(entry.name); const hostname = normalizeProfileHostname(entry.hostname); const token = normalizeProfileToken(entry.token); if (!provider || !mode || !name || !hostname || !token) continue; const key = `${provider}::${name.toLowerCase()}`; if (seen.has(key)) continue; seen.add(key); profiles.push({ id, name, provider, mode, hostname, token, createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(), updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(), }); } return { version: TUNNEL_PROFILES_VERSION, profiles }; } function warnIfUnsafeFilePermissions(filePath, { shouldWarn = true } = {}) { if (process.platform === 'win32') { return; } if (!shouldWarn) { return; } try { const stats = fs.statSync(filePath); const perms = stats.mode & 0o777; if (perms & 0o077) { const octal = perms.toString(8).padStart(3, '0'); console.warn( `Warning: Profile file '${filePath}' has permissions ${octal} (should be 600). ` + `Other users may be able to read tunnel tokens. Fix with: chmod 600 '${filePath}'` ); } } catch { // File may not exist yet — not an error } } function readTunnelProfilesFromDisk(options = {}) { const filePath = getTunnelProfilesFilePath(); try { warnIfUnsafeFilePermissions(filePath, options); const raw = fs.readFileSync(filePath, 'utf8'); return sanitizeTunnelProfilesData(JSON.parse(raw)); } catch { return { version: TUNNEL_PROFILES_VERSION, profiles: [] }; } } function writeTunnelProfilesToDisk(data) { const filePath = getTunnelProfilesFilePath(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(sanitizeTunnelProfilesData(data), null, 2), { encoding: 'utf8', mode: 0o600 }); } function writeManagedRemotePairsToDiskFromProfiles(profilesData) { const profiles = sanitizeTunnelProfilesData(profilesData).profiles; const cloudflareManagedRemote = profiles.filter( (entry) => entry.provider === 'cloudflare' && entry.mode === 'managed-remote' ); const tunnels = cloudflareManagedRemote.map((entry) => ({ id: entry.id, name: entry.name, hostname: entry.hostname, token: entry.token, updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(), })); const filePath = getLegacyCloudflareManagedRemoteFilePath(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify({ version: 1, tunnels }, null, 2), { encoding: 'utf8', mode: 0o600 }); } function readLegacyManagedRemoteEntries() { try { const raw = fs.readFileSync(getLegacyCloudflareManagedRemoteFilePath(), 'utf8'); const parsed = JSON.parse(raw); const tunnels = Array.isArray(parsed?.tunnels) ? parsed.tunnels : []; return tunnels .map((entry) => { if (!entry || typeof entry !== 'object') return null; const id = typeof entry.id === 'string' && entry.id.trim().length > 0 ? entry.id.trim() : crypto.randomUUID(); const name = normalizeProfileName(entry.name); const hostname = normalizeProfileHostname(entry.hostname); const token = normalizeProfileToken(entry.token); if (!name || !hostname || !token) return null; return { id, name, provider: 'cloudflare', mode: 'managed-remote', hostname, token, createdAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(), updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(), }; }) .filter(Boolean); } catch { return []; } } function makeUniqueProfileName(provider, desiredName, existingProfiles) { const normalizedDesired = normalizeProfileName(desiredName); if (!normalizedDesired) { return ''; } const existingNames = new Set( existingProfiles .filter((entry) => entry.provider === provider) .map((entry) => entry.name.toLowerCase()) ); if (!existingNames.has(normalizedDesired.toLowerCase())) { return normalizedDesired; } let index = 2; while (true) { const candidate = `${normalizedDesired}-${index}`; if (!existingNames.has(candidate.toLowerCase())) { return candidate; } index += 1; } } function ensureTunnelProfilesMigrated(options = {}) { const current = readTunnelProfilesFromDisk(options); if (current.profiles.length > 0) { return current; } const legacyEntries = readLegacyManagedRemoteEntries(); if (legacyEntries.length === 0) { return current; } const migratedProfiles = []; for (const entry of legacyEntries) { const name = makeUniqueProfileName(entry.provider, entry.name, migratedProfiles); migratedProfiles.push({ ...entry, name }); } const migrated = sanitizeTunnelProfilesData({ version: TUNNEL_PROFILES_VERSION, profiles: migratedProfiles }); writeTunnelProfilesToDisk(migrated); writeManagedRemotePairsToDiskFromProfiles(migrated); return migrated; } function resolveProfileByName(profiles, profileName, provider) { const normalizedName = normalizeProfileName(profileName).toLowerCase(); const normalizedProvider = normalizeProfileProvider(provider); const matches = profiles.filter((entry) => { if (entry.name.toLowerCase() !== normalizedName) return false; if (!normalizedProvider) return true; return entry.provider === normalizedProvider; }); if (matches.length === 0) { return { profile: null, error: `No tunnel profile found for name '${profileName}'. Run 'openchamber tunnel profile list'.` }; } if (matches.length > 1) { return { profile: null, error: `Profile name '${profileName}' exists for multiple providers. Use --provider .` }; } return { profile: matches[0], error: null }; } export { normalizeProfileProvider, normalizeProfileMode, normalizeProfileName, normalizeProfileHostname, normalizeProfileToken, suggestProfileNameFromHostname, maskToken, resolveToken, redactProfileForOutput, redactProfilesForOutput, formatProfileTokenStatus, warnIfUnsafeFilePermissions, writeTunnelProfilesToDisk, writeManagedRemotePairsToDiskFromProfiles, ensureTunnelProfilesMigrated, resolveProfileByName, };