const CF_API_BASE = "https://api.cloudflare.com/client/v4"; // ─── Types ─────────────────────────────────────────────────────────────────── export interface AccessApp { id: string; name: string; domain: string; type: string; self_hosted_domains?: string[]; created_at?: string; updated_at?: string; aud?: string; app_launcher_visible?: boolean; session_duration?: string; tags?: string[]; logo_url?: string; [key: string]: unknown; } export interface SshApp { id: string; name: string; domain: string; domains: string[]; launchUrl: string; createdAt: string | null; updatedAt: string | null; tags: string[]; logoUrl: string | null; sessionDuration: string | null; metricsUrl: string | null; } export interface Tunnel { id: string; name: string; status: string; created_at: string; deleted_at: string | null; connections: TunnelConnection[]; [key: string]: unknown; } export interface TunnelConnection { id: string; is_pending_reconnect: boolean; origin_ip: string; opened_at: string; [key: string]: unknown; } export interface SetupInput { serverName: string; hostname: string; sshPort: number; ssoEmail: string; allowedEmails: string[]; } export interface SetupStepResult { step: string; status: "success" | "error" | "skipped"; message: string; data?: Record; } export interface SetupResult { steps: SetupStepResult[]; tunnelToken: string | null; caPubkey: string | null; installCommand: string | null; launchUrl: string | null; } // ─── Credentials ───────────────────────────────────────────────────────────── // Reads from env vars first, falls back to encrypted cookie set via /settings import { requireCFCreds } from "@/lib/credentials"; async function getCredentials() { return requireCFCreds(); } async function cfGet(path: string, revalidateSec = 60) { const { apiToken } = await getCredentials(); const res = await fetch(`${CF_API_BASE}${path}`, { headers: { Authorization: `Bearer ${apiToken}` }, next: { revalidate: revalidateSec }, }); if (!res.ok) { const body = await res.text(); throw new Error(`CF API GET ${path} → ${res.status}: ${body}`); } return res.json(); } async function cfPost(path: string, body: unknown) { const { apiToken } = await getCredentials(); const res = await fetch(`${CF_API_BASE}${path}`, { method: "POST", headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const json = await res.json(); if (!res.ok) { throw new Error( `CF API POST ${path} → ${res.status}: ${JSON.stringify(json.errors ?? json)}` ); } return json; } async function cfPut(path: string, body: unknown) { const { apiToken } = await getCredentials(); const res = await fetch(`${CF_API_BASE}${path}`, { method: "PUT", headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const json = await res.json(); if (!res.ok) { throw new Error( `CF API PUT ${path} → ${res.status}: ${JSON.stringify(json.errors ?? json)}` ); } return json; } async function cfDelete(path: string) { const { apiToken } = await getCredentials(); const res = await fetch(`${CF_API_BASE}${path}`, { method: "DELETE", headers: { Authorization: `Bearer ${apiToken}` }, }); const json = await res.json(); if (!res.ok) { throw new Error( `CF API DELETE ${path} → ${res.status}: ${JSON.stringify(json.errors ?? json)}` ); } return json; } // ─── Access Applications ───────────────────────────────────────────────────── export async function listAccessApps(): Promise { const { accountId } = await getCredentials(); const allApps: AccessApp[] = []; let page = 1; const perPage = 50; while (true) { const json = await cfGet( `/accounts/${accountId}/access/apps?page=${page}&per_page=${perPage}` ); const apps: AccessApp[] = json.result ?? []; allApps.push(...apps); const info = json.result_info; if (!info || page >= info.total_pages) break; page++; } return allApps; } /** Tag added to every Access app created by this console */ export const SSH_CONSOLE_TAG = "managed:ssh-console"; /** * Identifies apps managed by this SSH console. * An app qualifies when it carries the `managed:ssh-console` tag OR * (legacy) has a `metrics:` tag — both patterns are set by `createAccessApp`. */ function isSshConsoleApp(app: AccessApp): boolean { const tags = (app.tags as string[]) ?? []; // New apps: explicit managed tag if (tags.includes(SSH_CONSOLE_TAG)) return true; // Legacy apps created before the tag was introduced: has a metrics: tag // (only this console sets that pattern) if (tags.some((t) => t.startsWith("metrics:"))) return true; return false; } export function filterSshApps(apps: AccessApp[]): SshApp[] { return apps .filter((app) => app.type === "self_hosted" && isSshConsoleApp(app)) .map((app) => { const primaryDomain = app.domain || (app.self_hosted_domains && app.self_hosted_domains[0]) || ""; const allDomains = app.self_hosted_domains ?? (primaryDomain ? [primaryDomain] : []); const rawTags = (app.tags as string[]) ?? []; const metricsTag = rawTags.find((t) => t.startsWith("metrics:")); const metricsUrl = metricsTag ? metricsTag.slice("metrics:".length) : null; const visibleTags = rawTags.filter((t) => !t.startsWith("metrics:")); return { id: app.id, name: app.name || primaryDomain, domain: primaryDomain, domains: allDomains, launchUrl: primaryDomain.startsWith("http") ? primaryDomain : `https://${primaryDomain}`, createdAt: (app.created_at as string) ?? null, updatedAt: (app.updated_at as string) ?? null, tags: visibleTags, logoUrl: (app.logo_url as string) ?? null, sessionDuration: (app.session_duration as string) ?? null, metricsUrl, }; }); } export async function getSshAppById(id: string): Promise { const apps = await listAccessApps(); const ssh = filterSshApps(apps); return ssh.find((a) => a.id === id) ?? null; } // ─── Zones ─────────────────────────────────────────────────────────────────── export async function listZones(): Promise<{ id: string; name: string }[]> { const json = await cfGet("/zones?per_page=50&status=active", 300); return ((json.result ?? []) as { id: string; name: string }[]).map((z) => ({ id: z.id, name: z.name, })); } // ─── Tunnels ───────────────────────────────────────────────────────────────── export async function listTunnels(): Promise { const { accountId } = await getCredentials(); const json = await cfGet( `/accounts/${accountId}/cfd_tunnel?is_deleted=false&per_page=50`, 30 ); return (json.result as Tunnel[]) ?? []; } export async function createTunnel(name: string): Promise { const { accountId } = await getCredentials(); const tunnelSecret = Buffer.from( crypto.getRandomValues(new Uint8Array(32)) ).toString("base64"); const json = await cfPost(`/accounts/${accountId}/cfd_tunnel`, { name, tunnel_secret: tunnelSecret, config_src: "cloudflare", }); return json.result as Tunnel; } export async function deleteTunnel(tunnelId: string): Promise { const { accountId } = await getCredentials(); await cfDelete(`/accounts/${accountId}/cfd_tunnel/${tunnelId}`); } export async function getTunnelToken(tunnelId: string): Promise { const { accountId } = await getCredentials(); const json = await cfGet( `/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`, 0 ); return json.result as string; } export async function configureTunnel( tunnelId: string, hostname: string, sshPort: number ): Promise { const { accountId } = await getCredentials(); await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, { config: { ingress: [ { hostname, service: `ssh://localhost:${sshPort}`, }, { service: "http_status:404", }, ], }, }); } // ─── DNS ───────────────────────────────────────────────────────────────────── export async function findZoneForHostname( hostname: string ): Promise<{ zoneId: string; zoneName: string }> { // Try progressively shorter domain parts to find the zone const parts = hostname.split("."); for (let i = 0; i < parts.length - 1; i++) { const candidate = parts.slice(i).join("."); const json = await cfGet(`/zones?name=${encodeURIComponent(candidate)}`, 0); const zones = json.result ?? []; if (zones.length > 0) { return { zoneId: zones[0].id, zoneName: zones[0].name }; } } throw new Error(`No Cloudflare zone found for hostname: ${hostname}`); } export async function createDnsCname( zoneId: string, name: string, tunnelId: string ): Promise { await cfPost(`/zones/${zoneId}/dns_records`, { type: "CNAME", name, content: `${tunnelId}.cfargotunnel.com`, proxied: true, comment: "Auto-created by SSH Launcher", }); } // ─── Access App Creation ───────────────────────────────────────────────────── export async function createAccessApp( hostname: string, appName: string, allowedEmails: string[] ): Promise<{ appId: string; metricsHostname: string }> { const { accountId } = await getCredentials(); const policies = []; if (allowedEmails.length > 0) { policies.push({ name: "Allow specified emails", decision: "allow", include: allowedEmails.map((email) => ({ email: { email }, })), }); } else { // Fallback: allow any authenticated user policies.push({ name: "Allow any authenticated user", decision: "allow", include: [{ everyone: {} }], }); } const metricsHostname = `metrics.${hostname}`; const json = await cfPost(`/accounts/${accountId}/access/apps`, { name: appName, domain: hostname, type: "self_hosted", session_duration: "24h", auto_redirect_to_identity: true, app_launcher_visible: true, tags: [SSH_CONSOLE_TAG, `metrics:https://${metricsHostname}`], policies, }); return { appId: json.result.id, metricsHostname }; } // ─── Short-lived Certificate CA ────────────────────────────────────────────── export async function getOrCreateCaCert( appId: string ): Promise { const { accountId } = await getCredentials(); try { // Try to get existing CA const json = await cfGet( `/accounts/${accountId}/access/apps/${appId}/ca`, 0 ); if (json.result?.public_key) { return json.result.public_key as string; } // If not found, create one const createJson = await cfPost( `/accounts/${accountId}/access/apps/${appId}/ca`, {} ); return (createJson.result?.public_key as string) ?? null; } catch { return null; } } // ─── Full Pipeline ─────────────────────────────────────────────────────────── export async function runFullSetup(input: SetupInput): Promise { const steps: SetupStepResult[] = []; let tunnelToken: string | null = null; let caPubkey: string | null = null; let tunnelId: string | null = null; let appId: string | null = null; // Step 1: Find zone let zoneId: string | null = null; try { const zone = await findZoneForHostname(input.hostname); zoneId = zone.zoneId; steps.push({ step: "find_zone", status: "success", message: `Found zone: ${zone.zoneName} (${zone.zoneId})`, data: zone, }); } catch (e) { steps.push({ step: "find_zone", status: "error", message: e instanceof Error ? e.message : "Failed to find zone", }); return { steps, tunnelToken: null, caPubkey: null, installCommand: null, launchUrl: null }; } // Step 2: Create tunnel try { const tunnel = await createTunnel(input.serverName); tunnelId = tunnel.id; steps.push({ step: "create_tunnel", status: "success", message: `Tunnel created: ${tunnel.name} (${tunnel.id})`, data: { tunnelId: tunnel.id, tunnelName: tunnel.name }, }); } catch (e) { steps.push({ step: "create_tunnel", status: "error", message: e instanceof Error ? e.message : "Failed to create tunnel", }); return { steps, tunnelToken: null, caPubkey: null, installCommand: null, launchUrl: null }; } // Step 3: Configure tunnel ingress (SSH + metrics HTTP) const metricsDomain = `metrics.${input.hostname}`; try { const { accountId } = await getCredentials(); await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, { config: { ingress: [ { hostname: input.hostname, service: `ssh://localhost:${input.sshPort}` }, { hostname: metricsDomain, service: "http://localhost:9101" }, { service: "http_status:404" }, ], }, }); steps.push({ step: "configure_tunnel", status: "success", message: `Ingress: ${input.hostname} → SSH, ${metricsDomain} → metrics:9101`, }); } catch (e) { steps.push({ step: "configure_tunnel", status: "error", message: e instanceof Error ? e.message : "Failed to configure tunnel", }); } // Step 4: Create DNS CNAME try { await createDnsCname(zoneId, input.hostname, tunnelId); steps.push({ step: "create_dns", status: "success", message: `CNAME created: ${input.hostname} → ${tunnelId}.cfargotunnel.com`, }); } catch (e) { const msg = e instanceof Error ? e.message : ""; if (msg.includes("already exists") || msg.includes("81057")) { steps.push({ step: "create_dns", status: "skipped", message: `DNS record already exists for ${input.hostname}`, }); } else { steps.push({ step: "create_dns", status: "error", message: msg || "Failed to create DNS record", }); } } // Step 5: Create Access application let metricsHostname: string | null = null; try { const appResult = await createAccessApp( input.hostname, `SSH · ${input.serverName}`, input.allowedEmails ); appId = appResult.appId; metricsHostname = appResult.metricsHostname; steps.push({ step: "create_access_app", status: "success", message: `Access app created: SSH · ${input.serverName}`, data: { appId }, }); } catch (e) { steps.push({ step: "create_access_app", status: "error", message: e instanceof Error ? e.message : "Failed to create Access app", }); } // Step 6: Get / create short-lived certificate CA if (appId) { caPubkey = await getOrCreateCaCert(appId); steps.push({ step: "setup_ca", status: caPubkey ? "success" : "error", message: caPubkey ? "Short-lived certificate CA configured" : "Could not retrieve CA public key (enable manually in dashboard)", }); } // Step 7: Get tunnel token try { tunnelToken = await getTunnelToken(tunnelId); steps.push({ step: "get_token", status: "success", message: "Tunnel token retrieved", }); } catch (e) { steps.push({ step: "get_token", status: "error", message: e instanceof Error ? e.message : "Failed to get tunnel token", }); } // Build install command const loginUser = input.ssoEmail.split("@")[0]; const SCRIPT_URL = "https://raw.githubusercontent.com/chunzhimoe/cf-status/main/setup-cf-browser-ssh.sh"; let installCommand: string | null = null; if (tunnelToken) { const parts = [ `curl -fsSL ${SCRIPT_URL} \\`, ` | sudo bash -s -- \\`, ` --tunnel-token "${tunnelToken}" \\`, ` --sso-email "${input.ssoEmail}" \\`, ` --login-user "${loginUser}"`, ]; if (caPubkey) { parts[parts.length - 1] += ` \\`; parts.push(` --ca-pubkey "${caPubkey}"`); } installCommand = parts.join("\n"); } const launchUrl = `https://${input.hostname}`; // 浏览器渲染提醒 steps.push({ step: "browser_rendering", status: "skipped", message: "请在 Cloudflare 控制台 → Access App → 编辑 → 启用「Browser rendering → SSH」", }); return { steps, tunnelToken, caPubkey, installCommand, launchUrl }; }