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
+178 -43
View File
@@ -1,9 +1,17 @@
import {
parseCustomProxyYaml,
extractProxyNameFromYaml,
} from "@/lib/custom-proxy";
import type Database from "better-sqlite3";
import { encryptText } from "@/lib/crypto";
import { ensureDb, getDb } from "@/lib/db";
import { getEnv } from "@/lib/env";
import { createOpaqueToken, createStableId, hashPassword } from "@/lib/password";
import {
createOpaqueToken,
createStableId,
hashPassword,
} from "@/lib/password";
export type Hy2UserRow = {
id: number;
@@ -24,8 +32,11 @@ export type Hy2UserRow = {
updated_at: string;
};
export type NodeType = "hy2" | "custom";
export type Hy2NodeRow = {
id: number;
node_type: NodeType;
slug: string;
name: string;
auth_token: string;
@@ -40,6 +51,7 @@ export type Hy2NodeRow = {
bandwidth_down_mbps: number;
skip_cert_verify: number;
sni: string | null;
custom_config_yaml: string;
enabled: number;
poll_interval_seconds: number;
last_polled_at: string | null;
@@ -114,7 +126,11 @@ export function toDatetimeLocalValue(value: string | null) {
return value.slice(0, 16);
}
export function buildNodeAuthUrl(slug: string, token: string, origin = getEnv().APP_URL) {
export function buildNodeAuthUrl(
slug: string,
token: string,
origin = getEnv().APP_URL,
) {
return `${origin}/api/hy2/nodes/${encodeURIComponent(slug)}/auth?token=${encodeURIComponent(token)}`;
}
@@ -124,6 +140,43 @@ export function buildTrafficStatsUrl(serverHost: string, listen: string) {
return `http://${serverHost}:${port}`;
}
export function normalizeNodeType(value: string | null | undefined): NodeType {
return value === "custom" ? "custom" : "hy2";
}
export function getNodeProxyName(node: {
node_type: NodeType;
custom_config_yaml: string;
client_name: string;
name: string;
}) {
if (node.node_type === "custom") {
return (
extractProxyNameFromYaml(node.custom_config_yaml) ||
node.client_name ||
node.name
);
}
return node.client_name || node.name;
}
function normalizePort(value: number) {
if (!Number.isInteger(value) || value < 1 || value > 65535) {
return 443;
}
return value;
}
function normalizePositiveInteger(value: number, fallback: number) {
if (!Number.isInteger(value) || value < 1) {
return fallback;
}
return value;
}
export function getUserLifecycleState(user: {
enabled: number;
expires_at: string | null;
@@ -153,9 +206,7 @@ export function getUserLifecycleState(user: {
export async function getAdminByUsername(username: string) {
const db = await ensureDb();
return db
.prepare("SELECT * FROM admins WHERE username = ?")
.get(username) as
return db.prepare("SELECT * FROM admins WHERE username = ?").get(username) as
| {
id: number;
username: string;
@@ -190,16 +241,14 @@ export async function getUserByUsername(username: string) {
export async function getUserById(id: number) {
const db = await ensureDb();
return db
.prepare("SELECT * FROM hy2_users WHERE id = ?")
.get(id) as Hy2UserRow | undefined;
return db.prepare("SELECT * FROM hy2_users WHERE id = ?").get(id) as
Hy2UserRow | undefined;
}
export async function getUserByAuthId(authId: string) {
const db = await ensureDb();
return db
.prepare("SELECT * FROM hy2_users WHERE auth_id = ?")
.get(authId) as Hy2UserRow | undefined;
return db.prepare("SELECT * FROM hy2_users WHERE auth_id = ?").get(authId) as
Hy2UserRow | undefined;
}
export async function getUserBySubscriptionToken(token: string) {
@@ -334,12 +383,12 @@ export async function listNodes() {
export async function getNodeBySlug(slug: string) {
const db = await ensureDb();
return db
.prepare("SELECT * FROM hy2_nodes WHERE slug = ?")
.get(slug) as Hy2NodeRow | undefined;
return db.prepare("SELECT * FROM hy2_nodes WHERE slug = ?").get(slug) as
Hy2NodeRow | undefined;
}
export async function createNode(input: {
nodeType?: NodeType;
slug: string;
name: string;
authToken: string | null;
@@ -351,6 +400,7 @@ export async function createNode(input: {
bandwidthDownMbps: number;
skipCertVerify: boolean;
sni: string | null;
customConfigYaml?: string;
trafficStatsUrl: string;
trafficStatsListen: string;
trafficStatsSecret: string;
@@ -359,46 +409,67 @@ export async function createNode(input: {
}) {
const db = await ensureDb();
const now = dbNow();
const nodeType = normalizeNodeType(input.nodeType);
const serverPort = normalizePort(input.serverPort);
const trafficStatsListen = input.trafficStatsListen || ":9999";
const customConfigYaml =
nodeType === "custom" ? (input.customConfigYaml ?? "") : "";
if (nodeType === "custom") parseCustomProxyYaml(customConfigYaml);
db.prepare(
`
INSERT INTO hy2_nodes (
slug, name, auth_token, hy2_listen, server_host, server_port, client_name,
node_type, slug, name, auth_token, hy2_listen, server_host, server_port, client_name,
bandwidth_up_mbps, bandwidth_down_mbps, skip_cert_verify, sni,
traffic_stats_url, traffic_stats_listen, traffic_stats_secret, enabled,
custom_config_yaml, traffic_stats_url, traffic_stats_listen, traffic_stats_secret, enabled,
poll_interval_seconds, created_at, updated_at
)
VALUES (
@slug, @name, @authToken, @hy2Listen, @serverHost, @serverPort, @clientName,
@nodeType, @slug, @name, @authToken, @hy2Listen, @serverHost, @serverPort, @clientName,
@bandwidthUpMbps, @bandwidthDownMbps, @skipCertVerify, @sni,
@trafficStatsUrl, @trafficStatsListen, @trafficStatsSecret, @enabled,
@customConfigYaml, @trafficStatsUrl, @trafficStatsListen, @trafficStatsSecret, @enabled,
@pollIntervalSeconds, @now, @now
)
`,
).run({
nodeType,
slug: input.slug,
name: input.name,
authToken: input.authToken || createOpaqueToken(),
hy2Listen: input.hy2Listen || `:${input.serverPort}`,
hy2Listen: input.hy2Listen || `:${serverPort}`,
serverHost: input.serverHost,
serverPort: input.serverPort,
clientName: input.clientName,
bandwidthUpMbps: input.bandwidthUpMbps,
bandwidthDownMbps: input.bandwidthDownMbps,
serverPort,
clientName:
input.clientName ||
(nodeType === "custom"
? extractProxyNameFromYaml(customConfigYaml)
: null) ||
input.name,
bandwidthUpMbps: normalizePositiveInteger(input.bandwidthUpMbps, 30),
bandwidthDownMbps: normalizePositiveInteger(input.bandwidthDownMbps, 30),
skipCertVerify: input.skipCertVerify ? 1 : 0,
sni: input.sni,
customConfigYaml,
trafficStatsUrl:
input.trafficStatsUrl || buildTrafficStatsUrl(input.serverHost, input.trafficStatsListen),
trafficStatsListen: input.trafficStatsListen,
nodeType === "custom"
? ""
: input.trafficStatsUrl ||
buildTrafficStatsUrl(input.serverHost, trafficStatsListen),
trafficStatsListen,
trafficStatsSecret: input.trafficStatsSecret || createOpaqueToken(),
enabled: input.enabled ? 1 : 0,
pollIntervalSeconds: input.pollIntervalSeconds,
pollIntervalSeconds: normalizePositiveInteger(
input.pollIntervalSeconds,
15,
),
now,
});
}
export async function updateNode(input: {
id: number;
nodeType?: NodeType;
slug: string;
name: string;
authToken: string;
@@ -410,6 +481,7 @@ export async function updateNode(input: {
bandwidthDownMbps: number;
skipCertVerify: boolean;
sni: string | null;
customConfigYaml?: string;
trafficStatsUrl: string;
trafficStatsListen: string;
trafficStatsSecret: string;
@@ -417,13 +489,25 @@ export async function updateNode(input: {
pollIntervalSeconds: number;
}) {
const db = await ensureDb();
const nodeType = normalizeNodeType(input.nodeType);
const serverPort = normalizePort(input.serverPort);
const trafficStatsListen = input.trafficStatsListen || ":9999";
const customConfigYaml =
nodeType === "custom" ? (input.customConfigYaml ?? "") : "";
if (nodeType === "custom") parseCustomProxyYaml(customConfigYaml);
db.prepare(
`
UPDATE hy2_nodes
SET
node_type = @nodeType,
slug = @slug,
name = @name,
auth_token = @authToken,
auth_token = CASE
WHEN @authToken = '' THEN auth_token
ELSE @authToken
END,
hy2_listen = @hy2Listen,
server_host = @serverHost,
server_port = @serverPort,
@@ -432,6 +516,7 @@ export async function updateNode(input: {
bandwidth_down_mbps = @bandwidthDownMbps,
skip_cert_verify = @skipCertVerify,
sni = @sni,
custom_config_yaml = @customConfigYaml,
traffic_stats_url = @trafficStatsUrl,
traffic_stats_listen = @trafficStatsListen,
traffic_stats_secret = CASE
@@ -445,9 +530,28 @@ export async function updateNode(input: {
`,
).run({
...input,
hy2Listen: input.hy2Listen || `:${input.serverPort}`,
nodeType,
hy2Listen: input.hy2Listen || `:${serverPort}`,
serverPort,
clientName:
input.clientName ||
(nodeType === "custom"
? extractProxyNameFromYaml(customConfigYaml)
: null) ||
input.name,
bandwidthUpMbps: normalizePositiveInteger(input.bandwidthUpMbps, 30),
bandwidthDownMbps: normalizePositiveInteger(input.bandwidthDownMbps, 30),
customConfigYaml,
trafficStatsUrl:
input.trafficStatsUrl || buildTrafficStatsUrl(input.serverHost, input.trafficStatsListen),
nodeType === "custom"
? ""
: input.trafficStatsUrl ||
buildTrafficStatsUrl(input.serverHost, trafficStatsListen),
trafficStatsListen,
pollIntervalSeconds: normalizePositiveInteger(
input.pollIntervalSeconds,
15,
),
enabled: input.enabled ? 1 : 0,
skipCertVerify: input.skipCertVerify ? 1 : 0,
updatedAt: dbNow(),
@@ -569,7 +673,11 @@ function getSeriesMapKey(bucketStart: string, nodeId: number) {
return `${bucketStart}:${nodeId}`;
}
function getTrafficSeriesMapKey(bucketStart: string, nodeId: number, authId: string) {
function getTrafficSeriesMapKey(
bucketStart: string,
nodeId: number,
authId: string,
) {
return `${bucketStart}|${nodeId}|${authId}`;
}
@@ -626,6 +734,7 @@ export async function getDashboardMetrics() {
`
SELECT
id,
node_type,
name,
slug,
enabled,
@@ -640,6 +749,7 @@ export async function getDashboardMetrics() {
)
.all() as Array<{
id: number;
node_type: NodeType;
name: string;
slug: string;
enabled: number;
@@ -801,7 +911,10 @@ export async function getDashboardMetrics() {
}>;
const loadBucketSet = new Set(hourlyBuckets);
const loadMap = new Map<string, { online_users: number; stream_count: number }>();
const loadMap = new Map<
string,
{ online_users: number; stream_count: number }
>();
for (const row of loadRows) {
const bucketStart = getUtcBucketKey(row.finished_at, "hour");
@@ -864,7 +977,9 @@ export async function getDashboardMetrics() {
failureMap.set(key, (failureMap.get(key) ?? 0) + 1);
}
const failureNodeIds = trackedNodeIds.includes(0) ? trackedNodeIds : [0, ...trackedNodeIds];
const failureNodeIds = trackedNodeIds.includes(0)
? trackedNodeIds
: [0, ...trackedNodeIds];
const failureTrend: DashboardFailureTrendRow[] = [];
for (const bucketStart of dailyBuckets) {
@@ -1051,12 +1166,16 @@ export async function touchUserSession(sessionId: string) {
export async function deleteUserSessionByHash(sessionHash: string) {
const db = await ensureDb();
db.prepare("DELETE FROM user_sessions WHERE session_hash = ?").run(sessionHash);
db.prepare("DELETE FROM user_sessions WHERE session_hash = ?").run(
sessionHash,
);
}
export async function deleteAdminSessionByHash(sessionHash: string) {
const db = await ensureDb();
db.prepare("DELETE FROM admin_sessions WHERE session_hash = ?").run(sessionHash);
db.prepare("DELETE FROM admin_sessions WHERE session_hash = ?").run(
sessionHash,
);
}
export async function getSubscriptionSettings() {
@@ -1073,7 +1192,9 @@ export async function getSubscriptionSettings() {
mainProxyGroupMemberSlugs: JSON.parse(
map.get("main_proxy_group_member_slugs_json") ?? "[]",
) as string[],
proxyGroups: JSON.parse(map.get("proxy_groups_json") ?? "[]") as ProxyGroupConfig[],
proxyGroups: JSON.parse(
map.get("proxy_groups_json") ?? "[]",
) as ProxyGroupConfig[],
} satisfies SubscriptionSettings;
}
@@ -1169,7 +1290,7 @@ export async function listEnabledNodesForPolling() {
`
SELECT *
FROM hy2_nodes
WHERE enabled = 1
WHERE enabled = 1 AND node_type = 'hy2'
ORDER BY id ASC
`,
)
@@ -1186,7 +1307,10 @@ export async function shouldPollNode(nodeId: number, intervalSeconds: number) {
return true;
}
return Date.now() - new Date(row.last_polled_at).getTime() >= intervalSeconds * 1000;
return (
Date.now() - new Date(row.last_polled_at).getTime() >=
intervalSeconds * 1000
);
}
export function applyNodeSync(
@@ -1211,14 +1335,19 @@ export function applyNodeSync(
`
SELECT id, auth_id
FROM hy2_users
WHERE auth_id IN (${Array.from(authIds)
.map(() => "?")
.join(",") || "''"})
WHERE auth_id IN (${
Array.from(authIds)
.map(() => "?")
.join(",") || "''"
})
`,
);
const users = authIds.size
? (lookupUsers.all(...Array.from(authIds)) as Array<{ id: number; auth_id: string }>)
? (lookupUsers.all(...Array.from(authIds)) as Array<{
id: number;
auth_id: string;
}>)
: [];
const userMap = new Map(users.map((user) => [user.auth_id, user.id]));
@@ -1329,7 +1458,11 @@ export function applyNodeSync(
);
}
export async function writeNodeSyncError(nodeId: number, startedAt: string, errorMessage: string) {
export async function writeNodeSyncError(
nodeId: number,
startedAt: string,
errorMessage: string,
) {
const db = await ensureDb();
const now = dbNow();
@@ -1382,7 +1515,9 @@ export async function getKickCandidates(authIds: string[]) {
});
}
export async function runInTransaction<T>(callback: (db: Database.Database) => T) {
export async function runInTransaction<T>(
callback: (db: Database.Database) => T,
) {
const db = await ensureDb();
const transaction = db.transaction(callback);
return transaction(getDb());