583 lines
17 KiB
TypeScript
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 };
|
|
}
|