init
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)} 剩余`;
|
||||
}
|
||||
Reference in New Issue
Block a user