feat: 支持证书托管和节点自动安装
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user