feat: 支持证书托管和节点自动安装

This commit is contained in:
2026-09-29 13:06:56 +08:00
parent ab7d1c937a
commit 586c2399d1
20 changed files with 1600 additions and 20 deletions
+85
View File
@@ -0,0 +1,85 @@
import { X509Certificate, createPrivateKey } from "node:crypto";
import { ensureDb } from "@/lib/db";
import { encryptText } from "@/lib/crypto";
export class ProvisionError extends Error {}
export type ManagedCertificate = {
id: number;
name: string;
certificate_pem: string;
private_key_encrypted: string;
expires_at: string;
created_at: string;
};
export function validateCertificate(pem: string, key: string, sni?: string) {
if (pem.length > 128 * 1024 || key.length > 32 * 1024)
throw new ProvisionError("证书或私钥文件过大。");
let certificate: X509Certificate;
try {
certificate = new X509Certificate(pem);
const privateKey = createPrivateKey(key);
if (!certificate.checkPrivateKey(privateKey)) throw new Error();
const chain = pem.match(
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
);
if (!chain?.length) throw new Error();
chain.forEach((part) => new X509Certificate(part));
} catch {
throw new ProvisionError(
"无法解析 PEM 证书/私钥,或私钥与证书不匹配。请上传未加密的私钥和完整证书链。",
);
}
if (
Date.parse(certificate.validFrom) > Date.now() ||
Date.parse(certificate.validTo) <= Date.now()
)
throw new ProvisionError("证书尚未生效或已过期。");
if (sni && !certificate.checkHost(sni, { subject: "never" }))
throw new ProvisionError("SNI 域名与证书的 SAN 不匹配。");
return certificate;
}
export async function saveCertificate(name: string, pem: string, key: string) {
if (!name.trim() || name.length > 100)
throw new ProvisionError("请填写 1–100 字的证书名称。");
const certificate = validateCertificate(pem, key);
const db = await ensureDb();
return db
.prepare(
`INSERT INTO managed_certificates
(name,certificate_pem,private_key_encrypted,expires_at,created_at) VALUES (?,?,?,?,?)`,
)
.run(
name.trim(),
pem,
encryptText(key),
new Date(certificate.validTo).toISOString(),
new Date().toISOString(),
).lastInsertRowid;
}
export async function listCertificates() {
const db = await ensureDb();
return db
.prepare(
"SELECT id,name,expires_at,created_at FROM managed_certificates ORDER BY id DESC",
)
.all() as Pick<
ManagedCertificate,
"id" | "name" | "expires_at" | "created_at"
>[];
}
export async function deleteCertificate(id: number) {
const db = await ensureDb();
if (
db
.prepare("SELECT 1 FROM node_installations WHERE certificate_id=?")
.get(id)
)
throw new ProvisionError(
"此证书仍关联安装记录,请先删除相关安装记录。已安装服务器的证书文件不会随之删除。",
);
db.prepare("DELETE FROM managed_certificates WHERE id=?").run(id);
}
+27
View File
@@ -172,6 +172,33 @@ const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [
ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443';
`,
ensureCustomNodeColumns,
`
CREATE TABLE managed_certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
certificate_pem TEXT NOT NULL,
private_key_encrypted TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE node_installations (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
token_encrypted TEXT NOT NULL,
certificate_id INTEGER NOT NULL REFERENCES managed_certificates(id) ON DELETE RESTRICT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
server_host TEXT NOT NULL,
sni TEXT NOT NULL,
origin TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
revoked_at TEXT,
completed_at TEXT,
ports_json TEXT,
node_id INTEGER REFERENCES hy2_nodes(id) ON DELETE CASCADE
);
`,
];
export function migrateDatabase(db: Database.Database) {
+21
View File
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { decryptText } from "@/lib/crypto";
import type { Installation } from "@/lib/provisioning";
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
export function buildInstallScript(job: Installation) {
const token = decryptText(job.token_encrypted);
if (!token) throw new Error("installation_token_unavailable");
const values: Record<string, string> = {
PANEL: job.origin,
TOKEN: token,
INSTALL_ID: job.id,
SLUG: job.slug,
};
return readFileSync(
path.join(process.cwd(), "lib/templates/install-node.sh"),
"utf8",
).replace(/__(PANEL|TOKEN|INSTALL_ID|SLUG)__/g, (_, key) =>
quote(values[key]),
);
}
+264
View File
@@ -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();
}
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -Eeuo pipefail
umask 077
PANEL=__PANEL__
TOKEN=__TOKEN__
INSTALL_ID=__INSTALL_ID__
SLUG=__SLUG__
DOMAIN=""
HY2_PORT=""
STATS_PORT=""
HY2_LISTEN=""
STATS_LISTEN=""
while [ "$#" -gt 0 ]; do
[ "$#" -ge 2 ] || { echo "参数缺少值:$1"; exit 1; }
case "$1" in
--domain) DOMAIN=$2;;
--hy2-port) HY2_PORT=$2;;
--stats-port) STATS_PORT=$2;;
--hy2-listen-port) HY2_LISTEN=$2;;
--stats-listen-port) STATS_LISTEN=$2;;
*) echo "未知参数:$1"; exit 1;;
esac
shift 2
done
[ "$(id -u)" -eq 0 ] || { echo '请使用 root 执行。'; exit 1; }
command -v systemctl >/dev/null || { echo '需要使用 systemd 的 Linux 服务器。'; exit 1; }
[ -d /run/systemd/system ] || { echo 'systemd 未运行。'; exit 1; }
case "$(uname -m)" in
x86_64) ARCH=amd64;; aarch64|arm64) ARCH=arm64;;
armv7l) ARCH=arm;; *) echo '暂不支持此 CPU 架构。'; exit 1;;
esac
if [ -e /etc/hysteria/config.yaml ] || [ -e /etc/systemd/system/hy2-panel-node.service ]; then
[ "$(cat /etc/hysteria/.panel-install-id 2>/dev/null || true)" = "$INSTALL_ID" ] || {
echo '检测到已有 HY2 配置;为避免覆盖,安装已停止。请在新服务器运行。'; exit 1;
}
fi
for unit in hysteria-server.service hysteria-server@config.service; do
if systemctl is-active --quiet "$unit"; then echo '已有 Hysteria 服务正在运行,安装已停止。'; exit 1; fi
done
read_port() {
local value
while true; do
read -r -p "$1" value </dev/tty
if [[ "$value" =~ ^[0-9]{1,5}$ ]] && (( 10#$value >= 1 && 10#$value <= 65535 )); then
printf '%s' "$((10#$value))"; return
fi
echo '端口必须为 1–65535。' >&2
done
}
echo "节点:$SLUG"
if [ -z "$DOMAIN" ]; then read -r -p '节点域名(输入 jp1 或 jp1.p.xn--876a.net):' DOMAIN </dev/tty; fi
DOMAIN=$(printf '%s' "$DOMAIN" | tr '[:upper:]' '[:lower:]')
if [[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then DOMAIN="$DOMAIN.p.xn--876a.net"; fi
[[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.p\.xn--876a\.net$ ]] || { echo '请填写 .p.xn--876a.net 下的单级子域名,例如 jp1.p.xn--876a.net。'; exit 1; }
[ -n "$HY2_PORT" ] || HY2_PORT=$(read_port 'HY2 公网 UDP 端口:')
[ -n "$STATS_PORT" ] || STATS_PORT=$(read_port '流量统计公网 TCP 端口:')
if [ -z "$HY2_LISTEN" ] && [ -z "$STATS_LISTEN" ] && [ -t 0 ]; then
read -r -p 'NAT 内外端口是否不同?[y/N] ' MAPPING </dev/tty
if [[ "$MAPPING" =~ ^[yY]$ ]]; then
HY2_LISTEN=$(read_port 'HY2 本机 UDP 监听端口:')
STATS_LISTEN=$(read_port '流量统计本机 TCP 监听端口:')
fi
fi
HY2_LISTEN=${HY2_LISTEN:-$HY2_PORT}
STATS_LISTEN=${STATS_LISTEN:-$STATS_PORT}
for port in "$HY2_PORT" "$STATS_PORT" "$HY2_LISTEN" "$STATS_LISTEN"; do
if ! [[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] || (( port > 65535 )); then echo '端口必须为 1–65535(无前导零)。'; exit 1; fi
done
if ! command -v curl >/dev/null || ! command -v jq >/dev/null || ! command -v openssl >/dev/null; then
if command -v apt-get >/dev/null; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y curl jq openssl ca-certificates
elif command -v dnf >/dev/null; then dnf install -y curl jq openssl ca-certificates
elif command -v yum >/dev/null; then yum install -y curl jq openssl ca-certificates
else echo '请先安装 curl、jq、openssl 和 ca-certificates。'; exit 1; fi
fi
WORK=$(mktemp -d)
STARTED=0
cleanup() {
local status=$?
rm -rf "$WORK"
if [ "$status" -ne 0 ]; then
if [ "$STARTED" -eq 1 ]; then systemctl stop hy2-panel-node.service || true; fi
echo '安装未完成。修复网络/端口问题后,可在凭据有效期内使用相同脚本和端口重试。' >&2
fi
}
trap cleanup EXIT
printf 'Authorization: Bearer %s\n' "$TOKEN" > "$WORK/headers"
# 使用 Hysteria 官方自动化下载入口,仅允许 HTTPS。
curl --proto '=https' --proto-redir '=https' -fSL --retry 3 --connect-timeout 20 --max-time 300 \
"https://download.hysteria.network/app/latest/hysteria-linux-$ARCH" -o "$WORK/hysteria"
chmod 700 "$WORK/hysteria"
"$WORK/hysteria" version
jq -n --arg domain "$DOMAIN" --argjson h "$HY2_PORT" --argjson s "$STATS_PORT" --argjson hl "$HY2_LISTEN" --argjson sl "$STATS_LISTEN" \
'{action:"prepare",ports:{domain:$domain,hy2Port:$h,statsPort:$s,hy2ListenPort:$hl,statsListenPort:$sl}}' > "$WORK/request"
api() {
local status
status=$(curl -sS --connect-timeout 20 --max-time 60 -o "$WORK/response" -w '%{http_code}' \
-H @"$WORK/headers" -H 'Content-Type: application/json' --data-binary @"$WORK/request" "$PANEL/api/install")
if [ "$status" != 200 ]; then
jq -r '.error // "平台请求失败"' "$WORK/response" >&2 || true
return 1
fi
}
api
jq -er '.config' "$WORK/response" > "$WORK/config.yaml"
jq -er '.certificate' "$WORK/response" > "$WORK/server.crt"
jq -er '.privateKey' "$WORK/response" > "$WORK/server.key"
jq -er '"Authorization: " + .statsSecret' "$WORK/response" > "$WORK/stats-header"
openssl x509 -in "$WORK/server.crt" -noout -checkend 0 >/dev/null
openssl x509 -in "$WORK/server.crt" -pubkey -noout > "$WORK/cert.pub"
openssl pkey -in "$WORK/server.key" -pubout > "$WORK/key.pub"
cmp -s "$WORK/cert.pub" "$WORK/key.pub" || { echo '证书与私钥不匹配。'; exit 1; }
install -d -m 700 /etc/hysteria /usr/local/lib/hy2-panel
# 仅允许重试本安装记录生成的配置。
printf '%s' "$INSTALL_ID" > /etc/hysteria/.panel-install-id
systemctl stop hy2-panel-node.service 2>/dev/null || true
install -m 755 "$WORK/hysteria" /usr/local/lib/hy2-panel/hysteria
install -m 600 "$WORK/config.yaml" /etc/hysteria/config.yaml
install -m 600 "$WORK/server.crt" /etc/hysteria/server.crt
install -m 600 "$WORK/server.key" /etc/hysteria/server.key
cat > /etc/systemd/system/hy2-panel-node.service <<'UNIT'
[Unit]
Description=Hysteria 2 (HY2 Panel)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/lib/hy2-panel/hysteria server --config /etc/hysteria/config.yaml
Restart=on-failure
RestartSec=5
LimitNOFILE=1048576
NoNewPrivileges=true
ProtectHome=true
ProtectSystem=strict
PrivateTmp=true
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl start hy2-panel-node.service
STARTED=1
READY=0
for attempt in {1..15}; do
if systemctl is-active --quiet hy2-panel-node.service && \
curl -fsS --max-time 2 -H @"$WORK/stats-header" "http://127.0.0.1:$STATS_LISTEN/traffic" -o /dev/null; then
READY=1; break
fi
sleep 1
done
[ "$READY" -eq 1 ] || { echo '服务健康检查失败,请检查端口占用和 systemctl status hy2-panel-node。'; exit 1; }
# 完成通知可能已被平台处理但响应丢失;保留已健康启动的服务,避免误停。
systemctl enable hy2-panel-node.service
STARTED=0
printf '{"action":"complete"}' > "$WORK/request"
# 重试完成通知,平台支持幂等确认。
for attempt in {1..3}; do
if api; then break; fi
[ "$attempt" -lt 3 ] || exit 1
sleep 2
done
STARTED=0
echo "安装完成,节点 $SLUG 已注册并启用。"
echo "请在 NAT / 云防火墙中映射 UDP $HY2_PORT → $HY2_LISTEN、TCP $STATS_PORT → $STATS_LISTEN。"
echo '流量统计 TCP 端口建议仅允许面板服务器 IP 访问。公网连通情况请在平台查看同步状态。'