This commit is contained in:
2026-04-14 18:05:32 +08:00
parent 68457c9fbd
commit 6378ab4994
40 changed files with 10488 additions and 113 deletions
+38
View File
@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { getEnv } from "@/lib/env";
function getKey() {
return createHash("sha256").update(getEnv().SESSION_SECRET).digest();
}
export function encryptText(value: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", getKey(), iv);
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString("base64url")}.${tag.toString("base64url")}.${encrypted.toString("base64url")}`;
}
export function decryptText(payload: string | null | undefined) {
if (!payload) return null;
const [ivPart, tagPart, encryptedPart] = payload.split(".");
if (!ivPart || !tagPart || !encryptedPart) {
return null;
}
const decipher = createDecipheriv(
"aes-256-gcm",
getKey(),
Buffer.from(ivPart, "base64url"),
);
decipher.setAuthTag(Buffer.from(tagPart, "base64url"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedPart, "base64url")),
decipher.final(),
]);
return decrypted.toString("utf8");
}
+340
View File
@@ -0,0 +1,340 @@
import Database from "better-sqlite3";
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';
`,
];
declare global {
var __hy2PanelDb: Database.Database | undefined;
var __hy2PanelDbInit: Promise<void> | undefined;
}
function createDbInstance() {
const env = getEnv();
const filePath = path.isAbsolute(env.DATABASE_PATH)
? env.DATABASE_PATH
: path.join(/* turbopackIgnore: true */ process.cwd(), env.DATABASE_PATH);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const db = new Database(filePath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
return db;
}
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}`);
}
const env = getEnv();
const now = new Date().toISOString();
const passwordHash = await hashPassword(env.ADMIN_PASSWORD);
db.prepare(
`
INSERT INTO admins (id, username, password_hash, created_at, updated_at)
VALUES (1, @username, @passwordHash, @now, @now)
ON CONFLICT(id) DO UPDATE SET
username = excluded.username,
password_hash = excluded.password_hash,
updated_at = excluded.updated_at
`,
).run({
username: env.ADMIN_USERNAME,
passwordHash,
now,
});
const templatePath = path.join(process.cwd(), "app", "config", "config.yaml");
const defaultTemplate = fs.readFileSync(templatePath, "utf8");
const settings = db.prepare(
`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO NOTHING
`,
);
settings.run("clash_template", defaultTemplate, now);
settings.run("extra_proxy_groups", "", now);
settings.run("profile_name", "FeieProxy", now);
settings.run("proxy_groups_json", "[]", now);
const existingAdminUser = db
.prepare(
`
SELECT id, auth_id, subscription_token
FROM hy2_users
WHERE is_admin = 1 OR username = ?
ORDER BY is_admin DESC, id ASC
LIMIT 1
`,
)
.get(env.ADMIN_USERNAME) as
| { id: number; auth_id: string; subscription_token: string | null }
| undefined;
if (existingAdminUser) {
db.prepare(
`
UPDATE hy2_users
SET
username = @username,
password_hash = @passwordHash,
auth_secret_encrypted = @secret,
is_admin = 1,
enabled = 1,
expires_at = NULL,
traffic_limit_bytes = NULL,
subscription_token = COALESCE(subscription_token, @subscriptionToken),
subscription_label = CASE
WHEN subscription_label = '' THEN @username
ELSE subscription_label
END,
updated_at = @now
WHERE id = @id
`,
).run({
id: existingAdminUser.id,
username: env.ADMIN_USERNAME,
passwordHash,
secret: encryptText(env.ADMIN_PASSWORD),
subscriptionToken: existingAdminUser.subscription_token ?? createOpaqueToken(),
now,
});
} else {
db.prepare(
`
INSERT INTO hy2_users (
auth_id, username, password_hash, enabled, expires_at, traffic_limit_bytes,
used_tx_bytes, used_rx_bytes, notes, is_admin, auth_secret_encrypted,
subscription_token, subscription_label, created_at, updated_at
)
VALUES (
@authId, @username, @passwordHash, 1, NULL, NULL,
0, 0, 'System administrator account', 1, @secret,
@subscriptionToken, @username, @now, @now
)
`,
).run({
authId: `admin-${createStableId()}`,
username: env.ADMIN_USERNAME,
passwordHash,
secret: encryptText(env.ADMIN_PASSWORD),
subscriptionToken: createOpaqueToken(),
now,
});
}
const usersMissingTokens = db
.prepare("SELECT id, username FROM hy2_users WHERE subscription_token IS NULL")
.all() as Array<{ id: number; username: string }>;
const updateUserToken = db.prepare(
`
UPDATE hy2_users
SET
subscription_token = ?,
subscription_label = CASE
WHEN subscription_label = '' THEN ?
ELSE subscription_label
END,
updated_at = ?
WHERE id = ?
`,
);
for (const user of usersMissingTokens) {
updateUserToken.run(createOpaqueToken(), user.username, now, user.id);
}
db.prepare(
`
UPDATE hy2_users
SET
subscription_label = username,
updated_at = ?
WHERE subscription_label = ''
`,
).run(now);
}
export function getDb() {
if (!global.__hy2PanelDb) {
global.__hy2PanelDb = createDbInstance();
}
return global.__hy2PanelDb;
}
export async function ensureDb() {
if (!global.__hy2PanelDbInit) {
global.__hy2PanelDbInit = initializeDb(getDb());
}
await global.__hy2PanelDbInit;
return getDb();
}
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
const schema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
APP_URL: z.string().url().default("http://127.0.0.1:3000"),
DATABASE_PATH: z.string().default("./data/hy2-panel.db"),
SESSION_SECRET: z
.string()
.min(16)
.default("change-this-session-secret-before-production"),
SESSION_COOKIE_SECURE: z
.enum(["auto", "true", "false"])
.default("auto"),
SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 365).default(720),
ADMIN_USERNAME: z.string().min(1).max(64).default("admin"),
ADMIN_PASSWORD: z.string().min(8).max(128).default("change-me-now"),
POLLER_ENABLED: z
.union([z.literal("true"), z.literal("false")])
.default("true")
.transform((value) => value === "true"),
POLLER_STARTUP_DELAY_MS: z.coerce.number().int().min(0).max(60_000).default(2500),
});
export type AppEnv = z.infer<typeof schema>;
let cachedEnv: AppEnv | null = null;
export function getEnv(): AppEnv {
if (cachedEnv) {
return cachedEnv;
}
cachedEnv = schema.parse(process.env);
return cachedEnv;
}
+40
View File
@@ -0,0 +1,40 @@
import { getNodeBySlug, getUserLifecycleState } from "@/lib/store";
export function parseHy2Userpass(auth: string) {
const separator = auth.indexOf(":");
if (separator <= 0 || separator === auth.length - 1) {
return null;
}
return {
username: auth.slice(0, separator),
password: auth.slice(separator + 1),
};
}
export async function verifyNodeToken(slug: string, token: string | null) {
const node = await getNodeBySlug(slug);
if (!node) {
return { ok: false, reason: "node_not_found" as const, node: null };
}
if (!node.enabled) {
return { ok: false, reason: "node_disabled" as const, node };
}
if (!token || token !== node.auth_token) {
return { ok: false, reason: "node_token_invalid" as const, node };
}
return { ok: true, reason: "ok" as const, node };
}
export function evaluateUserForAuth(user: {
enabled: number;
expires_at: string | null;
traffic_limit_bytes: number | null;
used_tx_bytes: number;
used_rx_bytes: number;
}) {
return getUserLifecycleState(user);
}
+27
View File
@@ -0,0 +1,27 @@
import bcrypt from "bcryptjs";
import { createHash, randomBytes, randomUUID } from "node:crypto";
const BCRYPT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
export async function verifyPassword(
password: string,
passwordHash: string,
): Promise<boolean> {
return bcrypt.compare(password, passwordHash);
}
export function createOpaqueToken(size = 24): string {
return randomBytes(size).toString("base64url");
}
export function createStableId(): string {
return randomUUID();
}
export function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
+127
View File
@@ -0,0 +1,127 @@
import { ensureDb, getDb } from "@/lib/db";
import { getEnv } from "@/lib/env";
import {
applyNodeSync,
getKickCandidates,
listEnabledNodesForPolling,
shouldPollNode,
writeNodeSyncError,
} from "@/lib/store";
declare global {
var __hy2PanelPollerStarted: boolean | undefined;
var __hy2PanelPollerRunning: boolean | undefined;
}
type TrafficMap = Record<string, { tx: number; rx: number }>;
async function fetchJson<T>(url: string, init: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(10_000),
cache: "no-store",
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
}
async function syncNode(node: Awaited<ReturnType<typeof listEnabledNodesForPolling>>[number]) {
const startedAt = new Date().toISOString();
try {
const baseUrl = node.traffic_stats_url.replace(/\/$/, "");
const headers = {
Authorization: node.traffic_stats_secret,
"Content-Type": "application/json",
};
const [traffic, online, streams] = await Promise.all([
fetchJson<TrafficMap>(`${baseUrl}/traffic?clear=1`, { headers }),
fetchJson<Record<string, number>>(`${baseUrl}/online`, { headers }),
fetchJson<{ streams?: unknown[] }>(`${baseUrl}/dump/streams`, { headers }),
]);
const kickAuthIds = await getKickCandidates(Object.keys(online));
if (kickAuthIds.length > 0) {
await fetch(`${baseUrl}/kick`, {
method: "POST",
headers,
body: JSON.stringify(kickAuthIds),
signal: AbortSignal.timeout(10_000),
}).then((response) => {
if (!response.ok) {
throw new Error(`kick failed: ${response.status} ${response.statusText}`);
}
});
}
await ensureDb();
const db = getDb();
const finishedAt = new Date().toISOString();
const transaction = db.transaction(() => {
applyNodeSync(db, {
node,
startedAt,
finishedAt,
traffic,
online,
streamCount: streams.streams?.length ?? 0,
kickedAuthIds: kickAuthIds,
});
});
transaction();
} catch (error) {
const message = error instanceof Error ? error.message : "unknown_sync_error";
await writeNodeSyncError(node.id, startedAt, message);
}
}
async function runPollingCycle() {
if (global.__hy2PanelPollerRunning) {
return;
}
global.__hy2PanelPollerRunning = true;
try {
const nodes = await listEnabledNodesForPolling();
for (const node of nodes) {
if (await shouldPollNode(node.id, node.poll_interval_seconds)) {
await syncNode(node);
}
}
} finally {
global.__hy2PanelPollerRunning = false;
}
}
export function startPoller() {
const env = getEnv();
if (!env.POLLER_ENABLED || global.__hy2PanelPollerStarted) {
return;
}
global.__hy2PanelPollerStarted = true;
const start = () => {
runPollingCycle().catch(() => undefined);
const handle = setInterval(() => {
runPollingCycle().catch(() => undefined);
}, 5_000);
handle.unref();
};
if (env.POLLER_STARTUP_DELAY_MS > 0) {
const timeout = setTimeout(start, env.POLLER_STARTUP_DELAY_MS);
timeout.unref();
return;
}
start();
}
+24
View File
@@ -0,0 +1,24 @@
import { headers } from "next/headers";
import { getEnv } from "@/lib/env";
export async function getRequestOrigin() {
const headerList = await headers();
const forwardedProto = headerList.get("x-forwarded-proto");
const forwardedHost = headerList.get("x-forwarded-host");
const host = forwardedHost || headerList.get("host");
if (!host) {
return getEnv().APP_URL;
}
const proto =
forwardedProto || (host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "http");
return `${proto}://${host}`;
}
export function getOriginFromRequest(request: Request) {
const url = new URL(request.url);
return `${url.protocol}//${url.host}`;
}
+188
View File
@@ -0,0 +1,188 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getEnv } from "@/lib/env";
import { createOpaqueToken, createStableId, sha256 } from "@/lib/password";
import {
createAdminSessionRecord,
createUserSessionRecord,
deleteAdminSessionByHash,
deleteUserSessionByHash,
getAdminSessionByHash,
getUserSessionByHash,
touchAdminSession,
touchUserSession,
} from "@/lib/store";
const ADMIN_COOKIE_NAME = "hy2_panel_admin_session";
const USER_COOKIE_NAME = "hy2_panel_user_session";
function shouldUseSecureCookies() {
const env = getEnv();
if (env.SESSION_COOKIE_SECURE === "true") {
return true;
}
if (env.SESSION_COOKIE_SECURE === "false") {
return false;
}
return new URL(env.APP_URL).protocol === "https:";
}
export async function createAdminSession(adminId: number) {
const env = getEnv();
const token = createOpaqueToken(32);
const expiresAt = new Date(
Date.now() + env.SESSION_TTL_HOURS * 60 * 60 * 1000,
).toISOString();
await createAdminSessionRecord({
id: createStableId(),
adminId,
sessionHash: sha256(`${env.SESSION_SECRET}:${token}`),
expiresAt,
});
const cookieStore = await cookies();
cookieStore.set(ADMIN_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: shouldUseSecureCookies(),
path: "/",
expires: new Date(expiresAt),
});
}
export async function createUserSession(userId: number) {
const env = getEnv();
const token = createOpaqueToken(32);
const expiresAt = new Date(
Date.now() + env.SESSION_TTL_HOURS * 60 * 60 * 1000,
).toISOString();
await createUserSessionRecord({
id: createStableId(),
userId,
sessionHash: sha256(`${env.SESSION_SECRET}:${token}`),
expiresAt,
});
const cookieStore = await cookies();
cookieStore.set(USER_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: shouldUseSecureCookies(),
path: "/",
expires: new Date(expiresAt),
});
}
export async function destroyAdminSession() {
const cookieStore = await cookies();
const raw = cookieStore.get(ADMIN_COOKIE_NAME)?.value;
if (raw) {
await deleteAdminSessionByHash(sha256(`${getEnv().SESSION_SECRET}:${raw}`));
}
cookieStore.delete(ADMIN_COOKIE_NAME);
}
export async function destroyUserSession() {
const cookieStore = await cookies();
const raw = cookieStore.get(USER_COOKIE_NAME)?.value;
if (raw) {
await deleteUserSessionByHash(sha256(`${getEnv().SESSION_SECRET}:${raw}`));
}
cookieStore.delete(USER_COOKIE_NAME);
}
export async function getAdminSession() {
const raw = (await cookies()).get(ADMIN_COOKIE_NAME)?.value;
if (!raw) return null;
const session = await getAdminSessionByHash(
sha256(`${getEnv().SESSION_SECRET}:${raw}`),
);
if (!session) {
return null;
}
if (new Date(session.expires_at).getTime() <= Date.now()) {
await deleteAdminSessionByHash(sha256(`${getEnv().SESSION_SECRET}:${raw}`));
return null;
}
if (Date.now() - new Date(session.last_seen_at).getTime() > 5 * 60 * 1000) {
await touchAdminSession(session.id);
}
return session;
}
export async function getUserSession() {
const raw = (await cookies()).get(USER_COOKIE_NAME)?.value;
if (!raw) return null;
const session = await getUserSessionByHash(
sha256(`${getEnv().SESSION_SECRET}:${raw}`),
);
if (!session) {
return null;
}
if (new Date(session.expires_at).getTime() <= Date.now()) {
await deleteUserSessionByHash(sha256(`${getEnv().SESSION_SECRET}:${raw}`));
return null;
}
if (Date.now() - new Date(session.last_seen_at).getTime() > 5 * 60 * 1000) {
await touchUserSession(session.id);
}
return session;
}
export async function requireAdminSession() {
const session = await getAdminSession();
if (!session) {
redirect("/login");
}
return session;
}
export async function requireAdminAccess() {
const adminSession = await getAdminSession();
if (adminSession) {
return {
username: adminSession.username,
source: "admin" as const,
};
}
const userSession = await getUserSession();
if (userSession?.is_admin) {
return {
username: userSession.username,
source: "user" as const,
};
}
redirect("/login");
}
export async function requireUserSession() {
const session = await getUserSession();
if (!session) {
redirect("/login");
}
return session;
}
+1062
View File
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
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";
type ClashSettings = {
template: string;
extraProxyGroups: string;
profileName: string;
proxyGroups: ProxyGroupConfig[];
};
function readDefaultTemplate() {
const filePath = path.join(process.cwd(), "app", "config", "config.yaml");
return fs.readFileSync(filePath, "utf8").trimEnd();
}
function indent(text: string, spaces: number) {
const prefix = " ".repeat(spaces);
return text
.split("\n")
.map((line) => (line ? `${prefix}${line}` : line))
.join("\n");
}
function renderProxy(node: Hy2NodeRow, authString: string) {
const lines = [
`- name: "${node.client_name || node.name}"`,
" type: hysteria2",
` server: ${node.server_host}`,
` port: ${node.server_port}`,
` password: "${authString}"`,
];
if (node.sni) {
lines.push(` sni: ${node.sni}`);
}
if (node.skip_cert_verify) {
lines.push(" skip-cert-verify: true");
}
lines.push(" bandwidth:");
lines.push(` up: ${node.bandwidth_up_mbps} mbps`);
lines.push(` down: ${node.bandwidth_down_mbps} mbps`);
return lines.join("\n");
}
function renderGroupEntry(name: string, proxyNames: string[]) {
const quoted = proxyNames.map((name) => `"${name}"`).join(", ");
return [
` - name: ${name}`,
" type: select",
` proxies: [${quoted}]`,
].join("\n");
}
export function encodeRFC5987ValueChars(value: string) {
return encodeURIComponent(value)
.replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(/\*/g, "%2A")
.replace(/%(7C|60|5E)/g, (_, code) =>
String.fromCharCode(Number.parseInt(code, 16)),
);
}
function buildAsciiFilenameFallback(value: string) {
const ascii = value
.normalize("NFKD")
.replace(/[^\x20-\x7E]+/g, " ")
.replace(/["\\]/g, "_")
.replace(/\s+/g, " ")
.trim();
return ascii || "subscription";
}
export function buildSubscriptionHeaders(user: Hy2UserRow, profileName: string) {
const expire = user.expires_at
? Math.round(new Date(user.expires_at).getTime() / 1000)
: user.is_admin
? 4102444800
: Math.round(Date.now() / 1000) + 3600 * 24 * 365 * 10;
const total =
user.is_admin || user.traffic_limit_bytes == null
? "9223372036854775807"
: String(user.traffic_limit_bytes);
const fallbackFilename = buildAsciiFilenameFallback(profileName);
return {
"Content-Disposition": `attachment; filename="${fallbackFilename}"; filename*=UTF-8''${encodeRFC5987ValueChars(profileName)}`,
"Content-Type": "text/yaml; charset=utf-8",
"Profile-Update-Interval": "12",
"subscription-userinfo": `upload=${user.used_tx_bytes}; download=${user.used_rx_bytes}; total=${total}; expire=${expire}`,
};
}
export function buildSubscriptionUrl(token: string, origin = getEnv().APP_URL) {
return `${origin}/api/subscription/${token}/clash`;
}
export function buildNodeHy2Config(node: Hy2NodeRow, origin = getEnv().APP_URL) {
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
return [
`listen: ${node.hy2_listen || `:${node.server_port}`}`,
"tls:",
" cert: /etc/hysteria/server.crt",
" key: /etc/hysteria/server.key",
"auth:",
" type: http",
" http:",
` url: ${authUrl}`,
" insecure: false",
"trafficStats:",
` listen: ${node.traffic_stats_listen}`,
` secret: ${node.traffic_stats_secret}`,
].join("\n");
}
export function buildSubscriptionConfig(input: {
user: Hy2UserRow;
nodes: Hy2NodeRow[];
settings: ClashSettings;
}) {
const authPassword = decryptText(input.user.auth_secret_encrypted);
if (!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 nodeNameBySlug = new Map(
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
);
const proxyNames = eligibleNodes.map((node) => node.client_name || node.name);
const proxiesBlock = eligibleNodes.map((node) => renderProxy(node, authString)).join("\n");
const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate();
const renderedGroups = [
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),
...input.settings.proxyGroups
.map((group) => {
const members = group.memberSlugs
.map((slug) => nodeNameBySlug.get(slug))
.filter((name): name is string => Boolean(name));
if (members.length === 0) {
return null;
}
return renderGroupEntry(group.name, members);
})
.filter((group): group is string => Boolean(group)),
].join("\n");
const extraGroups = input.settings.extraProxyGroups
.trim()
.replace(/^proxy-groups:\s*/i, "");
return [
defaultTemplate,
"",
"proxies:",
indent(proxiesBlock, 2),
"",
"proxy-groups:",
renderedGroups,
extraGroups ? `\n${extraGroups}` : "",
"",
].join("\n");
}
export function getDefaultClashTemplate() {
return readDefaultTemplate();
}
export function describeRemainingTraffic(user: Hy2UserRow) {
if (user.is_admin || user.traffic_limit_bytes == null) {
return "不限流量";
}
const remaining = Math.max(
user.traffic_limit_bytes - user.used_tx_bytes - user.used_rx_bytes,
0,
);
return `${formatBytes(remaining)} 剩余`;
}