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