import { randomBytes, randomUUID, createHash } from "node:crypto"; import { z } from "zod"; import { stringify } from "yaml"; import { ensureDb } from "@/lib/db"; import { decryptText, encryptText } from "@/lib/crypto"; import { getEnv } from "@/lib/env"; import { ProvisionError, validateCertificate, type ManagedCertificate, } from "@/lib/certificates"; import type { Hy2NodeRow } from "@/lib/store"; export const portSchema = z .object({ domain: z .string() .regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.p\.xn--876a\.net$/), hy2Port: z.number().int().min(1).max(65535), statsPort: z.number().int().min(1).max(65535), hy2ListenPort: z.number().int().min(1).max(65535), statsListenPort: z.number().int().min(1).max(65535), }) .strict(); export const installationSchema = z.object({ slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/), name: z.string().trim().min(1).max(100), certificateId: z.coerce.number().int().positive(), }); export type Installation = { id: string; token_hash: string; token_encrypted: string; certificate_id: number; slug: string; name: string; server_host: string; sni: string; origin: string; expires_at: string; created_at: string; revoked_at: string | null; completed_at: string | null; node_id: number | null; ports_json: string | null; }; const hash = (token: string) => createHash("sha256").update(token).digest("hex"); export async function createInstallation( input: z.infer, ) { const data = installationSchema.safeParse(input); if (!data.success) throw new ProvisionError("请检查节点名称、Slug 和托管证书。"); input = data.data; const origin = new URL(getEnv().APP_URL); if ( origin.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname) ) throw new ProvisionError("请将 APP_URL 设置为平台的公网 HTTPS 地址。"); const db = await ensureDb(); const cert = db .prepare("SELECT * FROM managed_certificates WHERE id=?") .get(input.certificateId) as ManagedCertificate | undefined; if (!cert) throw new ProvisionError("请选择托管证书。"); const certificate = validateCertificate( cert.certificate_pem, decryptText(cert.private_key_encrypted) || "", "install-check.p.xn--876a.net", ); if ( !/(?:^|, )DNS:\*\.p\.xn--876a\.net(?:,|$)/.test( certificate.subjectAltName || "", ) ) throw new ProvisionError("请选择包含 *.p.xn--876a.net 的通配符证书。"); const id = randomUUID(), token = randomBytes(32).toString("hex"), now = new Date().toISOString(); db.transaction(() => { if ( db.prepare("SELECT 1 FROM hy2_nodes WHERE slug=?").get(input.slug) || db .prepare("SELECT 1 FROM node_installations WHERE slug=?") .get(input.slug) ) throw new ProvisionError( "此 Slug 已存在,请使用新 Slug,或删除旧安装记录。", ); db.prepare( `INSERT INTO node_installations (id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, ).run( id, hash(token), encryptText(token), input.certificateId, input.slug, input.name, "", "", origin.origin, new Date(Date.now() + 24 * 3600_000).toISOString(), now, ); }).immediate(); return id; } export async function listInstallations() { const db = await ensureDb(); return db .prepare( `SELECT id,slug,name,server_host,sni,expires_at,created_at,revoked_at,completed_at,node_id, (SELECT name FROM managed_certificates WHERE id=certificate_id) AS certificate_name FROM node_installations ORDER BY created_at DESC`, ) .all() as (Omit< Installation, | "token_hash" | "token_encrypted" | "certificate_id" | "origin" | "ports_json" > & { certificate_name: string })[]; } export async function installationForDownload(id: string) { const db = await ensureDb(); const row = db .prepare("SELECT * FROM node_installations WHERE id=?") .get(id) as Installation | undefined; if ( !row || row.revoked_at || row.completed_at || row.expires_at <= new Date().toISOString() ) throw new ProvisionError( "安装凭据已过期、撤销或使用完成。请创建新安装记录。", ); return row; } export async function revokeInstallation(id: string, remove = false) { const db = await ensureDb(); db.prepare( remove ? "DELETE FROM node_installations WHERE id=?" : "UPDATE node_installations SET revoked_at=datetime('now') WHERE id=?", ).run(id); } export async function provisionNode( token: string, action: "prepare" | "complete", rawPorts?: unknown, ) { if (!/^[a-f0-9]{64}$/.test(token)) throw new ProvisionError("安装凭据无效。"); const db = await ensureDb(); return db .transaction(() => { const job = db .prepare("SELECT * FROM node_installations WHERE token_hash=?") .get(hash(token)) as Installation | undefined; if (!job || job.revoked_at || job.expires_at <= new Date().toISOString()) throw new ProvisionError("安装凭据无效或已过期。"); if (action === "complete") { if (!job.node_id) throw new ProvisionError("请先获取安装配置。"); if (!job.completed_at) { db.prepare( "UPDATE hy2_nodes SET enabled=1,updated_at=? WHERE id=?", ).run(new Date().toISOString(), job.node_id); db.prepare( "UPDATE node_installations SET completed_at=? WHERE id=?", ).run(new Date().toISOString(), job.id); } return { ok: true, slug: job.slug }; } if (job.completed_at) throw new ProvisionError("该凭据已完成安装,不能再次获取私钥。"); const parsed = portSchema.safeParse(rawPorts); if (!parsed.success) throw new ProvisionError( "域名须为 .p.xn--876a.net 下的单级子域名,端口必须为 1–65535 的整数。", ); const ports = parsed.data, portsJson = JSON.stringify(ports); if (job.ports_json && job.ports_json !== portsJson) throw new ProvisionError( "该安装记录已绑定域名和端口;重试请使用原配置,或创建新安装记录。", ); const cert = db .prepare("SELECT * FROM managed_certificates WHERE id=?") .get(job.certificate_id) as ManagedCertificate; const key = decryptText(cert.private_key_encrypted) || ""; validateCertificate(cert.certificate_pem, key, ports.domain); const now = new Date().toISOString(); let nodeId = job.node_id; if (!nodeId) { if (db.prepare("SELECT 1 FROM hy2_nodes WHERE slug=?").get(job.slug)) throw new ProvisionError("节点 Slug 已被占用。"); const host = ports.domain; nodeId = Number( db .prepare( `INSERT INTO hy2_nodes (slug,name,auth_token,traffic_stats_url,traffic_stats_secret,enabled,created_at,updated_at, server_host,server_port,client_name,sni,hy2_listen,traffic_stats_listen) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?)`, ) .run( job.slug, job.name, randomBytes(32).toString("hex"), `http://${host}:${ports.statsPort}`, randomBytes(32).toString("hex"), now, now, host, ports.hy2Port, job.name, host, `:${ports.hy2ListenPort}`, `:${ports.statsListenPort}`, ).lastInsertRowid, ); db.prepare( "UPDATE node_installations SET node_id=?,ports_json=?,server_host=?,sni=? WHERE id=?", ).run(nodeId, portsJson, host, host, job.id); } const node = db .prepare("SELECT * FROM hy2_nodes WHERE id=?") .get(nodeId) as Hy2NodeRow; const config = stringify({ listen: node.hy2_listen, tls: { cert: "/etc/hysteria/server.crt", key: "/etc/hysteria/server.key", }, auth: { type: "http", http: { url: `${job.origin}/api/hy2/nodes/${job.slug}/auth?token=${node.auth_token}`, insecure: false, }, }, trafficStats: { listen: node.traffic_stats_listen, secret: node.traffic_stats_secret, }, }); return { config, certificate: cert.certificate_pem, privateKey: key, statsSecret: node.traffic_stats_secret, }; }) .immediate(); }