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
+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 });
}
}
+20 -12
View File
@@ -2,17 +2,25 @@ import { test as base, expect } from "@playwright/test";
import Database from "better-sqlite3";
export const test = base.extend<{ isolatedData: void }>({
isolatedData: [async ({}, use) => {
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");
const db = new Database(file, { fileMustExist: true });
db.pragma("foreign_keys = ON");
db.transaction(() => {
db.exec("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();
await use();
}, { auto: true }],
isolatedData: [
async ({}, use) => {
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");
const db = new Database(file, { fileMustExist: true });
db.pragma("foreign_keys = ON");
db.transaction(() => {
db.exec(
"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();
await use();
},
{ auto: true },
],
});
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",
)
.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) {
@@ -40,7 +45,7 @@ test("fresh database exactly matches the inspected remote v4 column definitions"
const fresh = new Database(":memory:");
const remote = remoteDb();
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
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[]) {
@@ -93,7 +98,7 @@ for (const existing of [
if (!existing.includes(field)) delete row[field];
}
assert.deepEqual(after, before);
assert.equal(db.pragma("user_version", { simple: true }), 4);
assert.equal(db.pragma("user_version", { simple: true }), 5);
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(),
[{ name: "hy2_users" }],
);
db.pragma("user_version = 5");
db.pragma("user_version = 6");
assert.throws(() => migrateDatabase(db), /版本/);
assert.equal(db.pragma("user_version", { simple: true }), 5);
assert.equal(db.pragma("user_version", { simple: true }), 6);
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 });
}
});