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
+67
View File
@@ -0,0 +1,67 @@
import { parseDocument, stringify } from "yaml";
export class CustomProxyValidationError extends Error {}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
/** Accept a single proxy, a one-item list, or the legacy `proxies:` wrapper. */
export function parseCustomProxyYaml(
source: string,
): Record<string, unknown> & { name: string; type: string } {
if (!source.trim()) throw new CustomProxyValidationError("请填写节点 YAML。");
if (source.length > 128 * 1024)
throw new CustomProxyValidationError("单个节点 YAML 不能超过 128 KiB。");
const doc = parseDocument(source, { prettyErrors: false, uniqueKeys: true });
if (doc.errors.length || doc.warnings.length) {
// Parser messages can contain credentials from the YAML; do not echo them.
throw new CustomProxyValidationError(
"YAML 格式错误,请检查缩进、引号和重复字段。",
);
}
let value: unknown;
try {
value = doc.toJS({ maxAliasCount: 100 });
} catch {
throw new CustomProxyValidationError(
"YAML 引用无效或过多,请使用独立的节点配置。",
);
}
if (isRecord(value) && "proxies" in value) value = value.proxies;
if (Array.isArray(value)) {
if (value.length !== 1)
throw new CustomProxyValidationError(
"每条节点记录只能包含一个 proxy,请将多个节点分别添加。",
);
value = value[0];
}
if (!isRecord(value))
throw new CustomProxyValidationError(
"请填写一个 Clash proxy 对象或单节点列表。",
);
if (typeof value.name !== "string" || !value.name.trim()) {
throw new CustomProxyValidationError(
"节点 YAML 必须包含非空的 name 字段(字符串)。",
);
}
if (typeof value.type !== "string" || !value.type.trim()) {
throw new CustomProxyValidationError(
"节点 YAML 必须包含非空的 type 字段,例如 anytls。",
);
}
return value as Record<string, unknown> & { name: string; type: string };
}
/** Re-serialize only for output; stored source and comments remain untouched. */
export function renderCustomProxyYaml(source: string) {
return stringify([parseCustomProxyYaml(source)], { lineWidth: 0 }).trimEnd();
}
export function extractProxyNameFromYaml(source: string) {
try {
return parseCustomProxyYaml(source).name;
} catch {
return null;
}
}
+194
View File
@@ -0,0 +1,194 @@
import type Database from "better-sqlite3";
// Version 4 matches the schema already deployed on us3. Never reuse its number.
function ensureCustomNodeColumns(db: Database.Database) {
const columns = new Set(
(db.pragma("table_info(hy2_nodes)") as { name: string }[]).map(
(column) => column.name,
),
);
if (!columns.has("node_type"))
db.exec(
"ALTER TABLE hy2_nodes ADD COLUMN node_type TEXT NOT NULL DEFAULT 'hy2'",
);
if (!columns.has("custom_config_yaml"))
db.exec(
"ALTER TABLE hy2_nodes ADD COLUMN custom_config_yaml TEXT NOT NULL DEFAULT ''",
);
}
const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [
`
CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
admin_id INTEGER NOT NULL,
session_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS hy2_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
auth_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
traffic_limit_bytes INTEGER,
used_tx_bytes INTEGER NOT NULL DEFAULT 0,
used_rx_bytes INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hy2_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
auth_token TEXT NOT NULL,
traffic_stats_url TEXT NOT NULL,
traffic_stats_secret TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
poll_interval_seconds INTEGER NOT NULL DEFAULT 15,
last_polled_at TEXT,
last_sync_ok_at TEXT,
last_error_at TEXT,
last_error_message TEXT,
last_online_users INTEGER NOT NULL DEFAULT 0,
last_stream_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS traffic_ledgers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
user_id INTEGER,
auth_id TEXT NOT NULL,
tx_bytes INTEGER NOT NULL,
rx_bytes INTEGER NOT NULL,
window_started_at TEXT NOT NULL,
window_ended_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS node_sync_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
ok INTEGER NOT NULL,
traffic_entries INTEGER NOT NULL DEFAULT 0,
online_users INTEGER NOT NULL DEFAULT 0,
stream_count INTEGER NOT NULL DEFAULT 0,
kicked_auth_ids TEXT NOT NULL DEFAULT '',
error_message TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS auth_audits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER,
user_id INTEGER,
auth_id TEXT,
username TEXT,
addr TEXT,
requested_tx INTEGER,
ok INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE SET NULL,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS node_user_presence (
node_id INTEGER NOT NULL,
user_id INTEGER,
auth_id TEXT NOT NULL,
connections INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (node_id, auth_id),
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_hy2_users_auth_id ON hy2_users(auth_id);
CREATE INDEX IF NOT EXISTS idx_hy2_users_username ON hy2_users(username);
CREATE INDEX IF NOT EXISTS idx_hy2_nodes_enabled ON hy2_nodes(enabled);
CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_auth_id ON traffic_ledgers(auth_id);
CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_created_at ON traffic_ledgers(created_at);
CREATE INDEX IF NOT EXISTS idx_node_sync_records_node_id ON node_sync_records(node_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_auth_audits_created_at ON auth_audits(created_at DESC);
`,
`
ALTER TABLE hy2_users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;
ALTER TABLE hy2_users ADD COLUMN auth_secret_encrypted TEXT;
ALTER TABLE hy2_users ADD COLUMN subscription_token TEXT;
ALTER TABLE hy2_users ADD COLUMN subscription_label TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
session_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
ALTER TABLE hy2_nodes ADD COLUMN server_host TEXT NOT NULL DEFAULT '';
ALTER TABLE hy2_nodes ADD COLUMN server_port INTEGER NOT NULL DEFAULT 443;
ALTER TABLE hy2_nodes ADD COLUMN client_name TEXT NOT NULL DEFAULT '';
ALTER TABLE hy2_nodes ADD COLUMN bandwidth_up_mbps INTEGER NOT NULL DEFAULT 30;
ALTER TABLE hy2_nodes ADD COLUMN bandwidth_down_mbps INTEGER NOT NULL DEFAULT 30;
ALTER TABLE hy2_nodes ADD COLUMN skip_cert_verify INTEGER NOT NULL DEFAULT 0;
ALTER TABLE hy2_nodes ADD COLUMN sni TEXT;
ALTER TABLE hy2_nodes ADD COLUMN traffic_stats_listen TEXT NOT NULL DEFAULT ':9999';
CREATE UNIQUE INDEX IF NOT EXISTS idx_hy2_users_subscription_token ON hy2_users(subscription_token);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
`,
`
ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443';
`,
ensureCustomNodeColumns,
];
export function migrateDatabase(db: Database.Database) {
// Read the version under the same write lock as the migration to avoid races.
db.transaction(() => {
const currentVersion = db.pragma("user_version", {
simple: true,
}) as number;
if (currentVersion > MIGRATIONS.length)
throw new Error("数据库版本高于当前程序,请使用匹配的程序版本。");
for (let index = currentVersion; index < MIGRATIONS.length; index += 1) {
const migration = MIGRATIONS[index];
if (typeof migration === "string") db.exec(migration);
else migration(db);
db.pragma(`user_version = ${index + 1}`);
}
// Also repairs a partially applied v4 without rewriting any existing data.
ensureCustomNodeColumns(db);
}).immediate();
}
+12 -165
View File
@@ -1,166 +1,15 @@
import Database from "better-sqlite3";
import { migrateDatabase } from "@/lib/db-migrations";
import fs from "node:fs";
import path from "node:path";
import { encryptText } from "@/lib/crypto";
import { getEnv } from "@/lib/env";
import { createOpaqueToken, createStableId, hashPassword } from "@/lib/password";
const MIGRATIONS = [
`
CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
admin_id INTEGER NOT NULL,
session_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS hy2_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
auth_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
traffic_limit_bytes INTEGER,
used_tx_bytes INTEGER NOT NULL DEFAULT 0,
used_rx_bytes INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hy2_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
auth_token TEXT NOT NULL,
traffic_stats_url TEXT NOT NULL,
traffic_stats_secret TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
poll_interval_seconds INTEGER NOT NULL DEFAULT 15,
last_polled_at TEXT,
last_sync_ok_at TEXT,
last_error_at TEXT,
last_error_message TEXT,
last_online_users INTEGER NOT NULL DEFAULT 0,
last_stream_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS traffic_ledgers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
user_id INTEGER,
auth_id TEXT NOT NULL,
tx_bytes INTEGER NOT NULL,
rx_bytes INTEGER NOT NULL,
window_started_at TEXT NOT NULL,
window_ended_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS node_sync_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
ok INTEGER NOT NULL,
traffic_entries INTEGER NOT NULL DEFAULT 0,
online_users INTEGER NOT NULL DEFAULT 0,
stream_count INTEGER NOT NULL DEFAULT 0,
kicked_auth_ids TEXT NOT NULL DEFAULT '',
error_message TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS auth_audits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER,
user_id INTEGER,
auth_id TEXT,
username TEXT,
addr TEXT,
requested_tx INTEGER,
ok INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE SET NULL,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS node_user_presence (
node_id INTEGER NOT NULL,
user_id INTEGER,
auth_id TEXT NOT NULL,
connections INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (node_id, auth_id),
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_hy2_users_auth_id ON hy2_users(auth_id);
CREATE INDEX IF NOT EXISTS idx_hy2_users_username ON hy2_users(username);
CREATE INDEX IF NOT EXISTS idx_hy2_nodes_enabled ON hy2_nodes(enabled);
CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_auth_id ON traffic_ledgers(auth_id);
CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_created_at ON traffic_ledgers(created_at);
CREATE INDEX IF NOT EXISTS idx_node_sync_records_node_id ON node_sync_records(node_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_auth_audits_created_at ON auth_audits(created_at DESC);
`,
`
ALTER TABLE hy2_users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;
ALTER TABLE hy2_users ADD COLUMN auth_secret_encrypted TEXT;
ALTER TABLE hy2_users ADD COLUMN subscription_token TEXT;
ALTER TABLE hy2_users ADD COLUMN subscription_label TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
session_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
ALTER TABLE hy2_nodes ADD COLUMN server_host TEXT NOT NULL DEFAULT '';
ALTER TABLE hy2_nodes ADD COLUMN server_port INTEGER NOT NULL DEFAULT 443;
ALTER TABLE hy2_nodes ADD COLUMN client_name TEXT NOT NULL DEFAULT '';
ALTER TABLE hy2_nodes ADD COLUMN bandwidth_up_mbps INTEGER NOT NULL DEFAULT 30;
ALTER TABLE hy2_nodes ADD COLUMN bandwidth_down_mbps INTEGER NOT NULL DEFAULT 30;
ALTER TABLE hy2_nodes ADD COLUMN skip_cert_verify INTEGER NOT NULL DEFAULT 0;
ALTER TABLE hy2_nodes ADD COLUMN sni TEXT;
ALTER TABLE hy2_nodes ADD COLUMN traffic_stats_listen TEXT NOT NULL DEFAULT ':9999';
CREATE UNIQUE INDEX IF NOT EXISTS idx_hy2_users_subscription_token ON hy2_users(subscription_token);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
`,
`
ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443';
`,
];
import {
createOpaqueToken,
createStableId,
hashPassword,
} from "@/lib/password";
declare global {
var __hy2PanelDb: Database.Database | undefined;
@@ -183,12 +32,7 @@ function createDbInstance() {
}
async function initializeDb(db: Database.Database) {
const currentVersion = db.pragma("user_version", { simple: true }) as number;
for (let index = currentVersion; index < MIGRATIONS.length; index += 1) {
db.exec(MIGRATIONS[index]);
db.pragma(`user_version = ${index + 1}`);
}
migrateDatabase(db);
const env = getEnv();
const now = new Date().toISOString();
@@ -263,7 +107,8 @@ async function initializeDb(db: Database.Database) {
username: env.ADMIN_USERNAME,
passwordHash,
secret: encryptText(env.ADMIN_PASSWORD),
subscriptionToken: existingAdminUser.subscription_token ?? createOpaqueToken(),
subscriptionToken:
existingAdminUser.subscription_token ?? createOpaqueToken(),
now,
});
} else {
@@ -291,7 +136,9 @@ async function initializeDb(db: Database.Database) {
}
const usersMissingTokens = db
.prepare("SELECT id, username FROM hy2_users WHERE subscription_token IS NULL")
.prepare(
"SELECT id, username FROM hy2_users WHERE subscription_token IS NULL",
)
.all() as Array<{ id: number; username: string }>;
const updateUserToken = db.prepare(
+4
View File
@@ -22,6 +22,10 @@ export async function verifyNodeToken(slug: string, token: string | null) {
return { ok: false, reason: "node_disabled" as const, node };
}
if (node.node_type !== "hy2") {
return { ok: false, reason: "node_not_hy2" as const, node };
}
if (!token || token !== node.auth_token) {
return { ok: false, reason: "node_token_invalid" as const, node };
}
+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());
+60 -25
View File
@@ -1,14 +1,11 @@
import { renderCustomProxyYaml } from "@/lib/custom-proxy";
import fs from "node:fs";
import path from "node:path";
import { decryptText } from "@/lib/crypto";
import { getEnv } from "@/lib/env";
import type {
Hy2NodeRow,
Hy2UserRow,
ProxyGroupConfig,
} from "@/lib/store";
import { buildNodeAuthUrl, formatBytes } from "@/lib/store";
import type { Hy2NodeRow, Hy2UserRow, ProxyGroupConfig } from "@/lib/store";
import { buildNodeAuthUrl, formatBytes, getNodeProxyName } from "@/lib/store";
type ClashSettings = {
template: string;
@@ -31,13 +28,13 @@ function indent(text: string, spaces: number) {
.join("\n");
}
function renderProxy(node: Hy2NodeRow, authString: string) {
function renderHy2Proxy(node: Hy2NodeRow, authString: string) {
const lines = [
`- name: "${node.client_name || node.name}"`,
`- name: ${JSON.stringify(node.client_name || node.name)}`,
" type: hysteria2",
` server: ${node.server_host}`,
` port: ${node.server_port}`,
` password: "${authString}"`,
` password: ${JSON.stringify(authString)}`,
];
if (node.sni) {
@@ -55,10 +52,30 @@ function renderProxy(node: Hy2NodeRow, authString: string) {
return lines.join("\n");
}
function renderSubscriptionProxy(node: Hy2NodeRow, authString: string) {
if (node.node_type === "custom") {
return renderCustomProxyYaml(node.custom_config_yaml);
}
return renderHy2Proxy(node, authString);
}
function isEligibleSubscriptionNode(node: Hy2NodeRow) {
if (!node.enabled) {
return false;
}
if (node.node_type === "custom") {
return Boolean(node.custom_config_yaml.trim());
}
return Boolean(node.server_host && node.server_port > 0);
}
function renderGroupEntry(name: string, proxyNames: string[]) {
const quoted = proxyNames.map((name) => `"${name}"`).join(", ");
const quoted = proxyNames.map((name) => JSON.stringify(name)).join(", ");
return [
` - name: ${name}`,
` - name: ${JSON.stringify(name)}`,
" type: select",
` proxies: [${quoted}]`,
].join("\n");
@@ -94,7 +111,10 @@ function orderNodesBySlugs(nodes: Hy2NodeRow[], orderedSlugs: string[]) {
export function encodeRFC5987ValueChars(value: string) {
return encodeURIComponent(value)
.replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(
/['()]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
)
.replace(/\*/g, "%2A")
.replace(/%(7C|60|5E)/g, (_, code) =>
String.fromCharCode(Number.parseInt(code, 16)),
@@ -112,7 +132,10 @@ function buildAsciiFilenameFallback(value: string) {
return ascii || "subscription";
}
export function buildSubscriptionHeaders(user: Hy2UserRow, profileName: string) {
export function buildSubscriptionHeaders(
user: Hy2UserRow,
profileName: string,
) {
const expire = user.expires_at
? Math.round(new Date(user.expires_at).getTime() / 1000)
: user.is_admin
@@ -137,7 +160,10 @@ export function buildSubscriptionUrl(token: string, origin = getEnv().APP_URL) {
return `${origin}/api/subscription/${token}/clash`;
}
export function buildNodeHy2Config(node: Hy2NodeRow, origin = getEnv().APP_URL) {
export function buildNodeHy2Config(
node: Hy2NodeRow,
origin = getEnv().APP_URL,
) {
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
return [
@@ -161,25 +187,34 @@ export function buildSubscriptionConfig(input: {
nodes: Hy2NodeRow[];
settings: ClashSettings;
}) {
const authPassword = decryptText(input.user.auth_secret_encrypted);
if (!authPassword) {
const eligibleNodes = input.nodes.filter((node) =>
isEligibleSubscriptionNode(node),
);
const needsUserAuth = eligibleNodes.some((node) => node.node_type === "hy2");
const authPassword = needsUserAuth
? decryptText(input.user.auth_secret_encrypted)
: null;
if (needsUserAuth && !authPassword) {
throw new Error("user_secret_unavailable");
}
const authString = `${input.user.username}:${authPassword}`;
const eligibleNodes = input.nodes.filter(
(node) => node.enabled && node.server_host && node.server_port > 0,
);
const authString = authPassword
? `${input.user.username}:${authPassword}`
: "";
const orderedEligibleNodes = orderNodesBySlugs(
eligibleNodes,
input.settings.mainProxyGroupMemberSlugs,
);
const nodeNameBySlug = new Map(
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
eligibleNodes.map((node) => [node.slug, getNodeProxyName(node)]),
);
const proxyNames = orderedEligibleNodes.map((node) => node.client_name || node.name);
const proxiesBlock = orderedEligibleNodes.map((node) => renderProxy(node, authString)).join("\n");
const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate();
const proxyNames = orderedEligibleNodes.map((node) => getNodeProxyName(node));
const proxiesBlock = orderedEligibleNodes
.map((node) => renderSubscriptionProxy(node, authString))
.join("\n");
const defaultTemplate =
input.settings.template.trimEnd() || readDefaultTemplate();
const renderedGroups = [
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),
...input.settings.proxyGroups
@@ -202,7 +237,7 @@ export function buildSubscriptionConfig(input: {
return [
defaultTemplate,
"",
"proxies:",
proxiesBlock ? "proxies:" : "proxies: []",
indent(proxiesBlock, 2),
"",
"proxy-groups:",