Files

367 lines
11 KiB
JavaScript
Raw Permalink Normal View History

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 <id>.` };
}
return { profile: matches[0], error: null };
}
export {
normalizeProfileProvider,
normalizeProfileMode,
normalizeProfileName,
normalizeProfileHostname,
normalizeProfileToken,
suggestProfileNameFromHostname,
maskToken,
resolveToken,
redactProfileForOutput,
redactProfilesForOutput,
formatProfileTokenStatus,
warnIfUnsafeFilePermissions,
writeTunnelProfilesToDisk,
writeManagedRemotePairsToDiskFromProfiles,
ensureTunnelProfilesMigrated,
resolveProfileByName,
};