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
+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 });
}
});