Files
hy2-panel/lib/provisioning.ts
T

265 lines
8.6 KiB
TypeScript

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<typeof installationSchema>,
) {
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();
}