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