feat: 支持自定义订阅节点并兼容现有数据库
This commit is contained in:
@@ -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],
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user