import { createHash, randomBytes, randomUUID, timingSafeEqual, } from "node:crypto"; import { isIP } from "node:net"; import { z } from "zod"; import type Database from "better-sqlite3"; import { ensureDb } from "@/lib/db"; import { encryptText } from "@/lib/crypto"; import type { Hy2NodeRow } from "@/lib/store"; export type RelayHost = { id: string; name: string; server_host: string; enabled: number; token_hash: string; token_encrypted: string; last_seen_at: string | null; applied_hash: string; service_active: number; error: string; created_at: string; }; export type RelayRoute = { id: string; host_id: string; node_id: number; name: string; public_port: number; listen_port: number; enabled: number; created_at: string; }; export class RelayError extends Error {} const hostname = z .string() .trim() .min(1) .max(253) .refine( (v) => Boolean(isIP(v)) || /^(?=.{1,253}$)[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(v), "请输入域名或 IP,不含协议和端口。", ); const hostSchema = z.object({ id: z.string().uuid().optional(), name: z.string().trim().min(1).max(100), server_host: hostname, enabled: z.boolean(), }); const routeSchema = z.object({ id: z.string().uuid().optional(), host_id: z.string().uuid(), node_id: z.coerce.number().int().positive(), name: z.string().trim().min(1).max(100), public_port: z.coerce.number().int().min(1).max(65535), listen_port: z.coerce.number().int().min(1).max(65535), enabled: z.boolean(), }); const digest = (value: string) => createHash("sha256").update(value).digest("hex"); export const relayReportSchema = z .object({ appliedHash: z.string().regex(/^([a-f0-9]{64})?$/), active: z.boolean(), error: z.enum(["", "port_conflict", "apply_failed", "service_stopped"]), }) .strict(); export async function relayInventory() { const db = await ensureDb(); return { hosts: db .prepare("SELECT * FROM relay_hosts ORDER BY created_at DESC") .all() as RelayHost[], routes: db .prepare("SELECT * FROM relay_routes ORDER BY created_at DESC") .all() as RelayRoute[], }; } export async function saveRelayHost(input: unknown) { const parsed = hostSchema.safeParse(input); if (!parsed.success) throw new RelayError("请填写主机名称和有效的公网域名/IP。"); const data = parsed.data, db = await ensureDb(); if (data.id) { if ( !db .prepare( "UPDATE relay_hosts SET name=?,server_host=?,enabled=? WHERE id=?", ) .run(data.name, data.server_host, +data.enabled, data.id).changes ) throw new RelayError("中转主机不存在。"); return data.id; } const id = randomUUID(), token = randomBytes(32).toString("hex"); db.prepare( "INSERT INTO relay_hosts(id,name,server_host,enabled,token_hash,token_encrypted,created_at) VALUES(?,?,?,?,?,?,?)", ).run( id, data.name, data.server_host, +data.enabled, digest(token), encryptText(token), new Date().toISOString(), ); return id; } export async function saveRelayRoute(input: unknown) { const parsed = routeSchema.safeParse(input); if (!parsed.success) throw new RelayError("请填写线路名称、主机、目标节点及 1–65535 的端口。"); const data = parsed.data, db = await ensureDb(); return db .transaction(() => { const host = db .prepare("SELECT * FROM relay_hosts WHERE id=?") .get(data.host_id) as RelayHost | undefined; const node = db .prepare("SELECT * FROM hy2_nodes WHERE id=?") .get(data.node_id) as Hy2NodeRow | undefined; if ( !host || !node || !["hy2", "anytls"].includes(node.node_type) || !node.server_host || !node.server_port ) throw new RelayError("请选择托管 HY2 或 AnyTLS 节点作为目标。"); if ( data.enabled && db .prepare( `SELECT r.id FROM relay_routes r JOIN hy2_nodes n ON n.id=r.node_id WHERE r.host_id=? AND r.enabled=1 AND n.node_type=? AND (r.listen_port=? OR r.public_port=?) AND r.id<>?`, ) .get( data.host_id, node.node_type, data.listen_port, data.public_port, data.id || "", ) ) throw new RelayError( "该主机同协议的监听端口或公网端口已被其他线路使用。", ); if ( db .prepare("SELECT id FROM relay_routes WHERE name=? AND id<>?") .get(data.name, data.id || "") || db .prepare("SELECT id FROM hy2_nodes WHERE name=? OR client_name=?") .get(data.name, data.name) ) throw new RelayError("订阅名称与已有节点或线路重复。"); if ( host.server_host.toLowerCase() === node.server_host.toLowerCase() && [data.public_port, data.listen_port].includes(node.server_port) ) throw new RelayError("中转入口不能指向自身。"); const id = data.id || randomUUID(); if (data.id) { if ( !db .prepare( "UPDATE relay_routes SET host_id=?,node_id=?,name=?,public_port=?,listen_port=?,enabled=? WHERE id=?", ) .run( data.host_id, data.node_id, data.name, data.public_port, data.listen_port, +data.enabled, id, ).changes ) throw new RelayError("线路不存在。"); } else db.prepare( "INSERT INTO relay_routes(id,host_id,node_id,name,public_port,listen_port,enabled,created_at) VALUES(?,?,?,?,?,?,?,?)", ).run( id, data.host_id, data.node_id, data.name, data.public_port, data.listen_port, +data.enabled, new Date().toISOString(), ); return id; }) .immediate(); } export async function deleteRelay(kind: "host" | "route", id: string) { const db = await ensureDb(); db.prepare( kind === "host" ? "DELETE FROM relay_hosts WHERE id=?" : "DELETE FROM relay_routes WHERE id=?", ).run(id); } const address = (host: string, port: number) => `${isIP(host) === 6 ? `[${host}]` : host}:${port}`; export function relayDesired(db: Database.Database, host: RelayHost) { const rows = db .prepare( `SELECT r.*, n.node_type,n.server_host,n.server_port FROM relay_routes r JOIN hy2_nodes n ON n.id=r.node_id WHERE r.host_id=? AND r.enabled=1 AND n.enabled=1 AND n.node_type IN ('hy2','anytls') ORDER BY r.id`, ) .all(host.id) as (RelayRoute & Pick)[]; const active = host.enabled ? rows : []; const config = { log: { level: "warn" }, endpoints: active.map((r) => ({ listen: `0.0.0.0:${r.listen_port}`, remote: address(r.server_host, r.server_port), network: { no_tcp: r.node_type === "hy2", use_udp: r.node_type === "hy2", udp_timeout: 300, }, })), }; return { config, hash: digest(JSON.stringify(config)), leaseSeconds: 90, intervalSeconds: 15, }; } export async function syncRelay(id: string, token: string, input: unknown) { const db = await ensureDb(); const host = db.prepare("SELECT * FROM relay_hosts WHERE id=?").get(id) as RelayHost | undefined; if ( !host || !/^[a-f0-9]{64}$/.test(token) || !timingSafeEqual(Buffer.from(digest(token)), Buffer.from(host.token_hash)) ) throw new RelayError("凭据无效。"); const data = relayReportSchema.parse(input); db.prepare( "UPDATE relay_hosts SET last_seen_at=?,applied_hash=?,service_active=?,error=? WHERE id=?", ).run( new Date().toISOString(), data.appliedHash, +data.active, data.error, id, ); return relayDesired(db, host); } // These are subscription-only views of the existing destination, never polling nodes. export function appendRelaySubscriptionNodes( db: Database.Database, nodes: Hy2NodeRow[], ) { const hosts = db .prepare("SELECT * FROM relay_hosts WHERE enabled=1") .all() as RelayHost[]; const routes = db .prepare("SELECT * FROM relay_routes WHERE enabled=1 ORDER BY created_at") .all() as RelayRoute[]; const byId = new Map(nodes.map((n) => [n.id, n])); const ready = new Map( hosts .filter( (h) => h.service_active && !h.error && h.last_seen_at && Date.now() - Date.parse(h.last_seen_at) < 90000 && h.applied_hash === relayDesired(db, h).hash, ) .map((h) => [h.id, h]), ); return [ ...nodes, ...routes.flatMap((r) => { const host = ready.get(r.host_id), node = byId.get(r.node_id); if (!host || !node || !["hy2", "anytls"].includes(node.node_type)) return []; return [ { ...node, slug: `relay/${r.id}`, name: r.name, client_name: r.name, server_host: host.server_host, server_port: r.public_port, sni: node.sni || node.server_host, }, ]; }), ]; }