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
+26
View File
@@ -177,3 +177,29 @@ npm run test:e2e
升级时保留原 `.env`、`DATABASE_PATH`、`SESSION_SECRET` 和管理员配置。尤其不能更换 `SESSION_SECRET`,现有 HY2 用户凭据依赖它解密。发布前应停服务并通过 SQLite backup API 备份数据库(包含 WAL 中已提交的数据),不要仅复制正在写入的 `.db` 文件。随后更新代码、安装锁定依赖、构建并启动;无需导出导入业务数据,也无需手工执行 `ALTER TABLE`。 升级时保留原 `.env`、`DATABASE_PATH`、`SESSION_SECRET` 和管理员配置。尤其不能更换 `SESSION_SECRET`,现有 HY2 用户凭据依赖它解密。发布前应停服务并通过 SQLite backup API 备份数据库(包含 WAL 中已提交的数据),不要仅复制正在写入的 `.db` 文件。随后更新代码、安装锁定依赖、构建并启动;无需导出导入业务数据,也无需手工执行 `ALTER TABLE`。
`tests/fixtures/remote-v4-schema.sql` 是从服务器只读获取的实际表结构,不含生产数据。`npm test` 使用该结构和模拟业务数据验证兼容性、重复迁移、失败回滚、原始 YAML 保留、订阅生成,以及自定义节点不参与鉴权和轮询。`npm run test:e2e` 验证新版 UI 的新增、校验、编辑、分组、禁用和删除流程。 `tests/fixtures/remote-v4-schema.sql` 是从服务器只读获取的实际表结构,不含生产数据。`npm test` 使用该结构和模拟业务数据验证兼容性、重复迁移、失败回滚、原始 YAML 保留、订阅生成,以及自定义节点不参与鉴权和轮询。`npm run test:e2e` 验证新版 UI 的新增、校验、编辑、分组、禁用和删除流程。
## 证书托管与服务器自动安装
管理员进入「证书与安装」,上传 PEM 完整证书链(CRT)和未加密私钥(KEY)。证书私钥使用 `SESSION_SECRET` 派生密钥加密保存,不在页面回显;请保留原有 `SESSION_SECRET` 并备份数据库。本功能使用覆盖 `*.p.xn--876a.net` 的通配符证书;只管理上传的证书,不申请或自动续期,也不会自动更新已安装服务器的文件。
选择证书、填写节点名称与 Slug,生成并下载安装脚本。每份脚本限一个节点,有效期 24 小时,可在平台撤销。脚本不内嵌证书私钥,运行时通过 HTTPS 获取配置和证书,安装完成后凭据不能再次获取私钥。
在使用 systemd 的 Linux 服务器上以 root 运行(支持 x86_64、ARM64、ARMv7):
```bash
sudo bash install-jp1.sh
# 或显式指定参数;jp1 可替换为任意合法的单级子域名前缀:
sudo bash install-jp1.sh --domain jp1 --hy2-port 23456 --stats-port 23457
# NAT 内外端口不同:
sudo bash install-jp1.sh --domain jp1.p.xn--876a.net \
--hy2-port 23456 --stats-port 23457 \
--hy2-listen-port 443 --stats-listen-port 9999
```
无参数时交互输入域名和两个端口。短域名自动补全 `.p.xn--876a.net`;完整域名用作订阅地址和 SNI。请提前将域名解析至服务器公网 IP,在 NAT / 云防火墙设置 UDP 的 HY2 映射和 TCP 的统计 API 映射。脚本不能代替云厂商配置端口转发或 DNS。统计 API 使用随机密钥,建议将其 TCP 端口限制为仅面板 IP 可访问。
脚本安装 curl/jq/openssl 等依赖,从 Hysteria 官方下载入口下载服务端,保存 `/etc/hysteria/config.yaml`、`server.crt`、`server.key`,创建 `hy2-panel-node.service` 并设置开机启动。先以禁用状态注册,检查本机统计 API 健康后启用节点。公网 NAT 连通性请通过平台的节点同步状态确认。
重复执行仅允许同一安装记录、同一域名和端口,不覆盖其他已有 HY2 配置。失败后修复问题,在凭据有效期内重试;已启用的节点不因删除安装记录而删除。凭据过期且已有部分安装时,请先检查并清理对应的禁用节点和服务器配置,再生成新的安装记录。
平台 `APP_URL` 必须是服务器能访问的公网 HTTPS 地址(本地测试允许 localhost HTTP)。新迁移使用数据库版本 5,只新增 `managed_certificates` / `node_installations` 表,兼容现有 v4 节点、用户和订阅数据。部署前仍应备份数据库;不要用旧版本程序打开已升级的数据库。
+11 -3
View File
@@ -6,6 +6,7 @@ import {
Server, Server,
Radio, Radio,
ScrollText, ScrollText,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -17,12 +18,19 @@ type NavItem = {
export function AdminNav({ items }: { items: NavItem[] }) { export function AdminNav({ items }: { items: NavItem[] }) {
const pathname = usePathname(); const pathname = usePathname();
const icons = [LayoutDashboard, Users, Server, Radio, ScrollText]; const icons: Record<string, typeof Server> = {
"/admin/dashboard": LayoutDashboard,
"/admin/users": Users,
"/admin/nodes": Server,
"/admin/certificates": ShieldCheck,
"/admin/subscriptions": Radio,
"/admin/audits": ScrollText,
};
return ( return (
<nav className="nav-list"> <nav className="nav-list">
{items.map((item, index) => { {items.map((item) => {
const Icon = icons[index]; const Icon = icons[item.href] ?? Server;
const active = const active =
pathname === item.href || pathname.startsWith(`${item.href}/`); pathname === item.href || pathname.startsWith(`${item.href}/`);
+67
View File
@@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdminAccess } from "@/lib/session";
import {
saveCertificate,
deleteCertificate,
ProvisionError,
} from "@/lib/certificates";
import {
createInstallation,
installationSchema,
revokeInstallation,
} from "@/lib/provisioning";
async function run(action: () => Promise<unknown>) {
await requireAdminAccess();
try {
await action();
} catch (error) {
if (error instanceof ProvisionError) return { error: error.message };
throw error;
}
revalidatePath("/admin/certificates");
revalidatePath("/admin/nodes");
}
export async function uploadCertificateAction(data: FormData) {
return run(async () => {
const crt = data.get("certificate"),
key = data.get("private_key");
if (
!(crt instanceof File) ||
!(key instanceof File) ||
crt.size > 128 * 1024 ||
key.size > 32 * 1024
)
throw new ProvisionError(
"请选择 PEM 格式的 CRT(最多 128 KB)和 KEY(最多 32 KB)文件。",
);
await saveCertificate(
String(data.get("name") ?? ""),
await crt.text(),
await key.text(),
);
});
}
export async function deleteCertificateAction(data: FormData) {
return run(() => deleteCertificate(Number(data.get("id"))));
}
export async function createInstallationAction(data: FormData) {
return run(async () => {
const input = installationSchema.safeParse({
name: data.get("name"),
slug: data.get("slug"),
certificateId: data.get("certificate_id"),
});
if (!input.success)
throw new ProvisionError(
"请检查输入:Slug 使用小写字母、数字或连字符,并选择覆盖 *.p.xn--876a.net 的证书。",
);
await createInstallation(input.data);
});
}
export async function revokeInstallationAction(data: FormData) {
return run(() =>
revokeInstallation(String(data.get("id")), data.get("remove") === "yes"),
);
}
+272
View File
@@ -0,0 +1,272 @@
import Link from "next/link";
import { ActionForm } from "@/components/action-form";
import { Input } from "@/components/ui/input";
import { buttonVariants } from "@/components/ui/button";
import { SubmitButton } from "@/components/submit-button";
import { CopyButton } from "@/components/copy-button";
import { listCertificates } from "@/lib/certificates";
import { listInstallations } from "@/lib/provisioning";
import { formatLocalDateTime } from "@/lib/datetime";
import {
uploadCertificateAction,
deleteCertificateAction,
createInstallationAction,
revokeInstallationAction,
} from "./actions";
export default async function CertificatesPage() {
const [certificates, installations] = await Promise.all([
listCertificates(),
listInstallations(),
]);
const now = new Date().getTime();
return (
<main className="page-stack management-page">
<section className="hero-panel">
<div className="hero-copy">
<span className="eyebrow">部署</span>
<h2>证书与自动安装</h2>
<p className="muted">
上传证书 → 生成脚本 → 在服务器设置端口,自动安装并注册节点。
</p>
</div>
</section>
<section className="panel-card compact-create-panel">
<details open={certificates.length === 0}>
<summary className="compact-create-summary">
<h3>上传证书</h3>
</summary>
<ActionForm
action={uploadCertificateAction}
className="dense-form-grid provision-form"
>
<label className="field">
<span>证书名称</span>
<Input
name="name"
required
maxLength={100}
placeholder="example.com 通配符证书"
/>
</label>
<label className="field">
<span>CRT / 完整证书链</span>
<Input
type="file"
name="certificate"
accept=".crt,.pem,.cer"
required
/>
</label>
<label className="field">
<span>KEY / 私钥</span>
<Input
type="file"
name="private_key"
accept=".key,.pem"
required
/>
</label>
<p className="muted full-span">
使用 PEM
格式、未加密私钥。平台加密存储私钥,页面不回显。上传替换证书需创建新记录;已安装节点不会自动更新或续期。
</p>
<SubmitButton>上传证书</SubmitButton>
</ActionForm>
</details>
</section>
<section className="panel-card dense-panel">
<div className="section-title">
<h3>托管证书</h3>
<span>{certificates.length} 张</span>
</div>
<div className="provision-list">
{certificates.length === 0 ? (
<p className="muted">还没有证书,请先上传。</p>
) : (
certificates.map((cert) => {
const expired = Date.parse(cert.expires_at) <= now;
const soon = Date.parse(cert.expires_at) < now + 30 * 86400_000;
return (
<div className="provision-row" key={cert.id}>
<div>
<strong>{cert.name}</strong>
<p className="muted">
到期:{formatLocalDateTime(cert.expires_at)}
</p>
</div>
<span
className={`status-pill ${soon ? "status-pill--danger" : "status-pill--ok"}`}
>
{expired ? "已过期" : soon ? "30 天内到期" : "有效"}
</span>
<ActionForm action={deleteCertificateAction}>
<input type="hidden" name="id" value={cert.id} />
<SubmitButton
variant="destructive"
confirm={`删除证书「${cert.name}」?`}
>
删除
</SubmitButton>
</ActionForm>
</div>
);
})
)}
</div>
</section>
<section className="panel-card compact-create-panel">
<details open>
<summary className="compact-create-summary">
<h3>生成服务器安装脚本</h3>
</summary>
<ActionForm
action={createInstallationAction}
className="dense-form-grid provision-form"
>
<label className="field">
<span>节点名称</span>
<Input
name="name"
required
maxLength={100}
placeholder="香港 NAT 01"
/>
</label>
<label className="field">
<span>节点 Slug</span>
<Input
name="slug"
required
pattern="[a-z0-9][a-z0-9-]{0,62}"
placeholder="hk-nat-01"
/>
</label>
<label className="field">
<span id="certificate-selector-label">托管证书</span>
<select
name="certificate_id"
aria-labelledby="certificate-selector-label"
required
defaultValue=""
>
<option value="" disabled>
选择证书
</option>
{certificates
.filter((c) => Date.parse(c.expires_at) > now)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<p className="muted full-span">
脚本适用于使用 systemd 的 Linux(x86_64 / ARM64 / ARMv7),以 root
执行。运行时输入 jp1 等域名前缀(自动补全 .p.xn--876a.net)、HY2 UDP 和统计 API TCP
端口;支持 NAT 内外端口不同。域名用于节点地址和
SNI,请预先解析到服务器公网 IP。证书须覆盖 *.p.xn--876a.net。
</p>
<SubmitButton disabled={certificates.length === 0}>
生成安装脚本
</SubmitButton>
</ActionForm>
</details>
</section>
<section className="panel-card dense-panel">
<div className="section-title">
<h3>安装记录</h3>
<Link
href="/admin/nodes"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
查看节点同步状态
</Link>
</div>
<p className="muted">
下载后执行 sudo bash install-节点Slug.sh。脚本含有效期 24
小时的安装凭据,请妥善保管;安装成功后自动失效。也支持 --domain
jp1.p.xn--876a.net --hy2-port 端口 --stats-port
端口。重试需使用相同域名和端口。删除记录不删除已注册节点。
</p>
<div className="provision-list">
{installations.length === 0 ? (
<p className="muted">生成的脚本会出现在这里。</p>
) : (
installations.map((job) => {
const expired = Date.parse(job.expires_at) <= now;
const available =
!job.completed_at && !job.revoked_at && !expired;
const status = job.completed_at
? "已安装"
: job.revoked_at
? "已撤销"
: expired
? "已过期"
: job.node_id
? "待完成,可重试"
: "等待安装";
return (
<div className="provision-row" key={job.id}>
<div>
<strong>{job.name}</strong>
<p className="muted">
{job.slug} · {job.server_host || "域名在脚本中设置"} ·{" "}
{job.certificate_name}
</p>
<small className="muted">
凭据到期:{formatLocalDateTime(job.expires_at)}
</small>
</div>
<span
className={`status-pill ${job.completed_at ? "status-pill--ok" : "status-pill--neutral"}`}
>
{status}
</span>
<div className="provision-actions">
{available && (
<>
<a
className={buttonVariants({
variant: "outline",
size: "sm",
})}
href={`/api/admin/installations/${job.id}`}
>
下载 SH
</a>
<CopyButton
value={`sudo bash install-${job.slug}.sh`}
/>
<ActionForm action={revokeInstallationAction}>
<input type="hidden" name="id" value={job.id} />
<SubmitButton
variant="outline"
confirm="撤销后,此脚本将无法继续安装。"
>
撤销
</SubmitButton>
</ActionForm>
</>
)}
<ActionForm action={revokeInstallationAction}>
<input type="hidden" name="id" value={job.id} />
<input type="hidden" name="remove" value="yes" />
<SubmitButton
variant="destructive"
confirm="删除安装记录并使脚本失效?已注册节点会保留。"
>
删除记录
</SubmitButton>
</ActionForm>
</div>
</div>
);
})
)}
</div>
</section>
</main>
);
}
+1
View File
@@ -7,6 +7,7 @@ const navItems = [
{ href: "/admin/dashboard", label: "仪表盘" }, { href: "/admin/dashboard", label: "仪表盘" },
{ href: "/admin/users", label: "用户管理" }, { href: "/admin/users", label: "用户管理" },
{ href: "/admin/nodes", label: "节点管理" }, { href: "/admin/nodes", label: "节点管理" },
{ href: "/admin/certificates", label: "证书与安装" },
{ href: "/admin/subscriptions", label: "订阅配置" }, { href: "/admin/subscriptions", label: "订阅配置" },
{ href: "/admin/audits", label: "审计日志" }, { href: "/admin/audits", label: "审计日志" },
]; ];
+1
View File
@@ -48,6 +48,7 @@ export default async function NodesPage() {
<div className="hero-copy"> <div className="hero-copy">
<span className="eyebrow">节点</span> <span className="eyebrow">节点</span>
<h2>节点管理</h2> <h2>节点管理</h2>
<a href="/admin/certificates" className={buttonVariants({ variant: "outline", size: "sm" })}>证书托管 / 自动安装节点</a>
<div className="badge-row"> <div className="badge-row">
<span className="status-pill status-pill--ok"> <span className="status-pill status-pill--ok">
启用 {nodes.filter((node) => node.enabled).length} 启用 {nodes.filter((node) => node.enabled).length}
+27
View File
@@ -0,0 +1,27 @@
import { requireAdminAccess } from "@/lib/session";
import { installationForDownload } from "@/lib/provisioning";
import { buildInstallScript } from "@/lib/install-script";
import { ProvisionError } from "@/lib/certificates";
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
await requireAdminAccess();
try {
const job = await installationForDownload((await context.params).id);
return new Response(buildInstallScript(job), {
headers: {
"Content-Type": "text/x-shellscript; charset=utf-8",
"Content-Disposition": `attachment; filename="install-${job.slug}.sh"`,
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
if (!(error instanceof ProvisionError)) throw error;
return new Response(error.message, {
status: 410,
headers: { "Cache-Control": "no-store" },
});
}
}
+51
View File
@@ -0,0 +1,51 @@
import { ProvisionError } from "@/lib/certificates";
import { provisionNode } from "@/lib/provisioning";
export const runtime = "nodejs";
export async function POST(request: Request) {
const headers = { "Cache-Control": "no-store" };
try {
// Bound streamed input, including requests without Content-Length.
const reader = request.body?.getReader();
if (!reader)
return Response.json(
{ error: "缺少请求内容。" },
{ status: 400, headers },
);
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 4096) {
await reader.cancel();
return Response.json({ error: "请求过大。" }, { status: 413, headers });
}
chunks.push(value);
}
let payload;
try {
payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
return Response.json({ error: "无效 JSON。" }, { status: 400, headers });
}
if (!payload || !["prepare", "complete"].includes(payload.action))
return Response.json({ error: "无效操作。" }, { status: 400, headers });
const token =
request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
return Response.json(
await provisionNode(token, payload.action, payload.ports),
{ headers },
);
} catch (error) {
return Response.json(
{
error:
error instanceof ProvisionError
? error.message
: "安装请求失败,请联系平台管理员。",
},
{ status: error instanceof ProvisionError ? 400 : 500, headers },
);
}
}
+12
View File
@@ -1324,3 +1324,15 @@ details[open] > .group-summary::before {
line-height: 1.65; line-height: 1.65;
tab-size: 2; tab-size: 2;
} }
/* Compact certificate and installation lists. */
.provision-row { display:flex; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid var(--border); flex-wrap:wrap; }
.provision-row > div:first-child { flex:1; min-width:220px; overflow-wrap:anywhere; }
.provision-row p { margin:4px 0; }
.provision-actions { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
.provision-row form { max-width:100%; }
.provision-row form p { max-width:360px; }
.provision-form { grid-template-columns:repeat(3,minmax(0,1fr)); }
.provision-form button[type="submit"] { justify-self:start; }
@media (max-width:760px) { .provision-form { grid-template-columns:1fr; } }
+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'; ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443';
`, `,
ensureCustomNodeColumns, 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) { 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 访问。公网连通情况请在平台查看同步状态。'
+38
View File
@@ -0,0 +1,38 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
export function certificateFixture(domain = "*.p.xn--876a.net") {
const dir = mkdtempSync(path.join(tmpdir(), "hy2-cert-fixture-"));
try {
writeFileSync(
path.join(dir, "openssl.cnf"),
`[req]\nprompt=no\ndistinguished_name=dn\nx509_extensions=ext\n[dn]\nCN=${domain}\n[ext]\nsubjectAltName=DNS:${domain}\n`,
);
execFileSync(
"openssl",
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
path.join(dir, "key"),
"-out",
path.join(dir, "crt"),
"-days",
"60",
"-config",
path.join(dir, "openssl.cnf"),
],
{ stdio: "ignore" },
);
return {
certificate: readFileSync(path.join(dir, "crt"), "utf8"),
privateKey: readFileSync(path.join(dir, "key"), "utf8"),
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
+13 -5
View File
@@ -2,17 +2,25 @@ import { test as base, expect } from "@playwright/test";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
export const test = base.extend<{ isolatedData: void }>({ export const test = base.extend<{ isolatedData: void }>({
isolatedData: [async ({}, use) => { isolatedData: [
async ({}, use) => {
const file = process.env.HY2_E2E_DATABASE; const file = process.env.HY2_E2E_DATABASE;
if (!file || !/^\/tmp\/hy2-panel-ui-test-\d+\.db$/.test(file)) throw new Error("Refusing to reset a non-test database"); if (!file || !/^\/tmp\/hy2-panel-ui-test-\d+\.db$/.test(file))
throw new Error("Refusing to reset a non-test database");
const db = new Database(file, { fileMustExist: true }); const db = new Database(file, { fileMustExist: true });
db.pragma("foreign_keys = ON"); db.pragma("foreign_keys = ON");
db.transaction(() => { db.transaction(() => {
db.exec("DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;"); db.exec(
db.prepare("UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')").run(); "DELETE FROM node_installations; DELETE FROM managed_certificates; DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;",
);
db.prepare(
"UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')",
).run();
})(); })();
db.close(); db.close();
await use(); await use();
}, { auto: true }], },
{ auto: true },
],
}); });
export { expect }; export { expect };
+150
View File
@@ -0,0 +1,150 @@
import { test, expect } from "./e2e-fixtures";
import { certificateFixture } from "./certificate-fixture";
import { readFileSync } from "node:fs";
import { parse } from "yaml";
test("upload wildcard certificate, download script and complete NAT node registration", async ({
page,
request,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
const fixture = certificateFixture();
await page.goto("/login");
await page.getByLabel("用户名", { exact: true }).fill("ui-review");
await page.getByLabel("密码", { exact: true }).fill("ui-review-local-2026");
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/dashboard/);
await page.getByRole("link", { name: "证书与安装", exact: true }).click();
const upload = page.locator("details").filter({
has: page.getByRole("heading", { name: "上传证书", exact: true }),
});
await upload.getByLabel("证书名称").fill("测试通配符证书");
await upload.getByLabel("CRT / 完整证书链").setInputFiles({
name: "test.crt",
mimeType: "text/plain",
buffer: Buffer.from(fixture.certificate),
});
await upload.getByLabel("KEY / 私钥").setInputFiles({
name: "test.key",
mimeType: "text/plain",
buffer: Buffer.from("invalid"),
});
await upload.getByRole("button", { name: "上传证书", exact: true }).click();
await expect(upload.getByRole("alert")).toContainText("无法解析");
await upload.getByLabel("KEY / 私钥").setInputFiles({
name: "test.key",
mimeType: "text/plain",
buffer: Buffer.from(fixture.privateKey),
});
await upload.getByRole("button", { name: "上传证书", exact: true }).click();
await expect(
page.getByText("测试通配符证书", { exact: true }).first(),
).toBeVisible();
await page.reload();
const create = page.locator("details").filter({
has: page.getByRole("heading", {
name: "生成服务器安装脚本",
exact: true,
}),
});
if ((await create.getAttribute("open")) === null)
await create.locator("summary").click();
await create.getByLabel("节点名称", { exact: true }).fill("NAT 浏览器测试");
await create.getByLabel("节点 Slug", { exact: true }).fill("nat-browser");
await create
.getByLabel("托管证书", { exact: true })
.selectOption({ label: "测试通配符证书" });
await create
.getByRole("button", { name: "生成安装脚本", exact: true })
.click();
const row = page.locator(".provision-row").filter({ hasText: "nat-browser" });
await expect(row).toContainText("等待安装");
const downloadPath = await row
.getByRole("link", { name: "下载 SH" })
.getAttribute("href");
expect((await request.get(downloadPath!, { maxRedirects: 0 })).status()).toBe(
307,
);
const [download] = await Promise.all([
page.waitForEvent("download"),
row.getByRole("link", { name: "下载 SH" }).click(),
]);
const script = readFileSync((await download.path())!, "utf8");
expect(script).not.toContain("PRIVATE KEY");
expect(script).toContain("--domain");
const token = script.match(/^TOKEN='([a-f0-9]+)'$/m)![1];
const ports = {
domain: "nat.p.xn--876a.net",
hy2Port: 23456,
statsPort: 23457,
hy2ListenPort: 443,
statsListenPort: 9999,
};
expect(
(
await request.post("/api/install", { data: { action: "prepare", ports } })
).status(),
).toBe(400);
expect(
(await request.post("/api/install", { data: "x".repeat(5000) })).status(),
).toBe(413);
const prepare = await request.post("/api/install", {
headers: { Authorization: `Bearer ${token}` },
data: { action: "prepare", ports },
});
expect(prepare.status()).toBe(200);
expect(prepare.headers()["cache-control"]).toBe("no-store");
const bundle = await prepare.json();
expect(bundle.privateKey).toBe(fixture.privateKey);
const config = parse(bundle.config);
expect(config.listen).toBe(":443");
expect(config.trafficStats.listen).toBe(":9999");
expect(
(
await request.post("/api/install", {
headers: { Authorization: `Bearer ${token}` },
data: { action: "complete" },
})
).status(),
).toBe(200);
await page.reload();
await expect(row).toContainText("已安装");
await expect(row.getByRole("link", { name: "下载 SH" })).toHaveCount(0);
expect(
(
await request.post("/api/install", {
headers: { Authorization: `Bearer ${token}` },
data: { action: "prepare", ports },
})
).status(),
).toBe(400);
await page.screenshot({
path: "test-results/certificates-desktop.png",
fullPage: true,
});
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({
path: "test-results/certificates-mobile.png",
fullPage: true,
});
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth + 1,
),
).toBe(true);
await page.getByRole("link", { name: "节点管理", exact: true }).click();
const node = page
.locator(".entity-table__item")
.filter({ hasText: "nat-browser" });
await expect(node).toContainText("nat.p.xn--876a.net");
await node.locator(":scope > summary").click();
await expect(node.getByLabel("对外端口", { exact: true })).toHaveValue(
"23456",
);
await expect(node.getByLabel("SNI", { exact: true })).toHaveValue(
"nat.p.xn--876a.net",
);
await expect(node.getByLabel("启用节点", { exact: true })).toBeChecked();
expect(errors).toEqual([]);
});
+157
View File
@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { spawnSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
existsSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { certificateFixture } from "../certificate-fixture";
// Execute the generated shell in a disposable filesystem. Only systemctl and
// network effects are mocked; bash, jq, openssl, permissions and file writes run.
for (const scenario of [
"success",
"health-failure",
"occupied",
"bad-domain",
]) {
test(`installer shell: ${scenario}`, () => {
const dir = mkdtempSync(path.join(tmpdir(), "hy2-shell-test-"));
const fake = path.join(dir, "bin");
mkdirSync(fake);
mkdirSync(path.join(dir, "systemd"));
const executable = (name: string, body: string) =>
writeFileSync(path.join(fake, name), `#!/usr/bin/env bash\n${body}\n`, {
mode: 0o700,
});
const fixture = certificateFixture();
writeFileSync(
path.join(dir, "bundle.json"),
JSON.stringify({
...fixture,
config: "listen: :443\n",
statsSecret: "test-stats",
}),
);
executable("id", "echo 0");
executable("uname", "echo x86_64");
executable("sleep", "exit 0");
executable(
"systemctl",
`echo "$*" >> "$SANDBOX/systemctl.log"
if [ "$1" = is-active ]; then
[ "$3" = hy2-panel-node.service ] && [ "$SCENARIO" != health-failure ]; exit $?
fi
exit 0`,
);
executable(
"curl",
`url=""; output=""; request=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o) output=$2; shift 2;;
--data-binary) request=$2; shift 2;;
http*) url=$1; shift;;
*) shift;;
esac
done
case "$url" in
https://download.hysteria.network/*) printf '#!/usr/bin/env bash\\nexit 0\\n' > "$output";;
*/api/install)
cp "\${request#@}" "$SANDBOX/last-request.json"
if jq -e '.action == "prepare"' "\${request#@}" >/dev/null; then
cp "\${request#@}" "$SANDBOX/prepare.json"
cp "$SANDBOX/bundle.json" "$output"
else echo '{"ok":true}' > "$output"; touch "$SANDBOX/completed"; fi
printf 200;;
*/traffic) [ "$SCENARIO" != health-failure ];;
*) exit 1;;
esac`,
);
const template = readFileSync("lib/templates/install-node.sh", "utf8")
.replaceAll("__PANEL__", "'https://panel.example'")
.replaceAll("__TOKEN__", "'test-token'")
.replaceAll("__INSTALL_ID__", "'test-job'")
.replaceAll("__SLUG__", "'test-node'")
.replaceAll("/run/systemd/system", path.join(dir, "systemd"))
.replaceAll("/etc/hysteria", path.join(dir, "hysteria"))
.replaceAll("/etc/systemd/system", path.join(dir, "systemd"))
.replaceAll("/usr/local/lib/hy2-panel", path.join(dir, "lib"));
const script = path.join(dir, "install.sh");
writeFileSync(script, template);
if (scenario === "occupied") {
mkdirSync(path.join(dir, "hysteria"));
writeFileSync(path.join(dir, "hysteria/config.yaml"), "original-config");
}
try {
const result = spawnSync(
"bash",
[
script,
"--domain",
scenario === "bad-domain" ? "wrong.example" : "test",
"--hy2-port",
"23456",
"--stats-port",
"23457",
"--hy2-listen-port",
"443",
"--stats-listen-port",
"9999",
],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${fake}:${process.env.PATH}`,
SANDBOX: dir,
SCENARIO: scenario,
},
timeout: 20000,
},
);
assert.equal(
result.status,
scenario === "success" ? 0 : 1,
result.stderr + result.stdout,
);
assert.equal(
existsSync(path.join(dir, "completed")),
scenario === "success",
);
if (scenario === "success") {
assert.equal(
readFileSync(path.join(dir, "hysteria/server.key"), "utf8").trim(),
fixture.privateKey.trim(),
);
assert.equal(
JSON.parse(readFileSync(path.join(dir, "prepare.json"), "utf8")).ports
.domain,
"test.p.xn--876a.net",
);
assert.match(
readFileSync(path.join(dir, "systemctl.log"), "utf8"),
/enable hy2-panel-node/,
);
}
if (scenario === "health-failure")
assert.match(
readFileSync(path.join(dir, "systemctl.log"), "utf8"),
/stop hy2-panel-node.service/,
);
if (scenario === "occupied")
assert.equal(
readFileSync(path.join(dir, "hysteria/config.yaml"), "utf8"),
"original-config",
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
}
+10 -5
View File
@@ -22,7 +22,12 @@ function snapshot(db: Database.Database) {
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
) )
.all() as { name: string }[] .all() as { name: string }[]
).map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]), )
.filter(
({ name }) =>
!["managed_certificates", "node_installations"].includes(name),
)
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
); );
} }
function seed(db: Database.Database) { function seed(db: Database.Database) {
@@ -40,7 +45,7 @@ test("fresh database exactly matches the inspected remote v4 column definitions"
const fresh = new Database(":memory:"); const fresh = new Database(":memory:");
const remote = remoteDb(); const remote = remoteDb();
migrateDatabase(fresh); migrateDatabase(fresh);
assert.equal(fresh.pragma("user_version", { simple: true }), 4); assert.equal(fresh.pragma("user_version", { simple: true }), 5);
for (const { name } of remote for (const { name } of remote
.prepare("SELECT name FROM sqlite_master WHERE type='table'") .prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[]) { .all() as { name: string }[]) {
@@ -93,7 +98,7 @@ for (const existing of [
if (!existing.includes(field)) delete row[field]; if (!existing.includes(field)) delete row[field];
} }
assert.deepEqual(after, before); assert.deepEqual(after, before);
assert.equal(db.pragma("user_version", { simple: true }), 4); assert.equal(db.pragma("user_version", { simple: true }), 5);
db.close(); db.close();
}); });
} }
@@ -123,8 +128,8 @@ test("failed migrations roll back all statements and future schemas are not down
db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(), db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(),
[{ name: "hy2_users" }], [{ name: "hy2_users" }],
); );
db.pragma("user_version = 5"); db.pragma("user_version = 6");
assert.throws(() => migrateDatabase(db), /版本/); assert.throws(() => migrateDatabase(db), /版本/);
assert.equal(db.pragma("user_version", { simple: true }), 5); assert.equal(db.pragma("user_version", { simple: true }), 6);
db.close(); db.close();
}); });
+194
View File
@@ -0,0 +1,194 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { parse } from "yaml";
import { certificateFixture } from "../certificate-fixture";
test("certificate provisioning preserves credentials, binds domain/ports, supports retry and consumes capability", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "hy2-provision-test-"));
process.env.DATABASE_PATH = path.join(dir, "test.db");
process.env.APP_URL = "https://panel.example.com";
process.env.POLLER_ENABLED = "false";
const { ensureDb } = await import("../../lib/db");
const { decryptText } = await import("../../lib/crypto");
const {
saveCertificate,
validateCertificate,
deleteCertificate,
listCertificates,
} = await import("../../lib/certificates");
const {
createInstallation,
installationForDownload,
provisionNode,
revokeInstallation,
} = await import("../../lib/provisioning");
const { buildInstallScript } = await import("../../lib/install-script");
const db = await ensureDb();
const fixture = certificateFixture();
try {
assert.throws(
() =>
validateCertificate(
fixture.certificate,
certificateFixture().privateKey,
),
/匹配/,
);
assert.throws(
() =>
validateCertificate(
fixture.certificate,
fixture.privateKey,
"a.b.p.xn--876a.net",
),
/SNI/,
);
assert.throws(
() => validateCertificate("invalid", fixture.privateKey),
/解析/,
);
const certId = Number(
await saveCertificate(
"Wildcard",
fixture.certificate,
fixture.privateKey,
),
);
assert.equal(
JSON.stringify(await listCertificates()).includes("PRIVATE KEY"),
false,
);
const stored = db
.prepare("SELECT private_key_encrypted FROM managed_certificates")
.get() as { private_key_encrypted: string };
assert.notEqual(stored.private_key_encrypted, fixture.privateKey);
assert.equal(decryptText(stored.private_key_encrypted), fixture.privateKey);
const id = await createInstallation({
name: "New server",
slug: "new-server",
certificateId: certId,
});
await assert.rejects(
() =>
createInstallation({
name: "Duplicate",
slug: "new-server",
certificateId: certId,
}),
/存在/,
);
await assert.rejects(() => deleteCertificate(certId), /关联/);
const job = await installationForDownload(id);
const script = buildInstallScript(job);
assert.ok(script.includes("--domain"));
assert.ok(!script.includes("PRIVATE KEY"));
assert.ok(!script.includes("__TOKEN__"));
const token = decryptText(job.token_encrypted)!;
assert.equal(job.token_hash.includes(token), false);
const ports = {
domain: "hk.p.xn--876a.net",
hy2Port: 23456,
statsPort: 23457,
hy2ListenPort: 443,
statsListenPort: 9999,
};
await assert.rejects(
() => provisionNode("f".repeat(64), "prepare", ports),
/无效/,
);
await assert.rejects(() => provisionNode(token, "complete"), /先获取/);
await assert.rejects(
() =>
provisionNode(token, "prepare", { ...ports, domain: "evil.example" }),
/域名/,
);
await assert.rejects(
() => provisionNode(token, "prepare", { ...ports, hy2Port: 65536 }),
/端口/,
);
const bundle = await provisionNode(token, "prepare", ports);
assert.ok("config" in bundle);
assert.equal(bundle.privateKey, fixture.privateKey);
const config = parse(bundle.config);
assert.equal(config.listen, ":443");
assert.equal(config.trafficStats.listen, ":9999");
assert.match(
config.auth.http.url,
/^https:\/\/panel.example.com\/api\/hy2\/nodes\/new-server\/auth\?token=/,
);
assert.deepEqual(await provisionNode(token, "prepare", ports), bundle);
await assert.rejects(
() => provisionNode(token, "prepare", { ...ports, statsPort: 12345 }),
/绑定/,
);
const node = db
.prepare("SELECT * FROM hy2_nodes WHERE slug='new-server'")
.get() as Record<string, unknown>;
assert.equal(node.server_port, 23456);
assert.equal(node.traffic_stats_url, "http://hk.p.xn--876a.net:23457");
assert.equal(node.sni, ports.domain);
assert.equal(node.enabled, 0);
await provisionNode(token, "complete");
await provisionNode(token, "complete");
assert.equal(
(
db.prepare("SELECT enabled FROM hy2_nodes WHERE id=?").get(node.id) as {
enabled: number;
}
).enabled,
1,
);
await assert.rejects(() => provisionNode(token, "prepare", ports), /完成/);
await assert.rejects(() => installationForDownload(id), /完成/);
assert.equal(
(db.prepare("SELECT COUNT(*) AS n FROM hy2_nodes").get() as { n: number })
.n,
1,
);
const retryId = await createInstallation({
name: "Retry",
slug: "retry",
certificateId: certId,
});
const retryToken = decryptText(
(await installationForDownload(retryId)).token_encrypted,
)!;
await revokeInstallation(retryId);
await assert.rejects(
() => provisionNode(retryToken, "prepare", ports),
/无效/,
);
const expiredId = await createInstallation({
name: "Expired",
slug: "expired",
certificateId: certId,
});
const expiredToken = decryptText(
(await installationForDownload(expiredId)).token_encrypted,
)!;
db.prepare(
"UPDATE node_installations SET expires_at='2000-01-01' WHERE id=?",
).run(expiredId);
await assert.rejects(
() => provisionNode(expiredToken, "prepare", ports),
/过期/,
);
await revokeInstallation(id, true);
assert.equal(
(db.prepare("SELECT COUNT(*) AS n FROM hy2_nodes").get() as { n: number })
.n,
1,
);
await revokeInstallation(retryId, true);
await revokeInstallation(expiredId, true);
await deleteCertificate(certId);
assert.equal((await listCertificates()).length, 0);
assert.deepEqual(db.pragma("foreign_key_check"), []);
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
}
});