feat: 支持自定义订阅节点并兼容现有数据库

This commit is contained in:
2026-09-29 11:52:41 +08:00
parent 3faf960f35
commit ab7d1c937a
27 changed files with 2203 additions and 449 deletions
+121
View File
@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parse, stringify } from "yaml";
import {
parseCustomProxyYaml,
renderCustomProxyYaml,
extractProxyNameFromYaml,
} from "../../lib/custom-proxy";
import { buildSubscriptionConfig } from "../../lib/subscription";
import { encryptText } from "../../lib/crypto";
import type { Hy2NodeRow, Hy2UserRow } from "../../lib/store";
const proxy = {
name: 'HK "one": #香港',
type: "anytls",
server: "hk.example.com",
port: 443,
password: 'test:p"ass#word',
udp: true,
alpn: ["h2", "http/1.1"],
};
for (const [name, source] of Object.entries({
object: stringify(proxy),
sequence: stringify([proxy]),
wrapper: stringify({ proxies: [proxy] }),
flow: JSON.stringify(proxy),
})) {
test(`accepts ${name} and preserves protocol fields and quoted names on output`, () => {
assert.deepEqual(parseCustomProxyYaml(source), proxy);
assert.equal(extractProxyNameFromYaml(source), proxy.name);
assert.deepEqual(parse(renderCustomProxyYaml(source)), [proxy]);
});
}
for (const value of [
"",
"[]",
"[a, b]",
"- name: [bad",
"name: X\nname: Y\ntype: anytls",
"name: 123\ntype: anytls",
"name: X",
"name: X\ntype: anytls\npassword: *missing",
"proxies:\n - name: X\n type: anytls\n - name: Y\n type: anytls",
]) {
test(`rejects malformed or multiple proxies: ${JSON.stringify(value).slice(0, 40)}`, () =>
assert.throws(() => parseCustomProxyYaml(value)));
}
test("mixed subscriptions preserve order, names, group references, credentials and disabled filtering", () => {
const custom = {
id: 2,
slug: "custom",
node_type: "custom",
name: "Display name",
client_name: "Old fallback",
custom_config_yaml: stringify({ proxies: [proxy] }),
enabled: 1,
} as Hy2NodeRow;
const hy2 = {
id: 1,
slug: "hy2",
node_type: "hy2",
name: "Tokyo",
client_name: 'Tokyo "HY2"',
server_host: "jp.example.com",
server_port: 443,
enabled: 1,
custom_config_yaml: "",
bandwidth_up_mbps: 30,
bandwidth_down_mbps: 30,
} as Hy2NodeRow;
const user = {
username: "member",
auth_secret_encrypted: encryptText('test:abc"def'),
} as Hy2UserRow;
const settings = {
template: "mode: rule",
profileName: 'Profile: "1"',
extraProxyGroups: "",
mainProxyGroupMemberSlugs: ["custom", "hy2"],
proxyGroups: [
{
id: "work",
name: 'Work: "1"',
type: "select" as const,
memberSlugs: ["custom", "disabled", "hy2"],
},
],
};
const result = parse(
buildSubscriptionConfig({
user,
nodes: [hy2, custom, { ...custom, slug: "disabled", enabled: 0 }],
settings,
}),
);
assert.deepEqual(result.proxies[0], proxy);
assert.equal(result.proxies[1].password, 'member:test:abc"def');
assert.equal(result.proxies[1].name, hy2.client_name);
assert.deepEqual(
result["proxy-groups"].map((group: { proxies: string[] }) => group.proxies),
[
[proxy.name, hy2.client_name],
[proxy.name, hy2.client_name],
],
);
assert.equal(result["proxy-groups"][0].name, settings.profileName);
assert.equal(result["proxy-groups"][1].name, settings.proxyGroups[0].name);
assert.equal(result.proxies.length, 2);
// Custom-only subscriptions do not require HY2 credentials.
assert.deepEqual(
parse(
buildSubscriptionConfig({
user: { ...user, auth_secret_encrypted: null },
nodes: [custom],
settings,
}),
).proxies,
[proxy],
);
});
+130
View File
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import Database from "better-sqlite3";
import { migrateDatabase } from "../../lib/db-migrations";
const remoteSchema = readFileSync(
"tests/fixtures/remote-v4-schema.sql",
"utf8",
);
function remoteDb() {
const db = new Database(":memory:");
db.pragma("foreign_keys = ON");
db.exec(remoteSchema);
return db;
}
function snapshot(db: Database.Database) {
return Object.fromEntries(
(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
)
.all() as { name: string }[]
).map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
);
}
function seed(db: Database.Database) {
db.exec(`INSERT INTO hy2_users (id,auth_id,username,password_hash,notes,created_at,updated_at,subscription_token,auth_secret_encrypted,traffic_limit_bytes,used_tx_bytes,used_rx_bytes)
VALUES (42,'original-auth','member','test-hash','keep notes','2026-01-01','2026-01-01','test-sub-token','test-encrypted-value',4096,128,256);
INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,server_host)
VALUES (17,'existing-hy2','Existing HY2','test-auth-token','http://stats.example:9999','test-stats-secret','2026-01-01','2026-01-01','existing.example');
INSERT INTO traffic_ledgers (node_id,user_id,auth_id,tx_bytes,rx_bytes,window_started_at,window_ended_at,created_at) VALUES (17,42,'original-auth',128,256,'2026-01-01','2026-01-01','2026-01-01');
INSERT INTO user_sessions (id,user_id,session_hash,expires_at,created_at,last_seen_at) VALUES ('session',42,'test-session-hash','2030-01-01','2026-01-01','2026-01-01');
INSERT INTO app_settings VALUES ('main_proxy_group_member_slugs_json','["legacy-custom","existing-hy2"]','2026-01-01');
INSERT INTO app_settings VALUES ('proxy_groups_json','[{"id":"group-id","name":"Work","type":"select","memberSlugs":["legacy-custom"]}]','2026-01-01');`);
}
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);
for (const { name } of remote
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[]) {
assert.deepEqual(
fresh.pragma(`table_info(${name})`),
remote.pragma(`table_info(${name})`),
);
}
fresh.close();
remote.close();
});
test("remote v4: repeated migration preserves every row, ID, secret, quota, session and group", () => {
const db = remoteDb();
seed(db);
const source = `# legacy source stays byte-for-byte\n- name: "AnyTLS #1"\n type: anytls\n password: "test: password"\n server: hk.example.com\n port: 443\n`;
db.prepare(
`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,node_type,custom_config_yaml,client_name) VALUES (18,'legacy-custom','Legacy','test-token','','test-secret','2026-01-01','2026-01-01','custom',?,'Old fallback')`,
).run(source);
const before = snapshot(db);
migrateDatabase(db);
migrateDatabase(db);
assert.deepEqual(snapshot(db), before);
assert.deepEqual(db.pragma("foreign_key_check"), []);
db.close();
});
for (const existing of [
[],
["node_type"],
["custom_config_yaml"],
["node_type", "custom_config_yaml"],
]) {
test(`v3 upgrade with existing columns [${existing.join(", ")}] preserves data`, () => {
const db = remoteDb();
seed(db);
for (const field of ["node_type", "custom_config_yaml"])
if (!existing.includes(field))
db.exec(`ALTER TABLE hy2_nodes DROP COLUMN ${field}`);
db.pragma("user_version = 3");
const before = snapshot(db);
migrateDatabase(db);
migrateDatabase(db);
const after = snapshot(db);
// New fields have compatible defaults; every pre-existing field is unchanged.
for (const row of after.hy2_nodes as Record<string, unknown>[]) {
assert.equal(row.node_type, "hy2");
assert.equal(row.custom_config_yaml, "");
for (const field of ["node_type", "custom_config_yaml"])
if (!existing.includes(field)) delete row[field];
}
assert.deepEqual(after, before);
assert.equal(db.pragma("user_version", { simple: true }), 4);
db.close();
});
}
test("repairs interrupted v4 without duplicate-column errors", () => {
const db = remoteDb();
seed(db);
db.exec("ALTER TABLE hy2_nodes DROP COLUMN custom_config_yaml");
migrateDatabase(db);
assert.equal(
(
db.prepare("SELECT custom_config_yaml FROM hy2_nodes").get() as {
custom_config_yaml: string;
}
).custom_config_yaml,
"",
);
db.close();
});
test("failed migrations roll back all statements and future schemas are not downgraded", () => {
const db = new Database(":memory:");
db.exec("CREATE TABLE hy2_users(id INTEGER)");
assert.throws(() => migrateDatabase(db));
assert.equal(db.pragma("user_version", { simple: true }), 0);
assert.deepEqual(
db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(),
[{ name: "hy2_users" }],
);
db.pragma("user_version = 5");
assert.throws(() => migrateDatabase(db), /版本/);
assert.equal(db.pragma("user_version", { simple: true }), 5);
db.close();
});
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import Database from "better-sqlite3";
// Start with the real deployed schema rather than the new migration output.
test("existing v4 boots and custom nodes stay outside HY2 polling/auth while retaining credentials", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "hy2-custom-store-"));
process.env.DATABASE_PATH = path.join(dir, "remote-v4.db");
process.env.ADMIN_USERNAME = "test-admin";
process.env.ADMIN_PASSWORD = "test-admin-local-only";
process.env.POLLER_ENABLED = "false";
const legacy = new Database(process.env.DATABASE_PATH);
legacy.exec(readFileSync("tests/fixtures/remote-v4-schema.sql", "utf8"));
legacy.exec(`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,server_host)
VALUES (91,'existing-hy2','Existing HY2','test-auth','http://stats.example:9999','test-stats','2026-01-01','2026-01-01','hy2.example');`);
const yaml =
'# original comment\n- name: "Remote Custom"\n type: anytls\n password: "test-pass"\n server: custom.example\n port: 443\n';
legacy
.prepare(
`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,node_type,custom_config_yaml,client_name)
VALUES (92,'existing-custom','Display','test-original-auth','','test-original-stats','2026-01-01','2026-01-01','custom',?,'Old alias')`,
)
.run(yaml);
legacy.exec(
`INSERT INTO app_settings VALUES ('main_proxy_group_member_slugs_json','["existing-custom","existing-hy2"]','2026-01-01')`,
);
const before = legacy.prepare("SELECT * FROM hy2_nodes ORDER BY id").all();
legacy.close();
const { ensureDb } = await import("../../lib/db");
const store = await import("../../lib/store");
const { verifyNodeToken } = await import("../../lib/hy2");
const db = await ensureDb();
try {
assert.deepEqual(
db.prepare("SELECT * FROM hy2_nodes ORDER BY id").all(),
before,
);
assert.deepEqual(
(await store.getSubscriptionSettings()).mainProxyGroupMemberSlugs,
["existing-custom", "existing-hy2"],
);
assert.deepEqual(
(await store.listEnabledNodesForPolling()).map((node) => node.id),
[91],
);
assert.equal((await store.listSubscriptionNodes()).length, 2);
assert.equal(
(await verifyNodeToken("existing-custom", "test-original-auth")).reason,
"node_not_hy2",
);
assert.equal((await verifyNodeToken("existing-hy2", "test-auth")).ok, true);
const custom = (await store.getNodeBySlug("existing-custom"))!;
assert.equal(store.getNodeProxyName(custom), "Remote Custom");
const input = {
nodeType: "custom" as const,
slug: "existing-custom",
name: "Display",
authToken: "",
hy2Listen: "",
serverHost: "",
serverPort: 443,
clientName: "",
bandwidthUpMbps: 30,
bandwidthDownMbps: 30,
skipCertVerify: false,
sni: null,
customConfigYaml: yaml.replace("Remote Custom", "Updated Custom"),
trafficStatsUrl: "",
trafficStatsListen: ":9999",
trafficStatsSecret: "",
enabled: true,
pollIntervalSeconds: 15,
};
await store.updateNode({ ...input, id: custom.id });
const saved = (await store.getNodeBySlug("existing-custom"))!;
assert.equal(saved.id, 92);
assert.equal(saved.custom_config_yaml, input.customConfigYaml);
assert.equal(saved.auth_token, "test-original-auth");
assert.equal(saved.traffic_stats_secret, "test-original-stats");
assert.equal(store.getNodeProxyName(saved), "Updated Custom");
await assert.rejects(() =>
store.updateNode({
...input,
id: 92,
customConfigYaml: "name: [invalid",
}),
);
assert.deepEqual(await store.getNodeBySlug("existing-custom"), saved);
await store.updateNode({ ...input, id: 92, enabled: false });
assert.deepEqual(
(await store.listSubscriptionNodes()).map((node) => node.id),
[91],
);
} finally {
db.close();
global.__hy2PanelDb = undefined;
global.__hy2PanelDbInit = undefined;
rmSync(dir, { recursive: true, force: true });
}
});