feat: 支持托管中转主机和节点接入线路

This commit is contained in:
2026-09-30 17:36:17 +08:00
parent ac5fed695c
commit b6aa2bf30d
15 changed files with 1338 additions and 5 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ export const test = base.extend<{ isolatedData: void }>({
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;",
"DELETE FROM relay_hosts; 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')",
+118
View File
@@ -0,0 +1,118 @@
import { test, expect } from "./e2e-fixtures";
import Database from "better-sqlite3";
import { createHash } from "node:crypto";
test("managed relay UI, authenticated script, deployment state and subscription membership", async ({
page,
request,
}) => {
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 node = page
.locator("details")
.filter({
has: page.getByRole("heading", { name: "新增 HY2 节点", exact: true }),
});
await node.locator("summary").click();
await node.getByLabel("Slug", { exact: true }).fill("jp2");
await node.getByLabel("显示名", { exact: true }).fill("JP2 direct");
await node
.getByLabel("客户端地址", { exact: true })
.fill("jp2.p.xn--876a.net");
await node.getByRole("button", { name: "创建节点" }).click();
await expect(
page.getByText("JP2 direct", { exact: true }).first(),
).toBeVisible();
await page.getByRole("link", { name: "中转线路", exact: true }).click();
const host = page
.locator("details")
.filter({ has: page.getByRole("heading", { name: "新增中转主机" }) });
await host.getByLabel("主机名称").fill("HK2");
await host.getByLabel("公网域名 / IP").fill("hk2.p.xn--876a.net");
await host.getByRole("button", { name: "创建主机" }).click();
await expect(page.getByText("等待安装", { exact: true })).toBeVisible();
const row = page
.locator("details")
.filter({ has: page.locator("summary strong", { hasText: /^HK2$/ }) });
await row.locator("summary").click();
const href = await row
.getByRole("link", { name: "下载安装 SH" })
.getAttribute("href");
expect((await request.get(href!, { maxRedirects: 0 })).status()).toBe(307);
const script = await page.request.get(href!);
expect(script.status()).toBe(200);
expect(await script.text()).toContain("realm");
const create = page
.locator("details")
.filter({ has: page.getByRole("heading", { name: "新增中转线路" }) });
await create.getByLabel("线路名称").fill("JP2 via HK2");
await create
.getByLabel("中转主机", { exact: true })
.selectOption({ label: "HK2" });
await create
.getByLabel("目标节点")
.selectOption({ label: "JP2 direct · UDP" });
await create.getByLabel("公网端口").fill("51044");
await create.getByLabel("本机监听端口").fill("51044");
await create.getByRole("button", { name: "创建线路" }).click();
await expect(
page.locator("summary strong", { hasText: "JP2 via HK2" }),
).toBeVisible();
const db = new Database(process.env.HY2_E2E_DATABASE!);
const token = "a".repeat(64);
const id = (db.prepare("SELECT id FROM relay_hosts").get() as { id: string })
.id;
db.prepare("UPDATE relay_hosts SET token_hash=? WHERE id=?").run(
createHash("sha256").update(token).digest("hex"),
id,
);
db.close();
expect(
(
await request.post(`/api/relay/${id}/sync`, {
data: { appliedHash: "", active: false, error: "" },
})
).status(),
).toBe(403);
expect(
(
await request.post(`/api/relay/${id}/sync`, { data: "x".repeat(17000) })
).status(),
).toBe(413);
const sync = await request.post(`/api/relay/${id}/sync`, {
headers: { Authorization: `Bearer ${token}` },
data: { appliedHash: "", active: false, error: "" },
});
expect(sync.status()).toBe(200);
const desired = await sync.json();
expect(desired.config.endpoints[0].network.use_udp).toBe(true);
await request.post(`/api/relay/${id}/sync`, {
headers: { Authorization: `Bearer ${token}` },
data: { appliedHash: desired.hash, active: true, error: "" },
});
await page.reload();
await expect(
page.getByText("配置已应用", { exact: true }).first(),
).toBeVisible();
await page.screenshot({
path: "test-results/relays-desktop.png",
fullPage: true,
});
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({
path: "test-results/relays-mobile.png",
fullPage: true,
});
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth + 1,
),
).toBe(true);
await page.getByRole("link", { name: "订阅配置", exact: true }).click();
await expect(
page.getByRole("button", { name: "拖拽排序 JP2 via HK2" }),
).toBeVisible();
});
+31 -3
View File
@@ -29,6 +29,8 @@ function snapshot(db: Database.Database) {
"managed_certificates",
"node_installations",
"node_agent_counters",
"relay_hosts",
"relay_routes",
].includes(name),
)
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
@@ -49,7 +51,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 }), 6);
assert.equal(fresh.pragma("user_version", { simple: true }), 7);
for (const { name } of remote
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[]) {
@@ -102,7 +104,7 @@ for (const existing of [
if (!existing.includes(field)) delete row[field];
}
assert.deepEqual(after, before);
assert.equal(db.pragma("user_version", { simple: true }), 6);
assert.equal(db.pragma("user_version", { simple: true }), 7);
db.close();
});
}
@@ -132,8 +134,34 @@ 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 = 7");
db.pragma("user_version = 8");
assert.throws(() => migrateDatabase(db), /版本/);
assert.equal(db.pragma("user_version", { simple: true }), 8);
db.close();
});
test("v6 upgrade preserves AnyTLS installations, certificates and counters", () => {
const db = remoteDb();
seed(db);
migrateDatabase(db);
db.exec(
"DROP TABLE relay_routes; DROP TABLE relay_hosts; PRAGMA user_version=6;",
);
db.exec(`INSERT INTO managed_certificates VALUES(1,'wildcard','certificate','encrypted-key','2030-01-01','2026-01-01');
INSERT INTO node_installations(id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at,node_id,protocol) VALUES('install','hash','encrypted',1,'anytls','AnyTLS','jp2.example','jp2.example','https://panel.example','2030-01-01','2026-01-01',17,'anytls');
INSERT INTO node_agent_counters VALUES(17,'boot:1','original-auth',128,256);`);
const tables = [
"managed_certificates",
"node_installations",
"node_agent_counters",
];
const before = tables.map((t) => db.prepare(`SELECT * FROM ${t}`).all());
migrateDatabase(db);
migrateDatabase(db);
assert.deepEqual(
tables.map((t) => db.prepare(`SELECT * FROM ${t}`).all()),
before,
);
assert.equal(db.pragma("user_version", { simple: true }), 7);
db.close();
});
+218
View File
@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, rmSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
import { parse } from "yaml";
test("relay routes inherit credentials and SNI, isolate polling, reject conflicts and withdraw stale/disabled routes", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "hy2-relay-"));
process.env.DATABASE_PATH = path.join(dir, "db.sqlite");
const { ensureDb } = await import("../../lib/db");
const {
createNode,
getNodeBySlug,
listSubscriptionNodes,
listEnabledNodesForPolling,
listUsers,
getSubscriptionSettings,
} = await import("../../lib/store");
const {
saveRelayHost,
saveRelayRoute,
relayInventory,
syncRelay,
relayDesired,
deleteRelay,
} = await import("../../lib/relays");
const { decryptText } = await import("../../lib/crypto");
const { buildSubscriptionConfig } = await import("../../lib/subscription");
const db = await ensureDb();
try {
for (const protocol of ["hy2", "anytls"] as const) {
await createNode({
nodeType: protocol,
slug: protocol,
name: protocol,
authToken: null,
hy2Listen: ":51046",
serverHost: "jp2.p.xn--876a.net",
serverPort: protocol === "hy2" ? 51044 : 51046,
clientName: protocol,
bandwidthUpMbps: 30,
bandwidthDownMbps: 30,
skipCertVerify: false,
sni: null,
trafficStatsUrl: "http://127.0.0.1:19090",
trafficStatsListen: ":19090",
trafficStatsSecret: "test",
enabled: true,
pollIntervalSeconds: 15,
});
}
const any = (await getNodeBySlug("anytls"))!,
hy2 = (await getNodeBySlug("hy2"))!;
const hostId = await saveRelayHost({
name: "HK2",
server_host: "hk2.p.xn--876a.net",
enabled: true,
});
const host = (await relayInventory()).hosts[0],
token = decryptText(host.token_encrypted)!;
const base = {
host_id: hostId,
node_id: any.id,
name: "JP2 via HK2 TCP",
public_port: 52046,
listen_port: 51046,
enabled: true,
};
const tcpId = await saveRelayRoute(base);
await saveRelayRoute({
...base,
node_id: hy2.id,
name: "JP2 via HK2 UDP",
public_port: 52046,
listen_port: 51046,
});
assert.equal(
(await listSubscriptionNodes()).length,
2,
"not advertised before application",
);
await assert.rejects(
() => saveRelayRoute({ ...base, name: "conflict" }),
/端口/,
);
await assert.rejects(
() =>
saveRelayRoute({
...base,
public_port: 52047,
listen_port: 51047,
name: "anytls",
}),
/重复/,
);
await assert.rejects(
() =>
syncRelay(hostId, "0".repeat(64), {
appliedHash: "",
active: false,
error: "",
}),
/凭据/,
);
const desired = await syncRelay(hostId, token, {
appliedHash: "",
active: false,
error: "",
});
assert.equal(desired.config.endpoints.length, 2);
assert.equal(
desired.config.endpoints.find((e) => e.remote.endsWith(":51044"))?.network
.no_tcp,
true,
);
assert.equal(
desired.config.endpoints.find((e) => e.remote.endsWith(":51046"))?.network
.use_udp,
false,
);
assert.ok(
!JSON.stringify(desired).includes(any.auth_token),
"relay receives no backend credentials",
);
await syncRelay(hostId, token, {
appliedHash: desired.hash,
active: true,
error: "",
});
const nodes = await listSubscriptionNodes();
assert.equal(nodes.length, 4);
assert.equal(
(await listEnabledNodesForPolling()).length,
1,
"only original HY2 is polled",
);
assert.equal(
(db.prepare("SELECT count(*) n FROM hy2_nodes").get() as { n: number }).n,
2,
);
const result = parse(
buildSubscriptionConfig({
user: (await listUsers())[0],
nodes,
settings: await getSubscriptionSettings(),
}),
);
const direct = result.proxies.find(
(p: { name: string }) => p.name === "anytls",
),
relay = result.proxies.find(
(p: { name: string }) => p.name === base.name,
);
assert.equal(relay.password, direct.password);
assert.equal(relay.sni, "jp2.p.xn--876a.net");
assert.equal(relay.server, "hk2.p.xn--876a.net");
assert.equal(relay.port, 52046);
assert.equal(relay.udp, true);
db.prepare("UPDATE relay_hosts SET last_seen_at=? WHERE id=?").run(
"2000-01-01T00:00:00.000Z",
hostId,
);
assert.equal((await listSubscriptionNodes()).length, 2);
await syncRelay(hostId, token, {
appliedHash: desired.hash,
active: true,
error: "port_conflict",
});
assert.equal((await listSubscriptionNodes()).length, 2);
await syncRelay(hostId, token, {
appliedHash: desired.hash,
active: true,
error: "",
});
await saveRelayRoute({ ...base, id: tcpId, enabled: false });
assert.equal(
(await listSubscriptionNodes()).length,
2,
"hide old generation until applied",
);
const next = relayDesired(db, (await relayInventory()).hosts[0]);
assert.equal(next.config.endpoints.length, 1);
await syncRelay(hostId, token, {
appliedHash: next.hash,
active: true,
error: "",
});
assert.equal((await listSubscriptionNodes()).length, 3);
await saveRelayHost({
id: hostId,
name: "HK2",
server_host: "hk2.p.xn--876a.net",
enabled: false,
});
assert.equal(
(
await syncRelay(hostId, token, {
appliedHash: next.hash,
active: true,
error: "",
})
).config.endpoints.length,
0,
);
assert.equal((await listSubscriptionNodes()).length, 2);
await deleteRelay("host", hostId);
assert.equal((await relayInventory()).routes.length, 0);
await assert.rejects(
() =>
syncRelay(hostId, token, { appliedHash: "", active: false, error: "" }),
/凭据/,
);
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
}
});