cf-ssh/src/lib/cloudflare.ts

583 lines
17 KiB
TypeScript

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<string, unknown>;
}
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<AccessApp[]> {
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<SshApp | null> {
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<Tunnel[]> {
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<Tunnel> {
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<void> {
const { accountId } = await getCredentials();
await cfDelete(`/accounts/${accountId}/cfd_tunnel/${tunnelId}`);
}
export async function getTunnelToken(tunnelId: string): Promise<string> {
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<void> {
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<void> {
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<string | null> {
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<SetupResult> {
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 };
}