From b6aa2bf30d9ab2952cedae3ac1c333ad27f9ca58 Mon Sep 17 00:00:00 2001 From: feiezha Date: Wed, 30 Sep 2026 17:36:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=89=98=E7=AE=A1?= =?UTF-8?q?=E4=B8=AD=E8=BD=AC=E4=B8=BB=E6=9C=BA=E5=92=8C=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=BA=BF=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 + app/admin/layout.tsx | 1 + app/admin/relays/actions.ts | 51 ++++ app/admin/relays/page.tsx | 296 ++++++++++++++++++++ app/api/admin/relays/[id]/install/route.ts | 48 ++++ app/api/relay/[id]/sync/route.ts | 37 +++ lib/db-migrations.ts | 26 ++ lib/relays.ts | 307 +++++++++++++++++++++ lib/store.ts | 4 +- lib/templates/install-relay.sh | 70 +++++ lib/templates/relay-agent.py | 121 ++++++++ tests/e2e-fixtures.ts | 2 +- tests/relays.spec.ts | 118 ++++++++ tests/unit/migrations.test.ts | 34 ++- tests/unit/relays.test.ts | 218 +++++++++++++++ 15 files changed, 1338 insertions(+), 5 deletions(-) create mode 100644 app/admin/relays/actions.ts create mode 100644 app/admin/relays/page.tsx create mode 100644 app/api/admin/relays/[id]/install/route.ts create mode 100644 app/api/relay/[id]/sync/route.ts create mode 100644 lib/relays.ts create mode 100644 lib/templates/install-relay.sh create mode 100644 lib/templates/relay-agent.py create mode 100644 tests/relays.spec.ts create mode 100644 tests/unit/relays.test.ts diff --git a/README.md b/README.md index fdfdb6e..c02e89e 100644 --- a/README.md +++ b/README.md @@ -225,3 +225,13 @@ sudo bash install-jp2-anytls.sh --domain jp2 --port 51046 --listen-port 51046 上游源码与许可:https://github.com/SagerNet/sing-box/tree/v1.14.2 ,GPL-3.0-or-later。`lib/templates/stats.proto` 来自该版本,许可说明见同目录 `sing-box-LICENSE`。构建脚本提供可复现的源码获取与编译步骤。 真实协议回归可运行:`SING_BOX_TEST_BINARY=/path/to/sing-box AGENT_TEST_PYTHON=/path/to/python npx tsx tests/anytls-runtime.ts`(Python 环境需 grpcio 和 grpcio-tools)。它验证 TLS 校验、实际转发、按用户统计及停用后的拒绝连接。 + +## 中转主机与线路 + +「中转线路」新增入口主机,下载安装 SH,以 root 在该主机运行。脚本安装固定版本 Realm v2.9.6(官方 Linux musl x86_64 / ARM64 产物,内置 SHA-256),独立使用 `/etc/hy2-panel-relay`、`/usr/local/lib/hy2-panel-relay`、`hy2-panel-relay.service` 和 `hy2-panel-relay-agent.service`。不覆盖已有代理服务,不自动修改防火墙;需放行选定的监听端口,并配置云防火墙/NAT 公网端口映射。脚本含长期管理凭据,应按密钥保管;删除主机可撤销凭据。 + +新增线路时选择入口主机、原目标节点,填写公网端口与本机端口。第一版支持托管 HY2(UDP)和 AnyTLS(TCP),保留原节点直连。入口只转发加密流量,不持有节点证书、私钥或用户密码;订阅地址切换到入口,但 SNI 保留原目标域名。AnyTLS 仍允许在 TCP 隧道内转发 UDP 业务。 + +管理程序每 15 秒主动获取配置,检查本机端口冲突及 Realm 实际监听情况,配置变更会重启这一台主机的 Realm 并断开已有中转连接。启用、停用、删除在下次同步生效;无法联系平台约 90 秒后停止转发,联网后恢复。页面显示配置/进程状态,不能代替真实协议或大陆线路质量测试。UDP 空闲映射保留 300 秒。 + +仅最近上报、配置已应用且服务运行的线路进入订阅,可复用订阅拖拽排序和分组。目标节点停用时其中转线路也停止发布并从转发配置移除。数据库 v7 仅新增 `relay_hosts`、`relay_routes`,中转线路不会插入 `hy2_nodes` 或参与节点轮询;用户流量仅由落地节点计量一次,第一版不区分同一用户的直连/中转用量。删除被引用的目标节点前需先删除对应线路。 diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 127b5ee..e899937 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -7,6 +7,7 @@ const navItems = [ { href: "/admin/dashboard", label: "仪表盘" }, { href: "/admin/users", label: "用户管理" }, { href: "/admin/nodes", label: "节点管理" }, + { href: "/admin/relays", label: "中转线路" }, { href: "/admin/certificates", label: "证书与安装" }, { href: "/admin/subscriptions", label: "订阅配置" }, { href: "/admin/audits", label: "审计日志" }, diff --git a/app/admin/relays/actions.ts b/app/admin/relays/actions.ts new file mode 100644 index 0000000..8dd859d --- /dev/null +++ b/app/admin/relays/actions.ts @@ -0,0 +1,51 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { requireAdminAccess } from "@/lib/session"; +import { + deleteRelay, + RelayError, + saveRelayHost, + saveRelayRoute, +} from "@/lib/relays"; +const value = (form: FormData, key: string) => String(form.get(key) || ""); +async function run(work: () => Promise) { + await requireAdminAccess(); + try { + await work(); + revalidatePath("/admin/relays"); + revalidatePath("/admin/subscriptions"); + } catch (e) { + return { + error: e instanceof RelayError ? e.message : "保存失败,请检查输入。", + }; + } +} +export async function saveHostAction(form: FormData) { + return run(() => + saveRelayHost({ + id: value(form, "id") || undefined, + name: value(form, "name"), + server_host: value(form, "server_host"), + enabled: form.get("enabled") === "on", + }), + ); +} +export async function saveRouteAction(form: FormData) { + return run(() => + saveRelayRoute({ + id: value(form, "id") || undefined, + name: value(form, "name"), + host_id: value(form, "host_id"), + node_id: value(form, "node_id"), + public_port: value(form, "public_port"), + listen_port: value(form, "listen_port"), + enabled: form.get("enabled") === "on", + }), + ); +} +export async function deleteHostAction(form: FormData) { + return run(() => deleteRelay("host", value(form, "id"))); +} +export async function deleteRouteAction(form: FormData) { + return run(() => deleteRelay("route", value(form, "id"))); +} diff --git a/app/admin/relays/page.tsx b/app/admin/relays/page.tsx new file mode 100644 index 0000000..06e69ad --- /dev/null +++ b/app/admin/relays/page.tsx @@ -0,0 +1,296 @@ +import Link from "next/link"; +import { ActionForm } from "@/components/action-form"; +import { Input } from "@/components/ui/input"; +import { SubmitButton } from "@/components/submit-button"; +import { buttonVariants } from "@/components/ui/button"; +import { relayDesired, relayInventory, type RelayRoute } from "@/lib/relays"; +import { ensureDb } from "@/lib/db"; +import { listNodes } from "@/lib/store"; +import { formatLocalDateTime } from "@/lib/datetime"; +import { + saveHostAction, + saveRouteAction, + deleteHostAction, + deleteRouteAction, +} from "./actions"; +export default async function RelaysPage() { + const [{ hosts, routes }, nodes, db] = await Promise.all([ + relayInventory(), + listNodes(), + ensureDb(), + ]); + const targets = nodes.filter((n) => ["hy2", "anytls"].includes(n.node_type)); + const now = new Date().getTime(); + const states = new Map( + hosts.map((h) => [ + h.id, + !h.enabled + ? "已停用" + : !h.last_seen_at + ? "等待安装" + : now - Date.parse(h.last_seen_at) > 90000 + ? "离线" + : h.error + ? h.error === "port_conflict" + ? "端口冲突" + : "应用失败" + : h.applied_hash !== relayDesired(db, h).hash + ? "等待应用" + : relayDesired(db, h).config.endpoints.length === 0 + ? "待添加线路" + : h.service_active + ? "配置已应用" + : "服务未运行", + ]), + ); + const routeForm = (r?: RelayRoute) => ( + + {r && } + + + + + + + {r ? "保存线路" : "创建线路"} + + ); + return ( +
+
+
+

中转线路

+

+ 入口主机透传 TCP / UDP,目标节点继续认证与计量,保留直连。 +

+
+ + 订阅排序与分组 + +
+
+
+ +

新增中转主机

+
+ + + + + 创建主机 + +
+
+
+
+

中转主机

+ {hosts.length} 台 +
+
+ {hosts.map((h) => ( +
+ + {h.name} + {h.server_host} + {states.get(h.id)} + +

+ 最近上报: + {h.last_seen_at + ? formatLocalDateTime(h.last_seen_at) + : "尚未上报"} + 。状态代表配置和进程,不代表大陆线路质量。 +

+ + + + + + 保存主机 + +
+ + 下载安装 SH + + + + + 删除主机 + + +
+
+ ))} +
+

+ 以 root 在入口主机执行脚本,使用独立 Realm + 服务。脚本含管理凭据,请妥善保管。仅开放线路所需端口;不需要额外的公网管理端口或证书。 +

+
+
+
0 && !routes.length}> + +

新增中转线路

+
+ {routeForm()} +
+
+
+
+

线路列表

+ {routes.length} 条 +
+

+ 端口相同表示无 NAT 端口转换。配置约 15 + 秒同步,应用变更会中断该主机已有中转连接;服务失联超过 90 + 秒停止转发。仅配置已应用的线路进入订阅,SNI 和凭据继承目标节点。UDP + 业务仍可通过 AnyTLS 的 TCP 隧道转发。 +

+ {routes.map((r) => { + const h = hosts.find((h) => h.id === r.host_id), + n = nodes.find((n) => n.id === r.node_id); + return ( +
+ + {r.name} + + {n?.node_type === "hy2" ? "UDP" : "TCP"} ·{" "} + {r.enabled ? states.get(r.host_id) : "已停用"} + + +

+ {h?.server_host}:{r.public_port} → 本机 :{r.listen_port} →{" "} + {n?.server_host}:{n?.server_port} + {!n?.enabled ? "(目标已停用)" : ""} +

+ {routeForm(r)} + + + + 删除线路 + + +
+ ); + })} +
+
+ ); +} diff --git a/app/api/admin/relays/[id]/install/route.ts b/app/api/admin/relays/[id]/install/route.ts new file mode 100644 index 0000000..7bc9a5d --- /dev/null +++ b/app/api/admin/relays/[id]/install/route.ts @@ -0,0 +1,48 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { requireAdminAccess } from "@/lib/session"; +import { relayInventory } from "@/lib/relays"; +import { decryptText } from "@/lib/crypto"; +import { getEnv } from "@/lib/env"; +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + await requireAdminAccess(); + const id = (await context.params).id; + const row = (await relayInventory()).hosts.find((h) => h.id === id); + if (!row) return new Response(null, { status: 404 }); + const origin = new URL(getEnv().APP_URL); + if ( + origin.protocol !== "https:" && + !["localhost", "127.0.0.1"].includes(origin.hostname) + ) + return new Response("请配置 HTTPS APP_URL。", { status: 409 }); + const source = await readFile( + path.join(process.cwd(), "lib/templates/relay-agent.py"), + "utf8", + ); + const token = decryptText(row.token_encrypted); + const payload = Buffer.from( + JSON.stringify({ + credentials: { panel: origin.origin, id, token }, + source, + }), + ).toString("base64"); + const script = ( + await readFile( + path.join(process.cwd(), "lib/templates/install-relay.sh"), + "utf8", + ) + ) + .replace("__ID__", `'${id}'`) + .replace("__PAYLOAD__", `'${payload}'`); + return new Response(script, { + headers: { + "Content-Type": "text/x-shellscript; charset=utf-8", + "Content-Disposition": `attachment; filename="install-relay-${id}.sh"`, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/app/api/relay/[id]/sync/route.ts b/app/api/relay/[id]/sync/route.ts new file mode 100644 index 0000000..36cb7f9 --- /dev/null +++ b/app/api/relay/[id]/sync/route.ts @@ -0,0 +1,37 @@ +import { syncRelay, RelayError } from "@/lib/relays"; +export const runtime = "nodejs"; +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const headers = { "Cache-Control": "no-store" }; + try { + const reader = request.body?.getReader(); + if (!reader) return new Response(null, { status: 400, headers }); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 16384) { + await reader.cancel(); + return new Response(null, { status: 413, headers }); + } + chunks.push(value); + } + return Response.json( + await syncRelay( + (await context.params).id, + request.headers.get("authorization")?.replace(/^Bearer /, "") || "", + JSON.parse(Buffer.concat(chunks).toString("utf8")), + ), + { headers }, + ); + } catch (e) { + return Response.json( + { error: e instanceof RelayError ? e.message : "无效报告。" }, + { status: e instanceof RelayError ? 403 : 400, headers }, + ); + } +} diff --git a/lib/db-migrations.ts b/lib/db-migrations.ts index 171efcd..ebb72b4 100644 --- a/lib/db-migrations.ts +++ b/lib/db-migrations.ts @@ -210,6 +210,32 @@ const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [ PRIMARY KEY(node_id, epoch, auth_id) ); `, + ` + CREATE TABLE relay_hosts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_host TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_encrypted TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + last_seen_at TEXT, + applied_hash TEXT NOT NULL DEFAULT '', + service_active INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + CREATE TABLE relay_routes ( + id TEXT PRIMARY KEY, + host_id TEXT NOT NULL REFERENCES relay_hosts(id) ON DELETE CASCADE, + node_id INTEGER NOT NULL REFERENCES hy2_nodes(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + public_port INTEGER NOT NULL CHECK(public_port BETWEEN 1 AND 65535), + listen_port INTEGER NOT NULL CHECK(listen_port BETWEEN 1 AND 65535), + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ); + CREATE INDEX relay_routes_host ON relay_routes(host_id); + `, ]; export function migrateDatabase(db: Database.Database) { diff --git a/lib/relays.ts b/lib/relays.ts new file mode 100644 index 0000000..59fac9d --- /dev/null +++ b/lib/relays.ts @@ -0,0 +1,307 @@ +import { + createHash, + randomBytes, + randomUUID, + timingSafeEqual, +} from "node:crypto"; +import { isIP } from "node:net"; +import { z } from "zod"; +import type Database from "better-sqlite3"; +import { ensureDb } from "@/lib/db"; +import { encryptText } from "@/lib/crypto"; +import type { Hy2NodeRow } from "@/lib/store"; + +export type RelayHost = { + id: string; + name: string; + server_host: string; + enabled: number; + token_hash: string; + token_encrypted: string; + last_seen_at: string | null; + applied_hash: string; + service_active: number; + error: string; + created_at: string; +}; +export type RelayRoute = { + id: string; + host_id: string; + node_id: number; + name: string; + public_port: number; + listen_port: number; + enabled: number; + created_at: string; +}; +export class RelayError extends Error {} +const hostname = z + .string() + .trim() + .min(1) + .max(253) + .refine( + (v) => + Boolean(isIP(v)) || + /^(?=.{1,253}$)[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(v), + "请输入域名或 IP,不含协议和端口。", + ); +const hostSchema = z.object({ + id: z.string().uuid().optional(), + name: z.string().trim().min(1).max(100), + server_host: hostname, + enabled: z.boolean(), +}); +const routeSchema = z.object({ + id: z.string().uuid().optional(), + host_id: z.string().uuid(), + node_id: z.coerce.number().int().positive(), + name: z.string().trim().min(1).max(100), + public_port: z.coerce.number().int().min(1).max(65535), + listen_port: z.coerce.number().int().min(1).max(65535), + enabled: z.boolean(), +}); +const digest = (value: string) => + createHash("sha256").update(value).digest("hex"); +export const relayReportSchema = z + .object({ + appliedHash: z.string().regex(/^([a-f0-9]{64})?$/), + active: z.boolean(), + error: z.enum(["", "port_conflict", "apply_failed", "service_stopped"]), + }) + .strict(); +export async function relayInventory() { + const db = await ensureDb(); + return { + hosts: db + .prepare("SELECT * FROM relay_hosts ORDER BY created_at DESC") + .all() as RelayHost[], + routes: db + .prepare("SELECT * FROM relay_routes ORDER BY created_at DESC") + .all() as RelayRoute[], + }; +} +export async function saveRelayHost(input: unknown) { + const parsed = hostSchema.safeParse(input); + if (!parsed.success) + throw new RelayError("请填写主机名称和有效的公网域名/IP。"); + const data = parsed.data, + db = await ensureDb(); + if (data.id) { + if ( + !db + .prepare( + "UPDATE relay_hosts SET name=?,server_host=?,enabled=? WHERE id=?", + ) + .run(data.name, data.server_host, +data.enabled, data.id).changes + ) + throw new RelayError("中转主机不存在。"); + return data.id; + } + const id = randomUUID(), + token = randomBytes(32).toString("hex"); + db.prepare( + "INSERT INTO relay_hosts(id,name,server_host,enabled,token_hash,token_encrypted,created_at) VALUES(?,?,?,?,?,?,?)", + ).run( + id, + data.name, + data.server_host, + +data.enabled, + digest(token), + encryptText(token), + new Date().toISOString(), + ); + return id; +} +export async function saveRelayRoute(input: unknown) { + const parsed = routeSchema.safeParse(input); + if (!parsed.success) + throw new RelayError("请填写线路名称、主机、目标节点及 1–65535 的端口。"); + const data = parsed.data, + db = await ensureDb(); + return db + .transaction(() => { + const host = db + .prepare("SELECT * FROM relay_hosts WHERE id=?") + .get(data.host_id) as RelayHost | undefined; + const node = db + .prepare("SELECT * FROM hy2_nodes WHERE id=?") + .get(data.node_id) as Hy2NodeRow | undefined; + if ( + !host || + !node || + !["hy2", "anytls"].includes(node.node_type) || + !node.server_host || + !node.server_port + ) + throw new RelayError("请选择托管 HY2 或 AnyTLS 节点作为目标。"); + if ( + data.enabled && + db + .prepare( + `SELECT r.id FROM relay_routes r JOIN hy2_nodes n ON n.id=r.node_id WHERE r.host_id=? AND r.enabled=1 AND n.node_type=? AND (r.listen_port=? OR r.public_port=?) AND r.id<>?`, + ) + .get( + data.host_id, + node.node_type, + data.listen_port, + data.public_port, + data.id || "", + ) + ) + throw new RelayError( + "该主机同协议的监听端口或公网端口已被其他线路使用。", + ); + if ( + db + .prepare("SELECT id FROM relay_routes WHERE name=? AND id<>?") + .get(data.name, data.id || "") || + db + .prepare("SELECT id FROM hy2_nodes WHERE name=? OR client_name=?") + .get(data.name, data.name) + ) + throw new RelayError("订阅名称与已有节点或线路重复。"); + if ( + host.server_host.toLowerCase() === node.server_host.toLowerCase() && + [data.public_port, data.listen_port].includes(node.server_port) + ) + throw new RelayError("中转入口不能指向自身。"); + const id = data.id || randomUUID(); + if (data.id) { + if ( + !db + .prepare( + "UPDATE relay_routes SET host_id=?,node_id=?,name=?,public_port=?,listen_port=?,enabled=? WHERE id=?", + ) + .run( + data.host_id, + data.node_id, + data.name, + data.public_port, + data.listen_port, + +data.enabled, + id, + ).changes + ) + throw new RelayError("线路不存在。"); + } else + db.prepare( + "INSERT INTO relay_routes(id,host_id,node_id,name,public_port,listen_port,enabled,created_at) VALUES(?,?,?,?,?,?,?,?)", + ).run( + id, + data.host_id, + data.node_id, + data.name, + data.public_port, + data.listen_port, + +data.enabled, + new Date().toISOString(), + ); + return id; + }) + .immediate(); +} +export async function deleteRelay(kind: "host" | "route", id: string) { + const db = await ensureDb(); + db.prepare( + kind === "host" + ? "DELETE FROM relay_hosts WHERE id=?" + : "DELETE FROM relay_routes WHERE id=?", + ).run(id); +} +const address = (host: string, port: number) => + `${isIP(host) === 6 ? `[${host}]` : host}:${port}`; +export function relayDesired(db: Database.Database, host: RelayHost) { + const rows = db + .prepare( + `SELECT r.*, n.node_type,n.server_host,n.server_port FROM relay_routes r JOIN hy2_nodes n ON n.id=r.node_id WHERE r.host_id=? AND r.enabled=1 AND n.enabled=1 AND n.node_type IN ('hy2','anytls') ORDER BY r.id`, + ) + .all(host.id) as (RelayRoute & + Pick)[]; + const active = host.enabled ? rows : []; + const config = { + log: { level: "warn" }, + endpoints: active.map((r) => ({ + listen: `0.0.0.0:${r.listen_port}`, + remote: address(r.server_host, r.server_port), + network: { + no_tcp: r.node_type === "hy2", + use_udp: r.node_type === "hy2", + udp_timeout: 300, + }, + })), + }; + return { + config, + hash: digest(JSON.stringify(config)), + leaseSeconds: 90, + intervalSeconds: 15, + }; +} +export async function syncRelay(id: string, token: string, input: unknown) { + const db = await ensureDb(); + const host = db.prepare("SELECT * FROM relay_hosts WHERE id=?").get(id) as + RelayHost | undefined; + if ( + !host || + !/^[a-f0-9]{64}$/.test(token) || + !timingSafeEqual(Buffer.from(digest(token)), Buffer.from(host.token_hash)) + ) + throw new RelayError("凭据无效。"); + const data = relayReportSchema.parse(input); + db.prepare( + "UPDATE relay_hosts SET last_seen_at=?,applied_hash=?,service_active=?,error=? WHERE id=?", + ).run( + new Date().toISOString(), + data.appliedHash, + +data.active, + data.error, + id, + ); + return relayDesired(db, host); +} +// These are subscription-only views of the existing destination, never polling nodes. +export function appendRelaySubscriptionNodes( + db: Database.Database, + nodes: Hy2NodeRow[], +) { + const hosts = db + .prepare("SELECT * FROM relay_hosts WHERE enabled=1") + .all() as RelayHost[]; + const routes = db + .prepare("SELECT * FROM relay_routes WHERE enabled=1 ORDER BY created_at") + .all() as RelayRoute[]; + const byId = new Map(nodes.map((n) => [n.id, n])); + const ready = new Map( + hosts + .filter( + (h) => + h.service_active && + !h.error && + h.last_seen_at && + Date.now() - Date.parse(h.last_seen_at) < 90000 && + h.applied_hash === relayDesired(db, h).hash, + ) + .map((h) => [h.id, h]), + ); + return [ + ...nodes, + ...routes.flatMap((r) => { + const host = ready.get(r.host_id), + node = byId.get(r.node_id); + if (!host || !node || !["hy2", "anytls"].includes(node.node_type)) + return []; + return [ + { + ...node, + slug: `relay/${r.id}`, + name: r.name, + client_name: r.name, + server_host: host.server_host, + server_port: r.public_port, + sni: node.sni || node.server_host, + }, + ]; + }), + ]; +} diff --git a/lib/store.ts b/lib/store.ts index 760f3f2..6140161 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -1,3 +1,4 @@ +import { appendRelaySubscriptionNodes } from "@/lib/relays"; import { parseCustomProxyYaml, extractProxyNameFromYaml, @@ -1249,7 +1250,7 @@ export async function saveMainProxyGroupMemberSlugs(memberSlugs: string[]) { export async function listSubscriptionNodes() { const db = await ensureDb(); - return db + const nodes = db .prepare( ` SELECT * @@ -1259,6 +1260,7 @@ export async function listSubscriptionNodes() { `, ) .all() as Hy2NodeRow[]; + return appendRelaySubscriptionNodes(db, nodes); } export async function writeAuthAudit(input: AuditRecordInput) { diff --git a/lib/templates/install-relay.sh b/lib/templates/install-relay.sh new file mode 100644 index 0000000..0eb759a --- /dev/null +++ b/lib/templates/install-relay.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@" +set -Eeuo pipefail +umask 077 +ID=__ID__ +ROOT=/etc/hy2-panel-relay +[ "$(id -u)" -eq 0 ] && [ -d /run/systemd/system ] || { echo '需要 root 和 systemd Linux。'; exit 1; } +if [ -d "$ROOT" ] || [ -e /etc/systemd/system/hy2-panel-relay.service ]; then + [ "$(cat "$ROOT/host-id" 2>/dev/null || true)" = "$ID" ] || { echo '已有其他中转安装,停止以避免覆盖。'; exit 1; } +fi +case "$(uname -m)" in + x86_64) ARCH=x86_64; SHA=b1cc335547bea8bb2a88178bef12ec7f2363e36200e7ea1d4e1e67627929bf65;; + aarch64|arm64) ARCH=aarch64; SHA=f4c0318dd86854da483dcb7645b4f39cae2cc3f91c688fef969d53220b949488;; + *) echo '支持 x86_64 / ARM64。'; exit 1;; +esac +if command -v apt-get >/dev/null; then apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates python3 tar +elif command -v dnf >/dev/null; then dnf install -y curl ca-certificates python3 tar +else echo '需要 Debian/Ubuntu 或 dnf 发行版。'; exit 1; fi +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +curl -fsSL --retry 3 --connect-timeout 20 --max-time 300 "https://github.com/zhboner/realm/releases/download/v2.9.6/realm-$ARCH-unknown-linux-musl.tar.gz" -o "$WORK/realm.tar.gz" +printf '%s %s\n' "$SHA" "$WORK/realm.tar.gz" | sha256sum -c - +tar -xzf "$WORK/realm.tar.gz" -C "$WORK" realm +systemctl stop hy2-panel-relay-agent.service hy2-panel-relay.service 2>/dev/null || true +install -d -m 700 "$ROOT" /usr/local/lib/hy2-panel-relay +printf '%s' "$ID" > "$ROOT/host-id" +printf '%s' __PAYLOAD__ | base64 -d > "$WORK/payload.json" +python3 - "$WORK/payload.json" <<'PY' +import json,sys +from pathlib import Path +p=json.load(open(sys.argv[1])) +Path('/etc/hy2-panel-relay/agent.json').write_text(json.dumps(p['credentials'])) +Path('/usr/local/lib/hy2-panel-relay/agent.py').write_text(p['source']) +PY +install -m 755 "$WORK/realm" /usr/local/lib/hy2-panel-relay/realm +cat > /etc/systemd/system/hy2-panel-relay.service <<'UNIT' +[Unit] +Description=HY2 Panel Realm relay +After=network-online.target +[Service] +ExecStart=/usr/local/lib/hy2-panel-relay/realm -c /etc/hy2-panel-relay/config.json +Restart=on-failure +RestartSec=3 +LimitNOFILE=1048576 +NoNewPrivileges=true +ProtectHome=true +ProtectSystem=strict +PrivateTmp=true +UNIT +cat > /etc/systemd/system/hy2-panel-relay-agent.service <<'UNIT' +[Unit] +Description=HY2 Panel relay management agent +After=network-online.target +Wants=network-online.target +[Service] +ExecStart=/usr/bin/python3 /usr/local/lib/hy2-panel-relay/agent.py +ExecStopPost=/bin/systemctl stop hy2-panel-relay.service +Restart=always +RestartSec=5 +NoNewPrivileges=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/etc/hy2-panel-relay +PrivateTmp=true +[Install] +WantedBy=multi-user.target +UNIT +systemctl daemon-reload +systemctl enable --now hy2-panel-relay-agent.service +echo '中转管理程序已启动,请在面板配置线路并确认状态。无需开放公网管理端口。' diff --git a/lib/templates/relay-agent.py b/lib/templates/relay-agent.py new file mode 100644 index 0000000..7ebb029 --- /dev/null +++ b/lib/templates/relay-agent.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Outbound-only management for a dedicated Realm instance.""" +import fcntl +import json +import os +from pathlib import Path +import socket +import subprocess +import time +import urllib.request +import urllib.error +ROOT = Path('/etc/hy2-panel-relay') +UNIT = 'hy2-panel-relay.service' + + +def core(command): + subprocess.run(['systemctl', command, UNIT], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def pid(): + return int(subprocess.check_output(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value'], text=True).strip()) + + +def active(): + return subprocess.run(['systemctl', 'is-active', '--quiet', UNIT]).returncode == 0 + + +def verify_listeners(config): + process = pid() + if not process: + return False + inodes = set() + for fd in Path('/proc/' + str(process) + '/fd').iterdir(): + try: + link = os.readlink(fd) + if link.startswith('socket:['): + inodes.add(link[8:-1]) + except OSError: + pass + ports = set() + for network in ('tcp', 'udp'): + for line in Path('/proc/net/' + network).read_text().splitlines()[1:]: + fields = line.split() + if fields[9] in inodes and (network != 'tcp' or fields[3] == '0A'): + ports.add((network, int(fields[1].split(':')[1], 16))) + return all(('udp' if endpoint['network']['use_udp'] else 'tcp', int(endpoint['listen'].rsplit(':', 1)[1])) in ports for endpoint in config['endpoints']) + + +def write_config(config): + staged = ROOT / 'next.json' + with open(staged, 'w') as handle: + json.dump(config, handle) + handle.flush() + os.fsync(handle.fileno()) + os.replace(staged, ROOT / 'config.json') + + +def apply(config): + core('stop') + sockets = [] + try: + for endpoint in config['endpoints']: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM if endpoint['network']['use_udp'] else socket.SOCK_STREAM) + sockets.append(sock) + # SO_REUSEADDR allows a stopped TCP listener's TIME_WAIT connections. + if not endpoint['network']['use_udp']: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('0.0.0.0', int(endpoint['listen'].rsplit(':', 1)[1]))) + except OSError: + return 'port_conflict' + finally: + for sock in sockets: + sock.close() + write_config(config) + if config['endpoints']: + core('start') + for _ in range(20): + time.sleep(0.2) + if active() and verify_listeners(config): + return '' + core('stop') + return 'apply_failed' + return '' + + +def run(): + lock = open(ROOT / 'agent.lock', 'w') + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + credentials = json.loads((ROOT / 'agent.json').read_text()) + applied = '' + error = '' + deadline = 0 + core('stop') + while True: + try: + request = urllib.request.Request(credentials['panel'] + '/api/relay/' + credentials['id'] + '/sync', + data=json.dumps({'appliedHash': applied, 'active': active(), 'error': error}).encode(), + headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + credentials['token']}) + with urllib.request.urlopen(request, timeout=10) as response: + desired = json.load(response) + if desired['hash'] != applied or (desired['config']['endpoints'] and not active()): + error = apply(desired['config']) + applied = desired['hash'] if not error else '' + deadline = time.monotonic() + min(90, desired['leaseSeconds']) + print('同步成功' if not error else error, flush=True) + except Exception as exc: + print('同步失败:' + type(exc).__name__, flush=True) + if isinstance(exc, urllib.error.HTTPError) and exc.code in (400, 401, 403, 404): + deadline = 0 + if time.monotonic() >= deadline: + core('stop') + applied = '' + time.sleep(max(1, min(15, deadline - time.monotonic())) if deadline else 5) + + +if __name__ == '__main__': + os.umask(0o077) + try: + run() + finally: + core('stop') diff --git a/tests/e2e-fixtures.ts b/tests/e2e-fixtures.ts index 239a736..217b6bf 100644 --- a/tests/e2e-fixtures.ts +++ b/tests/e2e-fixtures.ts @@ -11,7 +11,7 @@ export const test = base.extend<{ isolatedData: void }>({ db.pragma("foreign_keys = ON"); db.transaction(() => { db.exec( - "DELETE FROM node_installations; DELETE FROM managed_certificates; DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;", + "DELETE FROM relay_hosts; DELETE FROM node_installations; DELETE FROM managed_certificates; DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;", ); db.prepare( "UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')", diff --git a/tests/relays.spec.ts b/tests/relays.spec.ts new file mode 100644 index 0000000..8835e8f --- /dev/null +++ b/tests/relays.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from "./e2e-fixtures"; +import Database from "better-sqlite3"; +import { createHash } from "node:crypto"; +test("managed relay UI, authenticated script, deployment state and subscription membership", async ({ + page, + request, +}) => { + await page.goto("/login"); + await page.getByLabel("用户名", { exact: true }).fill("ui-review"); + await page.getByLabel("密码", { exact: true }).fill("ui-review-local-2026"); + await page.getByRole("button", { name: "登录", exact: true }).click(); + await expect(page).toHaveURL(/dashboard/); + await page.getByRole("link", { name: "节点管理", exact: true }).click(); + const node = page + .locator("details") + .filter({ + has: page.getByRole("heading", { name: "新增 HY2 节点", exact: true }), + }); + await node.locator("summary").click(); + await node.getByLabel("Slug", { exact: true }).fill("jp2"); + await node.getByLabel("显示名", { exact: true }).fill("JP2 direct"); + await node + .getByLabel("客户端地址", { exact: true }) + .fill("jp2.p.xn--876a.net"); + await node.getByRole("button", { name: "创建节点" }).click(); + await expect( + page.getByText("JP2 direct", { exact: true }).first(), + ).toBeVisible(); + await page.getByRole("link", { name: "中转线路", exact: true }).click(); + const host = page + .locator("details") + .filter({ has: page.getByRole("heading", { name: "新增中转主机" }) }); + await host.getByLabel("主机名称").fill("HK2"); + await host.getByLabel("公网域名 / IP").fill("hk2.p.xn--876a.net"); + await host.getByRole("button", { name: "创建主机" }).click(); + await expect(page.getByText("等待安装", { exact: true })).toBeVisible(); + const row = page + .locator("details") + .filter({ has: page.locator("summary strong", { hasText: /^HK2$/ }) }); + await row.locator("summary").click(); + const href = await row + .getByRole("link", { name: "下载安装 SH" }) + .getAttribute("href"); + expect((await request.get(href!, { maxRedirects: 0 })).status()).toBe(307); + const script = await page.request.get(href!); + expect(script.status()).toBe(200); + expect(await script.text()).toContain("realm"); + const create = page + .locator("details") + .filter({ has: page.getByRole("heading", { name: "新增中转线路" }) }); + await create.getByLabel("线路名称").fill("JP2 via HK2"); + await create + .getByLabel("中转主机", { exact: true }) + .selectOption({ label: "HK2" }); + await create + .getByLabel("目标节点") + .selectOption({ label: "JP2 direct · UDP" }); + await create.getByLabel("公网端口").fill("51044"); + await create.getByLabel("本机监听端口").fill("51044"); + await create.getByRole("button", { name: "创建线路" }).click(); + await expect( + page.locator("summary strong", { hasText: "JP2 via HK2" }), + ).toBeVisible(); + const db = new Database(process.env.HY2_E2E_DATABASE!); + const token = "a".repeat(64); + const id = (db.prepare("SELECT id FROM relay_hosts").get() as { id: string }) + .id; + db.prepare("UPDATE relay_hosts SET token_hash=? WHERE id=?").run( + createHash("sha256").update(token).digest("hex"), + id, + ); + db.close(); + expect( + ( + await request.post(`/api/relay/${id}/sync`, { + data: { appliedHash: "", active: false, error: "" }, + }) + ).status(), + ).toBe(403); + expect( + ( + await request.post(`/api/relay/${id}/sync`, { data: "x".repeat(17000) }) + ).status(), + ).toBe(413); + const sync = await request.post(`/api/relay/${id}/sync`, { + headers: { Authorization: `Bearer ${token}` }, + data: { appliedHash: "", active: false, error: "" }, + }); + expect(sync.status()).toBe(200); + const desired = await sync.json(); + expect(desired.config.endpoints[0].network.use_udp).toBe(true); + await request.post(`/api/relay/${id}/sync`, { + headers: { Authorization: `Bearer ${token}` }, + data: { appliedHash: desired.hash, active: true, error: "" }, + }); + await page.reload(); + await expect( + page.getByText("配置已应用", { exact: true }).first(), + ).toBeVisible(); + await page.screenshot({ + path: "test-results/relays-desktop.png", + fullPage: true, + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ + path: "test-results/relays-mobile.png", + fullPage: true, + }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + 1, + ), + ).toBe(true); + await page.getByRole("link", { name: "订阅配置", exact: true }).click(); + await expect( + page.getByRole("button", { name: "拖拽排序 JP2 via HK2" }), + ).toBeVisible(); +}); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index 01c8495..bd44cbf 100644 --- a/tests/unit/migrations.test.ts +++ b/tests/unit/migrations.test.ts @@ -29,6 +29,8 @@ function snapshot(db: Database.Database) { "managed_certificates", "node_installations", "node_agent_counters", + "relay_hosts", + "relay_routes", ].includes(name), ) .map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]), @@ -49,7 +51,7 @@ test("fresh database exactly matches the inspected remote v4 column definitions" const fresh = new Database(":memory:"); const remote = remoteDb(); migrateDatabase(fresh); - assert.equal(fresh.pragma("user_version", { simple: true }), 6); + assert.equal(fresh.pragma("user_version", { simple: true }), 7); for (const { name } of remote .prepare("SELECT name FROM sqlite_master WHERE type='table'") .all() as { name: string }[]) { @@ -102,7 +104,7 @@ for (const existing of [ if (!existing.includes(field)) delete row[field]; } assert.deepEqual(after, before); - assert.equal(db.pragma("user_version", { simple: true }), 6); + assert.equal(db.pragma("user_version", { simple: true }), 7); db.close(); }); } @@ -132,8 +134,34 @@ test("failed migrations roll back all statements and future schemas are not down db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(), [{ name: "hy2_users" }], ); - db.pragma("user_version = 7"); + db.pragma("user_version = 8"); assert.throws(() => migrateDatabase(db), /版本/); + assert.equal(db.pragma("user_version", { simple: true }), 8); + db.close(); +}); + +test("v6 upgrade preserves AnyTLS installations, certificates and counters", () => { + const db = remoteDb(); + seed(db); + migrateDatabase(db); + db.exec( + "DROP TABLE relay_routes; DROP TABLE relay_hosts; PRAGMA user_version=6;", + ); + db.exec(`INSERT INTO managed_certificates VALUES(1,'wildcard','certificate','encrypted-key','2030-01-01','2026-01-01'); + INSERT INTO node_installations(id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at,node_id,protocol) VALUES('install','hash','encrypted',1,'anytls','AnyTLS','jp2.example','jp2.example','https://panel.example','2030-01-01','2026-01-01',17,'anytls'); + INSERT INTO node_agent_counters VALUES(17,'boot:1','original-auth',128,256);`); + const tables = [ + "managed_certificates", + "node_installations", + "node_agent_counters", + ]; + const before = tables.map((t) => db.prepare(`SELECT * FROM ${t}`).all()); + migrateDatabase(db); + migrateDatabase(db); + assert.deepEqual( + tables.map((t) => db.prepare(`SELECT * FROM ${t}`).all()), + before, + ); assert.equal(db.pragma("user_version", { simple: true }), 7); db.close(); }); diff --git a/tests/unit/relays.test.ts b/tests/unit/relays.test.ts new file mode 100644 index 0000000..31eb9a9 --- /dev/null +++ b/tests/unit/relays.test.ts @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { parse } from "yaml"; + +test("relay routes inherit credentials and SNI, isolate polling, reject conflicts and withdraw stale/disabled routes", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "hy2-relay-")); + process.env.DATABASE_PATH = path.join(dir, "db.sqlite"); + const { ensureDb } = await import("../../lib/db"); + const { + createNode, + getNodeBySlug, + listSubscriptionNodes, + listEnabledNodesForPolling, + listUsers, + getSubscriptionSettings, + } = await import("../../lib/store"); + const { + saveRelayHost, + saveRelayRoute, + relayInventory, + syncRelay, + relayDesired, + deleteRelay, + } = await import("../../lib/relays"); + const { decryptText } = await import("../../lib/crypto"); + const { buildSubscriptionConfig } = await import("../../lib/subscription"); + const db = await ensureDb(); + try { + for (const protocol of ["hy2", "anytls"] as const) { + await createNode({ + nodeType: protocol, + slug: protocol, + name: protocol, + authToken: null, + hy2Listen: ":51046", + serverHost: "jp2.p.xn--876a.net", + serverPort: protocol === "hy2" ? 51044 : 51046, + clientName: protocol, + bandwidthUpMbps: 30, + bandwidthDownMbps: 30, + skipCertVerify: false, + sni: null, + trafficStatsUrl: "http://127.0.0.1:19090", + trafficStatsListen: ":19090", + trafficStatsSecret: "test", + enabled: true, + pollIntervalSeconds: 15, + }); + } + const any = (await getNodeBySlug("anytls"))!, + hy2 = (await getNodeBySlug("hy2"))!; + const hostId = await saveRelayHost({ + name: "HK2", + server_host: "hk2.p.xn--876a.net", + enabled: true, + }); + const host = (await relayInventory()).hosts[0], + token = decryptText(host.token_encrypted)!; + const base = { + host_id: hostId, + node_id: any.id, + name: "JP2 via HK2 TCP", + public_port: 52046, + listen_port: 51046, + enabled: true, + }; + const tcpId = await saveRelayRoute(base); + await saveRelayRoute({ + ...base, + node_id: hy2.id, + name: "JP2 via HK2 UDP", + public_port: 52046, + listen_port: 51046, + }); + assert.equal( + (await listSubscriptionNodes()).length, + 2, + "not advertised before application", + ); + await assert.rejects( + () => saveRelayRoute({ ...base, name: "conflict" }), + /端口/, + ); + await assert.rejects( + () => + saveRelayRoute({ + ...base, + public_port: 52047, + listen_port: 51047, + name: "anytls", + }), + /重复/, + ); + await assert.rejects( + () => + syncRelay(hostId, "0".repeat(64), { + appliedHash: "", + active: false, + error: "", + }), + /凭据/, + ); + const desired = await syncRelay(hostId, token, { + appliedHash: "", + active: false, + error: "", + }); + assert.equal(desired.config.endpoints.length, 2); + assert.equal( + desired.config.endpoints.find((e) => e.remote.endsWith(":51044"))?.network + .no_tcp, + true, + ); + assert.equal( + desired.config.endpoints.find((e) => e.remote.endsWith(":51046"))?.network + .use_udp, + false, + ); + assert.ok( + !JSON.stringify(desired).includes(any.auth_token), + "relay receives no backend credentials", + ); + await syncRelay(hostId, token, { + appliedHash: desired.hash, + active: true, + error: "", + }); + const nodes = await listSubscriptionNodes(); + assert.equal(nodes.length, 4); + assert.equal( + (await listEnabledNodesForPolling()).length, + 1, + "only original HY2 is polled", + ); + assert.equal( + (db.prepare("SELECT count(*) n FROM hy2_nodes").get() as { n: number }).n, + 2, + ); + const result = parse( + buildSubscriptionConfig({ + user: (await listUsers())[0], + nodes, + settings: await getSubscriptionSettings(), + }), + ); + const direct = result.proxies.find( + (p: { name: string }) => p.name === "anytls", + ), + relay = result.proxies.find( + (p: { name: string }) => p.name === base.name, + ); + assert.equal(relay.password, direct.password); + assert.equal(relay.sni, "jp2.p.xn--876a.net"); + assert.equal(relay.server, "hk2.p.xn--876a.net"); + assert.equal(relay.port, 52046); + assert.equal(relay.udp, true); + db.prepare("UPDATE relay_hosts SET last_seen_at=? WHERE id=?").run( + "2000-01-01T00:00:00.000Z", + hostId, + ); + assert.equal((await listSubscriptionNodes()).length, 2); + await syncRelay(hostId, token, { + appliedHash: desired.hash, + active: true, + error: "port_conflict", + }); + assert.equal((await listSubscriptionNodes()).length, 2); + await syncRelay(hostId, token, { + appliedHash: desired.hash, + active: true, + error: "", + }); + await saveRelayRoute({ ...base, id: tcpId, enabled: false }); + assert.equal( + (await listSubscriptionNodes()).length, + 2, + "hide old generation until applied", + ); + const next = relayDesired(db, (await relayInventory()).hosts[0]); + assert.equal(next.config.endpoints.length, 1); + await syncRelay(hostId, token, { + appliedHash: next.hash, + active: true, + error: "", + }); + assert.equal((await listSubscriptionNodes()).length, 3); + await saveRelayHost({ + id: hostId, + name: "HK2", + server_host: "hk2.p.xn--876a.net", + enabled: false, + }); + assert.equal( + ( + await syncRelay(hostId, token, { + appliedHash: next.hash, + active: true, + error: "", + }) + ).config.endpoints.length, + 0, + ); + assert.equal((await listSubscriptionNodes()).length, 2); + await deleteRelay("host", hostId); + assert.equal((await relayInventory()).routes.length, 0); + await assert.rejects( + () => + syncRelay(hostId, token, { appliedHash: "", active: false, error: "" }), + /凭据/, + ); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +});