From ac5fed695c94782ba42bd0cdb7aedd34c1f1f238 Mon Sep 17 00:00:00 2001 From: feiezha Date: Tue, 29 Sep 2026 13:35:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20AnyTLS=20=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=89=98=E7=AE=A1=E5=92=8C=E7=94=A8=E6=88=B7=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 ++ app/admin/certificates/actions.ts | 1 + app/admin/certificates/page.tsx | 25 +- app/admin/dashboard/page.tsx | 20 +- app/admin/nodes/actions.ts | 21 ++ app/admin/nodes/page.tsx | 83 +++++- app/api/agent/[slug]/sync/route.ts | 42 +++ app/api/install/sing-box/[arch]/route.ts | 39 +++ lib/anytls.ts | 48 ++++ lib/db-migrations.ts | 11 + lib/install-script.ts | 12 +- lib/node-agent.ts | 109 ++++++++ lib/provisioning.ts | 78 +++++- lib/store.ts | 4 +- lib/subscription.ts | 20 +- lib/templates/anytls-agent.py | 163 ++++++++++++ lib/templates/install-anytls.sh | 134 ++++++++++ lib/templates/sing-box-LICENSE | 17 ++ lib/templates/stats.proto | 55 ++++ scripts/build-sing-box.sh | 17 ++ tests/anytls-runtime.ts | 178 +++++++++++++ tests/provisioning.spec.ts | 323 +++++++++++++---------- tests/unit/anytls.test.ts | 162 ++++++++++++ tests/unit/migrations.test.ts | 14 +- 24 files changed, 1413 insertions(+), 185 deletions(-) create mode 100644 app/api/agent/[slug]/sync/route.ts create mode 100644 app/api/install/sing-box/[arch]/route.ts create mode 100644 lib/anytls.ts create mode 100644 lib/node-agent.ts create mode 100644 lib/templates/anytls-agent.py create mode 100644 lib/templates/install-anytls.sh create mode 100644 lib/templates/sing-box-LICENSE create mode 100644 lib/templates/stats.proto create mode 100755 scripts/build-sing-box.sh create mode 100644 tests/anytls-runtime.ts create mode 100644 tests/unit/anytls.test.ts diff --git a/README.md b/README.md index a538be5..fdfdb6e 100644 --- a/README.md +++ b/README.md @@ -203,3 +203,25 @@ sudo bash install-jp1.sh --domain jp1.p.xn--876a.net \ 重复执行仅允许同一安装记录、同一域名和端口,不覆盖其他已有 HY2 配置。失败后修复问题,在凭据有效期内重试;已启用的节点不因删除安装记录而删除。凭据过期且已有部分安装时,请先检查并清理对应的禁用节点和服务器配置,再生成新的安装记录。 平台 `APP_URL` 必须是服务器能访问的公网 HTTPS 地址(本地测试允许 localhost HTTP)。新迁移使用数据库版本 5,只新增 `managed_certificates` / `node_installations` 表,兼容现有 v4 节点、用户和订阅数据。部署前仍应备份数据库;不要用旧版本程序打开已升级的数据库。 + +## 托管 AnyTLS / sing-box + +「证书与安装」选择 **AnyTLS · TCP / sing-box**,下载脚本后运行: + +```bash +sudo bash install-jp2-anytls.sh --domain jp2 --port 51046 --listen-port 51046 +``` + +只需映射代理 TCP 端口。sing-box 与管理程序使用独立目录和 systemd 单元,不覆盖已有 HY2。管理 API 自动分配本机端口,节点每 15 秒通过 HTTPS 主动向面板同步,无需开放公网管理端口。支持 Debian/Ubuntu、dnf 系发行版的 x86_64 / ARM64。 + +- 每用户派生独立的 AnyTLS 密码,订阅自动生成对应节点。更改用户密码会更新 AnyTLS 凭据。 +- 用户禁用、到期、额度耗尽及节点禁用在下次同步时生效。当前通过重启 sing-box 应用配置,所有已有连接会断开;配额为周期检查,可能产生一个同步周期的超额。 +- 连续无法同步超过 90 秒,或本地已到租约到期时间时停止代理;重新连接面板后自动恢复。管理程序异常退出也会停止代理。 +- 从 V2Ray API 读取累计用户流量,以进程启动标识和累计值去重;待发送报告落盘,网络重试不重复计费。流量为应用层统计,异常掉电/崩溃可能丢失最后一次采样之后的数据,不能当作精确的计费凭证。在线用户数暂不展示,连接数来自 Clash API。 +- 数据库迁移 v6 只增加安装协议字段和累计采样表,原 HY2 / 自定义节点保持兼容。 + +官方 sing-box 默认发行包未启用 `with_v2ray_api`。部署面板前运行 `bash scripts/build-sing-box.sh`,构建**未经修改的** sing-box v1.14.2(固定源码 commit `af6e64c3b69e6132ebaee0e1a3d24e93903f6709`),启用 `with_v2ray_api,with_clash_api,with_utls`。需要 Go 1.25.5 或更新版本。将生成的 `data/artifacts/` 作为部署产物保存到面板同一路径;程序不会把二进制或任何凭据提交到 Git。安装脚本从面板 HTTPS 下载二进制并核对 SHA-256;没有产物时下载端点返回 503。 + +上游源码与许可: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 校验、实际转发、按用户统计及停用后的拒绝连接。 diff --git a/app/admin/certificates/actions.ts b/app/admin/certificates/actions.ts index c1052fd..d6e7168 100644 --- a/app/admin/certificates/actions.ts +++ b/app/admin/certificates/actions.ts @@ -50,6 +50,7 @@ export async function createInstallationAction(data: FormData) { return run(async () => { const input = installationSchema.safeParse({ name: data.get("name"), + protocol: data.get("protocol") || "hy2", slug: data.get("slug"), certificateId: data.get("certificate_id"), }); diff --git a/app/admin/certificates/page.tsx b/app/admin/certificates/page.tsx index 58b92dd..ff687eb 100644 --- a/app/admin/certificates/page.tsx +++ b/app/admin/certificates/page.tsx @@ -124,6 +124,17 @@ export default async function CertificatesPage() { action={createInstallationAction} className="dense-form-grid provision-form" > +

- 脚本适用于使用 systemd 的 Linux(x86_64 / ARM64 / ARMv7),以 root - 执行。运行时输入 jp1 等域名前缀(自动补全 .p.xn--876a.net)、HY2 UDP 和统计 API TCP - 端口;支持 NAT 内外端口不同。域名用于节点地址和 - SNI,请预先解析到服务器公网 IP。证书须覆盖 *.p.xn--876a.net。 + 使用 systemd 的 Linux,以 root 执行。域名可输入 jp1,自动补全 + .p.xn--876a.net。 HY2 输入 UDP 和统计 TCP 端口;AnyTLS(x86_64 / + ARM64)只需代理 TCP 端口,管理接口仅监听本机。 支持 NAT + 内外端口不同。请先配置 DNS,证书须覆盖 *.p.xn--876a.net。

生成安装脚本 @@ -187,8 +198,9 @@ export default async function CertificatesPage() {

下载后执行 sudo bash install-节点Slug.sh。脚本含有效期 24 小时的安装凭据,请妥善保管;安装成功后自动失效。也支持 --domain - jp1.p.xn--876a.net --hy2-port 端口 --stats-port - 端口。重试需使用相同域名和端口。删除记录不删除已注册节点。 + jp1.p.xn--876a.net --hy2-port 端口 --stats-port 端口。AnyTLS 参数为 + --domain jp1 --port 端口,可加 --listen-port + 设置本机端口。重试需使用相同域名和端口。删除记录不删除已注册节点。

{installations.length === 0 ? ( @@ -212,6 +224,7 @@ export default async function CertificatesPage() {
{job.name}

+ {job.protocol === "anytls" ? "AnyTLS" : "HY2"} ·{" "} {job.slug} · {job.server_host || "域名在脚本中设置"} ·{" "} {job.certificate_name}

diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index c53d817..898db0c 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -7,8 +7,9 @@ import { DashboardCharts } from "./dashboard-charts"; function getNodePill(node: { enabled: number; - node_type: "hy2" | "custom"; + node_type: "hy2" | "custom" | "anytls"; last_error_message: string | null; + last_sync_ok_at: string | null; }) { if (!node.enabled) { return { label: "已禁用", className: "status-pill status-pill--off" }; @@ -17,6 +18,13 @@ function getNodePill(node: { if (node.node_type === "custom") return { label: "自定义订阅", className: "status-pill status-pill--ink" }; + if ( + node.node_type === "anytls" && + (!node.last_sync_ok_at || + Date.parse(node.last_sync_ok_at) < new Date().getTime() - 90_000) + ) + return { label: "心跳超时", className: "status-pill status-pill--danger" }; + if (node.last_error_message) { return { label: "同步异常", className: "status-pill status-pill--danger" }; } @@ -70,7 +78,9 @@ export default async function DashboardPage() { node.node_type === "hy2")} + nodeHealth={data.nodeHealth.filter( + (node) => node.node_type !== "custom", + )} trafficTrend={data.trafficTrend} trafficBuckets={data.trafficBuckets} trafficUserOptions={data.trafficUserOptions} @@ -109,9 +119,11 @@ export default async function DashboardPage() {

{node.node_type === "custom" ? "仅订阅 · 不参与轮询" - : `在线 ${node.last_online_users} / 流 ${node.last_stream_count}`} + : node.node_type === "anytls" + ? `AnyTLS · 流 ${node.last_stream_count}` + : `在线 ${node.last_online_users} / 流 ${node.last_stream_count}`}

- {node.node_type === "hy2" && ( + {node.node_type !== "custom" && (

最近成功 {formatLocalDateTime(node.last_sync_ok_at)}

diff --git a/app/admin/nodes/actions.ts b/app/admin/nodes/actions.ts index d32f380..b8b5305 100644 --- a/app/admin/nodes/actions.ts +++ b/app/admin/nodes/actions.ts @@ -6,6 +6,7 @@ import { } from "@/lib/custom-proxy"; import { revalidatePath } from "next/cache"; +import { ensureDb } from "@/lib/db"; import { requireAdminAccess } from "@/lib/session"; import { createNode, @@ -115,3 +116,23 @@ export async function deleteNodeAction(formData: FormData) { revalidatePath("/admin/subscriptions"); revalidatePath("/admin/dashboard"); } + +export async function updateAnyTLSNodeAction(data: FormData) { + await requireAdminAccess(); + const name = String(data.get("name") || "").trim(); + if (!name || name.length > 100) + return { error: "请填写 1–100 字的节点名称。" }; + const db = await ensureDb(); + db.prepare( + "UPDATE hy2_nodes SET name=?,client_name=?,enabled=?,updated_at=? WHERE id=? AND node_type='anytls'", + ).run( + name, + name, + data.get("enabled") === "on" ? 1 : 0, + new Date().toISOString(), + Number(data.get("id")), + ); + revalidatePath("/admin/nodes"); + revalidatePath("/admin/subscriptions"); + revalidatePath("/admin/dashboard"); +} diff --git a/app/admin/nodes/page.tsx b/app/admin/nodes/page.tsx index 6386166..45b11ca 100644 --- a/app/admin/nodes/page.tsx +++ b/app/admin/nodes/page.tsx @@ -17,14 +17,16 @@ import { import { createNodeAction, + updateAnyTLSNodeAction, deleteNodeAction, updateNodeAction, } from "./actions"; function getNodePills(node: { enabled: number; - node_type: "hy2" | "custom"; + node_type: "hy2" | "custom" | "anytls"; last_error_message: string | null; + last_sync_ok_at: string | null; }) { return [ { @@ -33,9 +35,13 @@ function getNodePills(node: { }, node.node_type === "custom" ? { label: "自定义 YAML", className: "status-pill status-pill--ink" } - : node.last_error_message - ? { label: "同步异常", className: "status-pill status-pill--danger" } - : { label: "同步正常", className: "status-pill status-pill--sage" }, + : node.node_type === "anytls" && + (!node.last_sync_ok_at || + Date.parse(node.last_sync_ok_at) < new Date().getTime() - 90_000) + ? { label: "心跳超时", className: "status-pill status-pill--danger" } + : node.last_error_message + ? { label: "同步异常", className: "status-pill status-pill--danger" } + : { label: "同步正常", className: "status-pill status-pill--sage" }, ]; } @@ -48,7 +54,12 @@ export default async function NodesPage() {
节点

节点管理

- 证书托管 / 自动安装节点 + + 证书托管 / 自动安装节点 +
启用 {nodes.filter((node) => node.enabled).length} @@ -216,6 +227,7 @@ export default async function NodesPage() { } items={nodes.map((node) => { const isCustom = node.node_type === "custom"; + const isAnyTLS = node.node_type === "anytls"; const proxyName = getNodeProxyName(node); const authUrl = buildNodeAuthUrl( node.slug, @@ -229,7 +241,7 @@ export default async function NodesPage() { return { id: node.id, - text: `${node.name} ${node.slug} ${node.server_host} ${proxyName} ${isCustom ? "自定义 YAML custom" : "HY2"}`, + text: `${node.name} ${node.slug} ${node.server_host} ${proxyName} ${isCustom ? "自定义 YAML custom" : isAnyTLS ? "AnyTLS sing-box TCP" : "HY2"}`, status: node.enabled ? "enabled" : "disabled", content: (
@@ -250,20 +262,30 @@ export default async function NodesPage() { ? proxyName : `${node.server_host}:${node.server_port}`} - {isCustom ? "自定义订阅节点" : `hy2 ${node.hy2_listen}`} + {isCustom + ? "自定义订阅节点" + : `${isAnyTLS ? "AnyTLS TCP" : "HY2 UDP"} ${node.hy2_listen}`} - {isCustom ? "不轮询" : node.traffic_stats_url} + {isCustom + ? "不轮询" + : isAnyTLS + ? "节点主动上报" + : node.traffic_stats_url} - {isCustom ? "仅订阅" : node.traffic_stats_listen} + {isCustom + ? "仅订阅" + : isAnyTLS + ? "管理接口仅本机" + : node.traffic_stats_listen} {isCustom ? "—" - : `${node.last_online_users} / ${node.last_stream_count}`} + : `${isAnyTLS ? "—" : node.last_online_users} / ${node.last_stream_count}`} {isCustom ? "无流量统计" : "用户 / 流"} @@ -285,6 +307,47 @@ export default async function NodesPage() {
{isCustom ? ( + ) : isAnyTLS ? ( + <> +

+ AnyTLS / sing-box · 每用户独立凭据,约 15 + 秒同步。用户增删、密码、到期或额度变更会更新配置并重启 + sing-box,现有连接会断开。连续失联约 90 + 秒后停止代理。在线用户数暂不提供,流数量来自本机接口。 +

+

+ {!node.last_sync_ok_at || + Date.parse(node.last_sync_ok_at) < + new Date().getTime() - 90_000 + ? "节点未连接或心跳已超时" + : "节点心跳正常"}{" "} + · SNI:{node.sni} +

+ + + + + 保存节点 + + ) : ( <>
diff --git a/app/api/agent/[slug]/sync/route.ts b/app/api/agent/[slug]/sync/route.ts new file mode 100644 index 0000000..0b4f65e --- /dev/null +++ b/app/api/agent/[slug]/sync/route.ts @@ -0,0 +1,42 @@ +import { syncAnyTLSNode } from "@/lib/node-agent"; +import { ProvisionError } from "@/lib/certificates"; +export const runtime = "nodejs"; +export async function POST( + request: Request, + context: { params: Promise<{ slug: string }> }, +) { + const headers = { "Cache-Control": "no-store" }; + try { + const reader = request.body?.getReader(); + if (!reader) throw new ProvisionError("缺少报告。"); + let length = 0; + const chunks: Uint8Array[] = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > 2 * 1024 * 1024) { + await reader.cancel(); + return new Response(null, { status: 413, headers }); + } + chunks.push(value); + } + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + return Response.json( + await syncAnyTLSNode( + (await context.params).slug, + request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "", + payload, + ), + { headers }, + ); + } catch (error) { + return Response.json( + { + error: + error instanceof ProvisionError ? error.message : "节点同步失败。", + }, + { status: error instanceof ProvisionError ? 400 : 500, headers }, + ); + } +} diff --git a/app/api/install/sing-box/[arch]/route.ts b/app/api/install/sing-box/[arch]/route.ts new file mode 100644 index 0000000..90747c4 --- /dev/null +++ b/app/api/install/sing-box/[arch]/route.ts @@ -0,0 +1,39 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { SING_BOX_VERSION } from "@/lib/anytls"; +import { createHash } from "node:crypto"; +export const runtime = "nodejs"; +export async function GET( + request: Request, + context: { params: Promise<{ arch: string }> }, +) { + const { arch } = await context.params; + if (!["amd64", "arm64"].includes(arch)) + return new Response(null, { status: 404 }); + try { + const bytes = await readFile( + path.join( + process.cwd(), + "data/artifacts", + `sing-box-${SING_BOX_VERSION}-linux-${arch}`, + ), + ); + const checksum = createHash("sha256").update(bytes).digest("hex"); + if (new URL(request.url).searchParams.has("sha256")) + return new Response(checksum, { + headers: { "Cache-Control": "no-store" }, + }); + return new Response(bytes, { + headers: { + "Content-Type": "application/octet-stream", + "Cache-Control": "no-store", + "X-Content-SHA256": checksum, + }, + }); + } catch { + return new Response( + "尚未构建节点程序,请运行 scripts/build-sing-box.sh。", + { status: 503 }, + ); + } +} diff --git a/lib/anytls.ts b/lib/anytls.ts new file mode 100644 index 0000000..6c85e38 --- /dev/null +++ b/lib/anytls.ts @@ -0,0 +1,48 @@ +import { createHmac } from "node:crypto"; +import { decryptText } from "@/lib/crypto"; +import { getEnv } from "@/lib/env"; +import type { Hy2UserRow, Hy2NodeRow } from "@/lib/store"; + +export const SING_BOX_VERSION = "1.14.2"; +export function anytlsPassword( + user: Pick, +) { + const secret = decryptText(user.auth_secret_encrypted); + if (!secret) throw new Error("user_secret_unavailable"); + return createHmac("sha256", getEnv().SESSION_SECRET) + .update(`anytls\0${user.auth_id}\0${secret}`) + .digest("base64url"); +} +export function buildSingBoxConfig(node: Hy2NodeRow, users: Hy2UserRow[]) { + return { + log: { level: "warn", timestamp: true }, + inbounds: [ + { + type: "anytls", + tag: "anytls-in", + listen: "::", + listen_port: Number(node.hy2_listen.replace(/^:/, "")), + users: users.map((u) => ({ + name: u.auth_id, + password: anytlsPassword(u), + })), + tls: { + enabled: true, + certificate_path: "/etc/hy2-panel-anytls/server.crt", + key_path: "/etc/hy2-panel-anytls/server.key", + }, + }, + ], + outbounds: [{ type: "direct", tag: "direct" }], + experimental: { + v2ray_api: { + listen: node.traffic_stats_listen, + stats: { enabled: true, users: users.map((u) => u.auth_id) }, + }, + clash_api: { + external_controller: new URL(node.traffic_stats_url).host, + secret: node.traffic_stats_secret, + }, + }, + }; +} diff --git a/lib/db-migrations.ts b/lib/db-migrations.ts index ea37313..171efcd 100644 --- a/lib/db-migrations.ts +++ b/lib/db-migrations.ts @@ -199,6 +199,17 @@ const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [ node_id INTEGER REFERENCES hy2_nodes(id) ON DELETE CASCADE ); `, + ` + ALTER TABLE node_installations ADD COLUMN protocol TEXT NOT NULL DEFAULT 'hy2'; + CREATE TABLE node_agent_counters ( + node_id INTEGER NOT NULL REFERENCES hy2_nodes(id) ON DELETE CASCADE, + epoch TEXT NOT NULL, + auth_id TEXT NOT NULL, + tx INTEGER NOT NULL DEFAULT 0, + rx INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(node_id, epoch, auth_id) + ); + `, ]; export function migrateDatabase(db: Database.Database) { diff --git a/lib/install-script.ts b/lib/install-script.ts index 9b3f3f4..591d660 100644 --- a/lib/install-script.ts +++ b/lib/install-script.ts @@ -1,3 +1,4 @@ +import { SING_BOX_VERSION } from "@/lib/anytls"; import { readFileSync } from "node:fs"; import path from "node:path"; import { decryptText } from "@/lib/crypto"; @@ -11,11 +12,18 @@ export function buildInstallScript(job: Installation) { TOKEN: token, INSTALL_ID: job.id, SLUG: job.slug, + SING_BOX_VERSION, }; return readFileSync( - path.join(process.cwd(), "lib/templates/install-node.sh"), + path.join( + process.cwd(), + "lib", "templates", + job.protocol === "anytls" + ? "install-anytls.sh" + : "install-node.sh", + ), "utf8", - ).replace(/__(PANEL|TOKEN|INSTALL_ID|SLUG)__/g, (_, key) => + ).replace(/__(PANEL|TOKEN|INSTALL_ID|SLUG|SING_BOX_VERSION)__/g, (_, key) => quote(values[key]), ); } diff --git a/lib/node-agent.ts b/lib/node-agent.ts new file mode 100644 index 0000000..1108a1c --- /dev/null +++ b/lib/node-agent.ts @@ -0,0 +1,109 @@ +import { timingSafeEqual } from "node:crypto"; +import { z } from "zod"; +import { ensureDb } from "@/lib/db"; +import { ProvisionError } from "@/lib/certificates"; +import { + applyNodeSync, + getUserLifecycleState, + type Hy2NodeRow, + type Hy2UserRow, +} from "@/lib/store"; +import { buildSingBoxConfig } from "@/lib/anytls"; + +const counter = z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER / 2); +export const reportSchema = z + .object({ + epoch: z.string().regex(/^[a-zA-Z0-9:._-]{1,160}$/), + traffic: z + .record( + z.string().min(1).max(128), + z.object({ tx: counter, rx: counter }).strict(), + ) + .refine((v) => Object.keys(v).length <= 10000), + streamCount: z.number().int().min(0).max(1000000), + }) + .strict(); +export function eligibleAnyTLSUsers(users: Hy2UserRow[]) { + return users.filter( + (u) => + (u.is_admin ? Boolean(u.enabled) : getUserLifecycleState(u).ok) && + u.auth_secret_encrypted, + ); +} +export async function syncAnyTLSNode( + slug: string, + token: string, + input: unknown, +) { + const data = reportSchema.safeParse(input); + if (!data.success) throw new ProvisionError("无效节点报告。"); + const db = await ensureDb(); + return db + .transaction(() => { + const node = db + .prepare("SELECT * FROM hy2_nodes WHERE slug=? AND node_type='anytls'") + .get(slug) as Hy2NodeRow | undefined; + if ( + !/^[a-f0-9]{64}$/.test(token) || + !node || + token.length !== node.auth_token.length || + !timingSafeEqual(Buffer.from(token), Buffer.from(node.auth_token)) + ) + throw new ProvisionError("节点凭据无效。"); + const now = new Date().toISOString(); + const traffic: Record = {}; + const lookup = db.prepare( + "SELECT tx,rx FROM node_agent_counters WHERE node_id=? AND epoch=? AND auth_id=?", + ); + const save = + db.prepare(`INSERT INTO node_agent_counters(node_id,epoch,auth_id,tx,rx) VALUES (?,?,?,?,?) + ON CONFLICT(node_id,epoch,auth_id) DO UPDATE SET tx=MAX(tx,excluded.tx),rx=MAX(rx,excluded.rx)`); + for (const [authId, counters] of Object.entries(data.data.traffic)) { + if (!db.prepare("SELECT 1 FROM hy2_users WHERE auth_id=?").get(authId)) + continue; + const previous = lookup.get(node.id, data.data.epoch, authId) as + | { tx: number; rx: number } + | undefined; + const tx = Math.max(0, counters.tx - (previous?.tx ?? 0)); + const rx = Math.max(0, counters.rx - (previous?.rx ?? 0)); + if (tx || rx) traffic[authId] = { tx, rx }; + save.run(node.id, data.data.epoch, authId, counters.tx, counters.rx); + } + applyNodeSync(db, { + node, + startedAt: node.last_sync_ok_at || now, + finishedAt: now, + traffic, + online: {}, + streamCount: data.data.streamCount, + kickedAuthIds: [], + }); + const users = node.enabled + ? eligibleAnyTLSUsers( + db + .prepare("SELECT * FROM hy2_users ORDER BY id") + .all() as Hy2UserRow[], + ) + : []; + const expires = users + .filter((u) => !u.is_admin && u.expires_at) + .map((u) => Date.parse(u.expires_at!)); + const leaseSeconds = Math.max( + 1, + Math.min( + 90, + ...expires.map((v) => Math.floor((v - Date.now()) / 1000)), + ), + ); + return { + config: buildSingBoxConfig(node, users), + leaseSeconds, + intervalSeconds: 15, + }; + }) + .immediate(); +} diff --git a/lib/provisioning.ts b/lib/provisioning.ts index 6f0b71a..eb8e8d2 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -1,3 +1,7 @@ +import { buildSingBoxConfig } from "@/lib/anytls"; +import { eligibleAnyTLSUsers } from "@/lib/node-agent"; +import { readFileSync } from "node:fs"; +import path from "node:path"; import { randomBytes, randomUUID, createHash } from "node:crypto"; import { z } from "zod"; import { stringify } from "yaml"; @@ -9,7 +13,7 @@ import { validateCertificate, type ManagedCertificate, } from "@/lib/certificates"; -import type { Hy2NodeRow } from "@/lib/store"; +import type { Hy2NodeRow, Hy2UserRow } from "@/lib/store"; export const portSchema = z .object({ @@ -22,13 +26,27 @@ export const portSchema = z statsListenPort: z.number().int().min(1).max(65535), }) .strict(); +export const anytlsPortSchema = z + .object({ + domain: portSchema.shape.domain, + port: portSchema.shape.hy2Port, + listenPort: portSchema.shape.hy2Port, + statsPort: portSchema.shape.hy2Port, + controlPort: portSchema.shape.hy2Port, + }) + .strict() + .refine( + (v) => new Set([v.listenPort, v.statsPort, v.controlPort]).size === 3, + ); export const installationSchema = z.object({ slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/), name: z.string().trim().min(1).max(100), + protocol: z.enum(["hy2", "anytls"]).optional(), certificateId: z.coerce.number().int().positive(), }); export type Installation = { id: string; + protocol?: "hy2" | "anytls"; token_hash: string; token_encrypted: string; certificate_id: number; @@ -91,8 +109,8 @@ export async function createInstallation( ); db.prepare( `INSERT INTO node_installations - (id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + (id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at,protocol) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, ).run( id, hash(token), @@ -105,6 +123,7 @@ export async function createInstallation( origin.origin, new Date(Date.now() + 24 * 3600_000).toISOString(), now, + input.protocol || "hy2", ); }).immediate(); return id; @@ -114,7 +133,7 @@ export async function listInstallations() { const db = await ensureDb(); return db .prepare( - `SELECT id,slug,name,server_host,sni,expires_at,created_at,revoked_at,completed_at,node_id, + `SELECT id,protocol,slug,name,server_host,sni,expires_at,created_at,revoked_at,completed_at,node_id, (SELECT name FROM managed_certificates WHERE id=certificate_id) AS certificate_name FROM node_installations ORDER BY created_at DESC`, ) @@ -182,12 +201,25 @@ export async function provisionNode( } if (job.completed_at) throw new ProvisionError("该凭据已完成安装,不能再次获取私钥。"); - const parsed = portSchema.safeParse(rawPorts); + const parsed = + job.protocol === "anytls" + ? anytlsPortSchema.safeParse(rawPorts) + : portSchema.safeParse(rawPorts); if (!parsed.success) throw new ProvisionError( "域名须为 .p.xn--876a.net 下的单级子域名,端口必须为 1–65535 的整数。", ); - const ports = parsed.data, + const ports = + "listenPort" in parsed.data + ? { + domain: parsed.data.domain, + hy2Port: parsed.data.port, + hy2ListenPort: parsed.data.listenPort, + statsPort: parsed.data.statsPort, + statsListenPort: parsed.data.statsPort, + controlPort: parsed.data.controlPort, + } + : { ...parsed.data, controlPort: 0 }, portsJson = JSON.stringify(ports); if (job.ports_json && job.ports_json !== portsJson) throw new ProvisionError( @@ -209,14 +241,16 @@ export async function provisionNode( .prepare( `INSERT INTO hy2_nodes (slug,name,auth_token,traffic_stats_url,traffic_stats_secret,enabled,created_at,updated_at, - server_host,server_port,client_name,sni,hy2_listen,traffic_stats_listen) - VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?)`, + server_host,server_port,client_name,sni,hy2_listen,traffic_stats_listen,node_type) + VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?,?)`, ) .run( job.slug, job.name, randomBytes(32).toString("hex"), - `http://${host}:${ports.statsPort}`, + job.protocol === "anytls" + ? `http://127.0.0.1:${ports.controlPort}` + : `http://${host}:${ports.statsPort}`, randomBytes(32).toString("hex"), now, now, @@ -225,7 +259,10 @@ export async function provisionNode( job.name, host, `:${ports.hy2ListenPort}`, - `:${ports.statsListenPort}`, + job.protocol === "anytls" + ? `127.0.0.1:${ports.statsPort}` + : `:${ports.statsListenPort}`, + job.protocol || "hy2", ).lastInsertRowid, ); db.prepare( @@ -235,6 +272,27 @@ export async function provisionNode( const node = db .prepare("SELECT * FROM hy2_nodes WHERE id=?") .get(nodeId) as Hy2NodeRow; + if (job.protocol === "anytls") { + const users = eligibleAnyTLSUsers( + db + .prepare("SELECT * FROM hy2_users ORDER BY id") + .all() as Hy2UserRow[], + ); + return { + config: JSON.stringify(buildSingBoxConfig(node, users), null, 2), + certificate: cert.certificate_pem, + privateKey: key, + agent: { panel: job.origin, slug: job.slug, token: node.auth_token }, + agentSource: readFileSync( + path.join(process.cwd(), "lib/templates/anytls-agent.py"), + "utf8", + ), + statsProto: readFileSync( + path.join(process.cwd(), "lib/templates/stats.proto"), + "utf8", + ), + }; + } const config = stringify({ listen: node.hy2_listen, tls: { diff --git a/lib/store.ts b/lib/store.ts index b889470..760f3f2 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -32,7 +32,7 @@ export type Hy2UserRow = { updated_at: string; }; -export type NodeType = "hy2" | "custom"; +export type NodeType = "hy2" | "custom" | "anytls"; export type Hy2NodeRow = { id: number; @@ -141,7 +141,7 @@ export function buildTrafficStatsUrl(serverHost: string, listen: string) { } export function normalizeNodeType(value: string | null | undefined): NodeType { - return value === "custom" ? "custom" : "hy2"; + return value === "custom" ? "custom" : value === "anytls" ? "anytls" : "hy2"; } export function getNodeProxyName(node: { diff --git a/lib/subscription.ts b/lib/subscription.ts index bdfdd9f..b2a23fe 100644 --- a/lib/subscription.ts +++ b/lib/subscription.ts @@ -1,3 +1,4 @@ +import { anytlsPassword } from "@/lib/anytls"; import { renderCustomProxyYaml } from "@/lib/custom-proxy"; import fs from "node:fs"; import path from "node:path"; @@ -52,11 +53,26 @@ function renderHy2Proxy(node: Hy2NodeRow, authString: string) { return lines.join("\n"); } -function renderSubscriptionProxy(node: Hy2NodeRow, authString: string) { +function renderSubscriptionProxy( + node: Hy2NodeRow, + authString: string, + user: Hy2UserRow, +) { if (node.node_type === "custom") { return renderCustomProxyYaml(node.custom_config_yaml); } + if (node.node_type === "anytls") + return [ + `- name: ${JSON.stringify(node.client_name || node.name)}`, + " type: anytls", + ` server: ${JSON.stringify(node.server_host)}`, + ` port: ${node.server_port}`, + ` password: ${JSON.stringify(anytlsPassword(user))}`, + ` sni: ${JSON.stringify(node.sni || node.server_host)}`, + " skip-cert-verify: false", + " udp: true", + ].join("\n"); return renderHy2Proxy(node, authString); } @@ -211,7 +227,7 @@ export function buildSubscriptionConfig(input: { ); const proxyNames = orderedEligibleNodes.map((node) => getNodeProxyName(node)); const proxiesBlock = orderedEligibleNodes - .map((node) => renderSubscriptionProxy(node, authString)) + .map((node) => renderSubscriptionProxy(node, authString, input.user)) .join("\n"); const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate(); diff --git a/lib/templates/anytls-agent.py b/lib/templates/anytls-agent.py new file mode 100644 index 0000000..b4eabeb --- /dev/null +++ b/lib/templates/anytls-agent.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""HY2 Panel AnyTLS agent. Local-only core APIs; HTTPS outbound control plane.""" +import fcntl +import json +import os +from pathlib import Path +import subprocess +import sys +import time +import urllib.request +import urllib.error +import grpc +import stats_pb2 + +ROOT = Path('/etc/hy2-panel-anytls') +STATE = Path('/var/lib/hy2-panel-anytls') +BINARY = '/usr/local/lib/hy2-panel-anytls/sing-box' +UNIT = 'hy2-panel-anytls.service' + + +def atomic_json(file, value): + temporary = file.with_suffix('.tmp') + with open(temporary, 'w') as handle: + os.chmod(temporary, 0o600) + json.dump(value, handle, sort_keys=True) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, file) + + +def core(command): + subprocess.run(['systemctl', command, UNIT], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def read_json(file): + return json.loads(file.read_text()) + + +def epoch(): + pid = subprocess.check_output(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value'], text=True).strip() + if pid == '0': + return None + # PID + boot ID + start ticks survives agent restarts without re-counting. + start = Path('/proc/' + pid + '/stat').read_text().rsplit(')', 1)[1].split()[19] + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + ':' + pid + ':' + start + + +def snapshot(): + current = epoch() + if current is None: + return None + config = read_json(ROOT / 'config.json') + experimental = config['experimental'] + with grpc.insecure_channel(experimental['v2ray_api']['listen']) as channel: + query = channel.unary_unary( + '/v2ray.core.app.stats.command.StatsService/QueryStats', + request_serializer=stats_pb2.QueryStatsRequest.SerializeToString, + response_deserializer=stats_pb2.QueryStatsResponse.FromString) + result = query(stats_pb2.QueryStatsRequest(pattern='user>>>', reset=False), timeout=5) + traffic = {} + for stat in result.stat: + parts = stat.name.split('>>>') + if len(parts) == 4 and parts[0] == 'user' and parts[2] == 'traffic': + value = traffic.setdefault(parts[1], {'tx': 0, 'rx': 0}) + # HY2 ledgers use tx=server->client, rx=client->server. + if parts[3] == 'uplink': + value['rx'] = int(stat.value) + elif parts[3] == 'downlink': + value['tx'] = int(stat.value) + clash = experimental['clash_api'] + request = urllib.request.Request('http://' + clash['external_controller'] + '/connections', + headers={'Authorization': 'Bearer ' + clash['secret']}) + with urllib.request.urlopen(request, timeout=5) as response: + streams = len(json.load(response).get('connections', [])) + if epoch() != current: + raise RuntimeError('core_restarted_during_snapshot') + return {'epoch': current, 'traffic': traffic, 'streamCount': streams} + + +def remember(state): + sample = snapshot() + if sample: + state['pending'][sample['epoch']] = sample + atomic_json(STATE / 'state.json', state) + return sample + + +def sync(credentials, sample): + request = urllib.request.Request(credentials['panel'] + '/api/agent/' + credentials['slug'] + '/sync', + data=json.dumps(sample).encode(), method='POST', + headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + credentials['token']}) + with urllib.request.urlopen(request, timeout=10) as response: + return json.load(response) + + +def apply_config(config, state): + previous = read_json(ROOT / 'config.json') + if previous != config: + # Capture one last cumulative sample before replacing a core process. + remember(state) + staged = ROOT / 'next.json' + atomic_json(staged, config) + checked = subprocess.run([BINARY, 'check', '-c', str(staged)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if checked.returncode: + core('stop') + raise RuntimeError('invalid_core_configuration') + os.replace(staged, ROOT / 'config.json') + core('restart') + else: + core('start') + + +def run(): + STATE.mkdir(mode=0o700, parents=True, exist_ok=True) + lock = open(STATE / 'agent.lock', 'w') + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + credentials = read_json(ROOT / 'agent.json') + state_file = STATE / 'state.json' + state = read_json(state_file) if state_file.exists() else {'pending': {}} + # No cached lease is trusted after an agent restart. + deadline = 0 + while True: + try: + if time.monotonic() >= deadline: + try: + remember(state) + finally: + core('stop') + else: + remember(state) + if len(state['pending']) > 256: + core('stop') + raise RuntimeError('report_backlog_full') + reports = list(state['pending'].items()) + if not reports: + reports = [('heartbeat', {'epoch': 'heartbeat', 'traffic': {}, 'streamCount': 0})] + desired = None + for report_epoch, report in reports: + desired = sync(credentials, report) + state['pending'].pop(report_epoch, None) + atomic_json(state_file, state) + apply_config(desired['config'], state) + deadline = time.monotonic() + min(90, max(1, desired['leaseSeconds'])) + print('同步成功', flush=True) + except Exception as error: + # Do not log credentials, URLs, user lists or config contents. + print('同步失败:' + type(error).__name__, flush=True) + if isinstance(error, urllib.error.HTTPError) and error.code in (400, 401, 403, 404): + deadline = 0 + core('stop') + if time.monotonic() >= deadline: + core('stop') + time.sleep(max(1, min(15, deadline - time.monotonic())) if deadline else 5) + + +if __name__ == '__main__': + os.umask(0o077) + try: + run() + except Exception as error: + core('stop') + print('管理程序停止:' + type(error).__name__, flush=True) + sys.exit(1) diff --git a/lib/templates/install-anytls.sh b/lib/templates/install-anytls.sh new file mode 100644 index 0000000..ee6b2f7 --- /dev/null +++ b/lib/templates/install-anytls.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@" +set -Eeuo pipefail +umask 077 +PANEL=__PANEL__ +TOKEN=__TOKEN__ +INSTALL_ID=__INSTALL_ID__ +SLUG=__SLUG__ +VERSION=__SING_BOX_VERSION__ +echo "安装 sing-box $VERSION(带用户统计模块)" +DOMAIN='' PORT='' LISTEN_PORT='' +while [ "$#" -gt 0 ]; do + [ "$#" -ge 2 ] || { echo "参数缺少值:$1"; exit 1; } + case "$1" in + --domain) DOMAIN=$2;; --port) PORT=$2;; --listen-port) LISTEN_PORT=$2;; + *) echo "未知参数:$1"; exit 1;; + esac + shift 2 +done +[ "$(id -u)" -eq 0 ] && [ -d /run/systemd/system ] || { echo '需要 root 和 systemd Linux。'; exit 1; } +ROOT=/etc/hy2-panel-anytls +if [ -d "$ROOT" ] || [ -e /etc/systemd/system/hy2-panel-anytls.service ]; then + [ "$(cat "$ROOT/install-id" 2>/dev/null || true)" = "$INSTALL_ID" ] || { echo '已有其他 AnyTLS 安装,停止以避免覆盖。'; exit 1; } +fi +[ -n "$DOMAIN" ] || read -r -p '域名前缀(例如 jp2)或完整域名:' DOMAIN 65535 )); then echo '端口须为 1–65535。'; exit 1; fi +done +case "$(uname -m)" in x86_64) ARCH=amd64;; aarch64|arm64) ARCH=arm64;; *) 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 jq ca-certificates python3 python3-venv openssl +elif command -v dnf >/dev/null; then dnf install -y curl jq ca-certificates python3 python3-pip openssl +else echo '当前安装脚本支持 Debian/Ubuntu 或使用 dnf 的发行版。'; exit 1; fi +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +printf 'Authorization: Bearer %s\n' "$TOKEN" > "$WORK/headers" +# Panel serves a pinned, unmodified upstream build with the V2Ray stats module. +curl -fsSL --retry 3 --connect-timeout 20 --max-time 300 "$PANEL/api/install/sing-box/$ARCH" -o "$WORK/sing-box" +curl -fsSL --retry 3 --max-time 60 "$PANEL/api/install/sing-box/$ARCH?sha256" -o "$WORK/checksum" +printf '%s %s\n' "$(cat "$WORK/checksum")" "$WORK/sing-box" | sha256sum -c - +# Retain local management ports on retries. These are never exposed publicly. +if [ -f "$ROOT/ports.json" ]; then + jq -e --arg domain "$DOMAIN" --argjson port "$PORT" --argjson listen "$LISTEN_PORT" '.domain == $domain and .port == $port and .listenPort == $listen' "$ROOT/ports.json" >/dev/null || { echo '重试安装请使用原域名和端口;变更配置需生成新的安装记录。'; exit 1; } + cp "$ROOT/ports.json" "$WORK/ports.json" +else + python3 - "$PORT" "$LISTEN_PORT" "$DOMAIN" > "$WORK/ports.json" <<'PY' +import json,socket,sys +sockets=[] +for _ in range(2): + s=socket.socket();s.bind(('127.0.0.1',0));sockets.append(s) +print(json.dumps(dict(domain=sys.argv[3],port=int(sys.argv[1]),listenPort=int(sys.argv[2]),statsPort=sockets[0].getsockname()[1],controlPort=sockets[1].getsockname()[1]))) +PY +fi +jq -n --slurpfile ports "$WORK/ports.json" '{action:"prepare",ports:$ports[0]}' > "$WORK/request" +api() { + local status + status=$(curl -sS --connect-timeout 15 --max-time 60 -o "$WORK/response" -w '%{http_code}' -H @"$WORK/headers" -H 'Content-Type: application/json' --data-binary @"$WORK/request" "$PANEL/api/install") + if [ "$status" != 200 ]; then jq -r '.error // "平台请求失败"' "$WORK/response" >&2; return 1; fi +} +api +systemctl stop hy2-panel-anytls-agent.service hy2-panel-anytls.service 2>/dev/null || true +install -d -m 700 "$ROOT" /usr/local/lib/hy2-panel-anytls /var/lib/hy2-panel-anytls +printf '%s' "$INSTALL_ID" > "$ROOT/install-id" +install -m 600 "$WORK/ports.json" "$ROOT/ports.json" +jq -er '.config' "$WORK/response" > "$ROOT/config.json" +jq -er '.certificate' "$WORK/response" > "$ROOT/server.crt" +jq -er '.privateKey' "$WORK/response" > "$ROOT/server.key" +jq -e '.agent' "$WORK/response" > "$ROOT/agent.json" +jq -er '.agentSource' "$WORK/response" > /usr/local/lib/hy2-panel-anytls/agent.py +jq -er '.statsProto' "$WORK/response" > /usr/local/lib/hy2-panel-anytls/stats.proto +install -m 755 "$WORK/sing-box" /usr/local/lib/hy2-panel-anytls/sing-box +/usr/local/lib/hy2-panel-anytls/sing-box check -c "$ROOT/config.json" +python3 -m venv /usr/local/lib/hy2-panel-anytls/venv +/usr/local/lib/hy2-panel-anytls/venv/bin/pip -q install 'grpcio==1.76.0' 'grpcio-tools==1.76.0' +/usr/local/lib/hy2-panel-anytls/venv/bin/python -m grpc_tools.protoc -I /usr/local/lib/hy2-panel-anytls --python_out=/usr/local/lib/hy2-panel-anytls /usr/local/lib/hy2-panel-anytls/stats.proto +cat > /etc/systemd/system/hy2-panel-anytls.service <<'UNIT' +[Unit] +Description=AnyTLS sing-box managed by HY2 Panel +After=network-online.target +[Service] +ExecStart=/usr/local/lib/hy2-panel-anytls/sing-box run -c /etc/hy2-panel-anytls/config.json +Restart=on-failure +RestartSec=3 +LimitNOFILE=1048576 +NoNewPrivileges=true +ProtectHome=true +ProtectSystem=strict +PrivateTmp=true +UNIT +cat > /etc/systemd/system/hy2-panel-anytls-agent.service <<'UNIT' +[Unit] +Description=HY2 Panel AnyTLS sync agent +After=network-online.target +Wants=network-online.target +[Service] +ExecStart=/usr/local/lib/hy2-panel-anytls/venv/bin/python /usr/local/lib/hy2-panel-anytls/agent.py +ExecStopPost=/bin/systemctl stop hy2-panel-anytls.service +Restart=always +RestartSec=5 +NoNewPrivileges=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/etc/hy2-panel-anytls /var/lib/hy2-panel-anytls +PrivateTmp=true +[Install] +WantedBy=multi-user.target +UNIT +systemctl daemon-reload +systemctl restart hy2-panel-anytls.service +CONTROL=$(jq -r '.controlPort' "$ROOT/ports.json") +jq -er '.experimental.clash_api.secret | "Authorization: Bearer " + .' "$ROOT/config.json" > "$WORK/control-header" +READY=0 +for attempt in {1..15}; do + if curl -fsS --max-time 2 -H @"$WORK/control-header" "http://127.0.0.1:$CONTROL/connections" -o /dev/null; then READY=1; break; fi + sleep 1 +done +[ "$READY" -eq 1 ] || { systemctl stop hy2-panel-anytls.service; echo 'sing-box 健康检查失败。'; exit 1; } +printf '{"action":"complete"}' > "$WORK/request" +for attempt in {1..3}; do + if api; then break; fi + [ "$attempt" -lt 3 ] || { systemctl stop hy2-panel-anytls.service; exit 1; } + sleep 2 +done +# Only the agent starts at boot; it requires a valid panel lease before starting the core. +systemctl enable --now hy2-panel-anytls-agent.service +echo "AnyTLS 节点 $SLUG 已安装。TCP $PORT → $LISTEN_PORT;管理端口仅监听本机。" +echo '用户变更约 15 秒同步;应用变更会重启 sing-box 并断开现有连接。' diff --git a/lib/templates/sing-box-LICENSE b/lib/templates/sing-box-LICENSE new file mode 100644 index 0000000..175f350 --- /dev/null +++ b/lib/templates/sing-box-LICENSE @@ -0,0 +1,17 @@ +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. diff --git a/lib/templates/stats.proto b/lib/templates/stats.proto new file mode 100644 index 0000000..d45c3c7 --- /dev/null +++ b/lib/templates/stats.proto @@ -0,0 +1,55 @@ +// Source: SagerNet/sing-box v1.14.2, commit af6e64c3b69e6132ebaee0e1a3d24e93903f6709 +// Copyright (C) 2022 nekohasekai. GPL-3.0-or-later; see sing-box-LICENSE. +syntax = "proto3"; + +package experimental.v2rayapi; +option go_package = "github.com/sagernet/sing-box/experimental/v2rayapi"; + +message GetStatsRequest { + // Name of the stat counter. + string name = 1; + // Whether or not to reset the counter to fetching its value. + bool reset = 2; +} + +message Stat { + string name = 1; + int64 value = 2; +} + +message GetStatsResponse { + Stat stat = 1; +} + +message QueryStatsRequest { + // Deprecated, use Patterns instead + string pattern = 1; + bool reset = 2; + repeated string patterns = 3; + bool regexp = 4; +} + +message QueryStatsResponse { + repeated Stat stat = 1; +} + +message SysStatsRequest {} + +message SysStatsResponse { + uint32 NumGoroutine = 1; + uint32 NumGC = 2; + uint64 Alloc = 3; + uint64 TotalAlloc = 4; + uint64 Sys = 5; + uint64 Mallocs = 6; + uint64 Frees = 7; + uint64 LiveObjects = 8; + uint64 PauseTotalNs = 9; + uint32 Uptime = 10; +} + +service StatsService { + rpc GetStats(GetStatsRequest) returns (GetStatsResponse) {} + rpc QueryStats(QueryStatsRequest) returns (QueryStatsResponse) {} + rpc GetSysStats(SysStatsRequest) returns (SysStatsResponse) {} +} \ No newline at end of file diff --git a/scripts/build-sing-box.sh b/scripts/build-sing-box.sh new file mode 100755 index 0000000..419087b --- /dev/null +++ b/scripts/build-sing-box.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +# Unmodified upstream, pinned source; custom tags enable per-user statistics. +SOURCE_COMMIT=af6e64c3b69e6132ebaee0e1a3d24e93903f6709 +DEST=$(pwd)/data/artifacts +mkdir -p "$DEST" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +git clone --depth 1 --branch v1.14.2 https://github.com/SagerNet/sing-box.git "$WORK/source" +[ "$(git -C "$WORK/source" rev-parse HEAD)" = "$SOURCE_COMMIT" ] +for arch in amd64 arm64; do + (cd "$WORK/source" && CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -trimpath -tags with_v2ray_api,with_clash_api,with_utls \ + -ldflags '-s -w -X github.com/sagernet/sing-box/constant.Version=1.14.2' \ + -o "$DEST/sing-box-1.14.2-linux-$arch" ./cmd/sing-box) +done +cp "$WORK/source/LICENSE" "$DEST/sing-box-LICENSE" +(cd "$DEST" && shasum -a 256 sing-box-1.14.2-linux-* > SHA256SUMS) diff --git a/tests/anytls-runtime.ts b/tests/anytls-runtime.ts new file mode 100644 index 0000000..db7be9e --- /dev/null +++ b/tests/anytls-runtime.ts @@ -0,0 +1,178 @@ +// Optional integration check: SING_BOX_TEST_BINARY=/path/to/build npx tsx tests/anytls-runtime.ts +import assert from "node:assert/strict"; +import { spawn, execFileSync, type ChildProcess } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { createServer } from "node:http"; +import { certificateFixture } from "./certificate-fixture"; + +async function main() { + const binary = process.env.SING_BOX_TEST_BINARY; + const python = process.env.AGENT_TEST_PYTHON; + if (!binary || !python) + throw new Error("Set SING_BOX_TEST_BINARY and AGENT_TEST_PYTHON"); + const dir = mkdtempSync(path.join(tmpdir(), "hy2-anytls-runtime-")); + process.env.DATABASE_PATH = path.join(dir, "db.sqlite"); + process.env.APP_URL = "https://panel.example.com"; + const { saveCertificate } = await import("../lib/certificates"); + const { createInstallation, installationForDownload, provisionNode } = + await import("../lib/provisioning"); + const { decryptText } = await import("../lib/crypto"); + const { syncAnyTLSNode } = await import("../lib/node-agent"); + const { ensureDb } = await import("../lib/db"); + const fixture = certificateFixture(); + const certId = Number( + await saveCertificate("Runtime", fixture.certificate, fixture.privateKey), + ); + const id = await createInstallation({ + slug: "runtime", + name: "Runtime", + certificateId: certId, + protocol: "anytls", + }); + const token = decryptText( + (await installationForDownload(id)).token_encrypted, + )!; + const bundle = await provisionNode(token, "prepare", { + domain: "runtime.p.xn--876a.net", + port: 39194, + listenPort: 39194, + statsPort: 39196, + controlPort: 39197, + }); + assert.ok(bundle.agent && bundle.config && bundle.statsProto); + writeFileSync(path.join(dir, "server.crt"), fixture.certificate); + writeFileSync(path.join(dir, "server.key"), fixture.privateKey, { + mode: 0o600, + }); + const config = JSON.parse( + bundle.config.replaceAll("/etc/hy2-panel-anytls", dir), + ); + const authId = config.inbounds[0].users[0].name; + writeFileSync(path.join(dir, "server.json"), JSON.stringify(config), { + mode: 0o600, + }); + writeFileSync( + path.join(dir, "client.json"), + JSON.stringify({ + log: { level: "error" }, + inbounds: [{ type: "mixed", listen: "127.0.0.1", listen_port: 39193 }], + outbounds: [ + { + type: "anytls", + server: "127.0.0.1", + server_port: 39194, + password: config.inbounds[0].users[0].password, + tls: { + enabled: true, + server_name: "runtime.p.xn--876a.net", + certificate_path: path.join(dir, "server.crt"), + }, + }, + ], + }), + { mode: 0o600 }, + ); + const target = createServer((_req, res) => res.end("A".repeat(65536))); + await new Promise((resolve) => + target.listen(39195, "127.0.0.1", resolve), + ); + const start = (file: string) => + spawn(binary, ["run", "-c", path.join(dir, file)], { stdio: "ignore" }); + let server: ChildProcess | undefined, client: ChildProcess | undefined; + const stop = async (p?: ChildProcess) => { + if (!p || p.exitCode !== null) return; + p.kill(); + await new Promise((resolve) => p.once("exit", () => resolve())); + }; + const transfer = () => + new Promise((resolve) => { + const p = spawn( + "curl", + [ + "--noproxy", + "", + "-fsS", + "--max-time", + "4", + "--socks5-hostname", + "127.0.0.1:39193", + "http://127.0.0.1:39195", + "-o", + "/dev/null", + ], + { stdio: "ignore" }, + ); + p.once("exit", (code) => resolve(code ?? 1)); + }); + try { + execFileSync(binary, ["check", "-c", path.join(dir, "server.json")], { + stdio: "ignore", + }); + server = start("server.json"); + client = start("client.json"); + await new Promise((r) => setTimeout(r, 800)); + assert.equal(await transfer(), 0, "real AnyTLS transfer"); + writeFileSync(path.join(dir, "stats.proto"), bundle.statsProto); + execFileSync(python, [ + "-m", + "grpc_tools.protoc", + "-I", + dir, + `--python_out=${dir}`, + path.join(dir, "stats.proto"), + ]); + writeFileSync( + path.join(dir, "query.py"), + `import grpc,json,stats_pb2\nwith grpc.insecure_channel('127.0.0.1:39196') as c:\n q=c.unary_unary('/v2ray.core.app.stats.command.StatsService/QueryStats',request_serializer=stats_pb2.QueryStatsRequest.SerializeToString,response_deserializer=stats_pb2.QueryStatsResponse.FromString)\n print(json.dumps({s.name:s.value for s in q(stats_pb2.QueryStatsRequest(pattern='user>>>'),timeout=5).stat}))\n`, + ); + const counters = JSON.parse( + execFileSync(python, [path.join(dir, "query.py")], { encoding: "utf8" }), + ); + const tx = counters[`user>>>${authId}>>>traffic>>>downlink`], + rx = counters[`user>>>${authId}>>>traffic>>>uplink`]; + assert.ok(tx >= 65536 && rx > 0); + await provisionNode(token, "complete"); + await syncAnyTLSNode("runtime", bundle.agent.token, { + epoch: "native:1", + traffic: { [authId]: { tx, rx } }, + streamCount: 0, + }); + const db = await ensureDb(); + db.prepare("UPDATE hy2_users SET enabled=0 WHERE auth_id=?").run(authId); + const disabled = await syncAnyTLSNode("runtime", bundle.agent.token, { + epoch: "native:1", + traffic: { [authId]: { tx, rx } }, + streamCount: 0, + }); + await stop(server); + writeFileSync( + path.join(dir, "server.json"), + JSON.stringify(disabled.config).replaceAll("/etc/hy2-panel-anytls", dir), + { mode: 0o600 }, + ); + server = start("server.json"); + await new Promise((r) => setTimeout(r, 500)); + assert.notEqual(await transfer(), 0, "disabled credentials must fail"); + console.log( + JSON.stringify({ + anytlsTransfer: true, + tlsVerified: true, + perUserTx: tx, + perUserRx: rx, + disabledUserRejected: true, + }), + ); + } finally { + await stop(client); + await stop(server); + await new Promise((resolve) => target.close(() => resolve())); + (await ensureDb()).close(); + rmSync(dir, { recursive: true, force: true }); + } +} +main().catch((e) => { + console.error(e.message); + process.exitCode = 1; +}); diff --git a/tests/provisioning.spec.ts b/tests/provisioning.spec.ts index 51db022..9a824ef 100644 --- a/tests/provisioning.spec.ts +++ b/tests/provisioning.spec.ts @@ -3,148 +3,183 @@ import { certificateFixture } from "./certificate-fixture"; import { readFileSync } from "node:fs"; import { parse } from "yaml"; -test("upload wildcard certificate, download script and complete NAT node registration", async ({ - page, - request, -}) => { - const errors: string[] = []; - page.on("pageerror", (e) => errors.push(e.message)); - const fixture = certificateFixture(); - 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 upload = page.locator("details").filter({ - has: page.getByRole("heading", { name: "上传证书", exact: true }), +for (const protocol of ["hy2", "anytls"] as const) + test(`upload wildcard certificate and register ${protocol} NAT node`, async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + const fixture = certificateFixture(); + 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 upload = page.locator("details").filter({ + has: page.getByRole("heading", { name: "上传证书", exact: true }), + }); + await upload.getByLabel("证书名称").fill("测试通配符证书"); + await upload.getByLabel("CRT / 完整证书链").setInputFiles({ + name: "test.crt", + mimeType: "text/plain", + buffer: Buffer.from(fixture.certificate), + }); + await upload.getByLabel("KEY / 私钥").setInputFiles({ + name: "test.key", + mimeType: "text/plain", + buffer: Buffer.from("invalid"), + }); + await upload.getByRole("button", { name: "上传证书", exact: true }).click(); + await expect(upload.getByRole("alert")).toContainText("无法解析"); + await upload.getByLabel("KEY / 私钥").setInputFiles({ + name: "test.key", + mimeType: "text/plain", + buffer: Buffer.from(fixture.privateKey), + }); + await upload.getByRole("button", { name: "上传证书", exact: true }).click(); + await expect( + page.getByText("测试通配符证书", { exact: true }).first(), + ).toBeVisible(); + await page.reload(); + const create = page.locator("details").filter({ + has: page.getByRole("heading", { + name: "生成服务器安装脚本", + exact: true, + }), + }); + if ((await create.getAttribute("open")) === null) + await create.locator("summary").click(); + await create.getByLabel("节点名称", { exact: true }).fill("NAT 浏览器测试"); + await create.getByLabel("节点 Slug", { exact: true }).fill("nat-browser"); + await create.getByLabel("代理协议", { exact: true }).selectOption(protocol); + await create + .getByLabel("托管证书", { exact: true }) + .selectOption({ label: "测试通配符证书" }); + await create + .getByRole("button", { name: "生成安装脚本", exact: true }) + .click(); + const row = page + .locator(".provision-row") + .filter({ hasText: "nat-browser" }); + await expect(row).toContainText("等待安装"); + const downloadPath = await row + .getByRole("link", { name: "下载 SH" }) + .getAttribute("href"); + expect( + (await request.get(downloadPath!, { maxRedirects: 0 })).status(), + ).toBe(307); + const [download] = await Promise.all([ + page.waitForEvent("download"), + row.getByRole("link", { name: "下载 SH" }).click(), + ]); + const script = readFileSync((await download.path())!, "utf8"); + expect(script).not.toContain("PRIVATE KEY"); + expect(script).toContain("--domain"); + const token = script.match(/^TOKEN='([a-f0-9]+)'$/m)![1]; + const ports = + protocol === "anytls" + ? { + domain: "nat.p.xn--876a.net", + port: 23456, + listenPort: 23456, + statsPort: 19090, + controlPort: 19091, + } + : { + domain: "nat.p.xn--876a.net", + hy2Port: 23456, + statsPort: 23457, + hy2ListenPort: 443, + statsListenPort: 9999, + }; + expect( + ( + await request.post("/api/install", { + data: { action: "prepare", ports }, + }) + ).status(), + ).toBe(400); + expect( + (await request.post("/api/install", { data: "x".repeat(5000) })).status(), + ).toBe(413); + const prepare = await request.post("/api/install", { + headers: { Authorization: `Bearer ${token}` }, + data: { action: "prepare", ports }, + }); + expect(prepare.status()).toBe(200); + expect(prepare.headers()["cache-control"]).toBe("no-store"); + const bundle = await prepare.json(); + expect(bundle.privateKey).toBe(fixture.privateKey); + const config = parse(bundle.config); + if (protocol === "hy2") { + expect(config.listen).toBe(":443"); + expect(config.trafficStats.listen).toBe(":9999"); + } else { + expect(config.inbounds[0].type).toBe("anytls"); + expect(config.experimental.v2ray_api.listen).toBe("127.0.0.1:19090"); + } + expect( + ( + await request.post("/api/install", { + headers: { Authorization: `Bearer ${token}` }, + data: { action: "complete" }, + }) + ).status(), + ).toBe(200); + await page.reload(); + await expect(row).toContainText("已安装"); + await expect(row.getByRole("link", { name: "下载 SH" })).toHaveCount(0); + expect( + ( + await request.post("/api/install", { + headers: { Authorization: `Bearer ${token}` }, + data: { action: "prepare", ports }, + }) + ).status(), + ).toBe(400); + await page.screenshot({ + path: "test-results/certificates-desktop.png", + fullPage: true, + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ + path: "test-results/certificates-mobile.png", + fullPage: true, + }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + 1, + ), + ).toBe(true); + await page.getByRole("link", { name: "节点管理", exact: true }).click(); + const node = page + .locator(".entity-table__item") + .filter({ hasText: "nat-browser" }); + await expect(node).toContainText("nat.p.xn--876a.net"); + await node.locator(":scope > summary").click(); + if (protocol === "hy2") { + await expect(node.getByLabel("对外端口", { exact: true })).toHaveValue( + "23456", + ); + await expect(node.getByLabel("SNI", { exact: true })).toHaveValue( + "nat.p.xn--876a.net", + ); + } else { + await expect(node).toContainText("节点主动上报"); + await expect(node.getByLabel("节点名称 / 订阅名")).toHaveValue( + "NAT 浏览器测试", + ); + const sync = await request.post("/api/agent/nat-browser/sync", { + headers: { Authorization: `Bearer ${bundle.agent.token}` }, + data: { epoch: "test:1", traffic: {}, streamCount: 1 }, + }); + expect(sync.status()).toBe(200); + expect( + (await sync.json()).config.inbounds[0].users.length, + ).toBeGreaterThan(0); + } + await expect(node.getByLabel("启用节点", { exact: true })).toBeChecked(); + expect(errors).toEqual([]); }); - await upload.getByLabel("证书名称").fill("测试通配符证书"); - await upload.getByLabel("CRT / 完整证书链").setInputFiles({ - name: "test.crt", - mimeType: "text/plain", - buffer: Buffer.from(fixture.certificate), - }); - await upload.getByLabel("KEY / 私钥").setInputFiles({ - name: "test.key", - mimeType: "text/plain", - buffer: Buffer.from("invalid"), - }); - await upload.getByRole("button", { name: "上传证书", exact: true }).click(); - await expect(upload.getByRole("alert")).toContainText("无法解析"); - await upload.getByLabel("KEY / 私钥").setInputFiles({ - name: "test.key", - mimeType: "text/plain", - buffer: Buffer.from(fixture.privateKey), - }); - await upload.getByRole("button", { name: "上传证书", exact: true }).click(); - await expect( - page.getByText("测试通配符证书", { exact: true }).first(), - ).toBeVisible(); - await page.reload(); - const create = page.locator("details").filter({ - has: page.getByRole("heading", { - name: "生成服务器安装脚本", - exact: true, - }), - }); - if ((await create.getAttribute("open")) === null) - await create.locator("summary").click(); - await create.getByLabel("节点名称", { exact: true }).fill("NAT 浏览器测试"); - await create.getByLabel("节点 Slug", { exact: true }).fill("nat-browser"); - await create - .getByLabel("托管证书", { exact: true }) - .selectOption({ label: "测试通配符证书" }); - await create - .getByRole("button", { name: "生成安装脚本", exact: true }) - .click(); - const row = page.locator(".provision-row").filter({ hasText: "nat-browser" }); - await expect(row).toContainText("等待安装"); - const downloadPath = await row - .getByRole("link", { name: "下载 SH" }) - .getAttribute("href"); - expect((await request.get(downloadPath!, { maxRedirects: 0 })).status()).toBe( - 307, - ); - const [download] = await Promise.all([ - page.waitForEvent("download"), - row.getByRole("link", { name: "下载 SH" }).click(), - ]); - const script = readFileSync((await download.path())!, "utf8"); - expect(script).not.toContain("PRIVATE KEY"); - expect(script).toContain("--domain"); - const token = script.match(/^TOKEN='([a-f0-9]+)'$/m)![1]; - const ports = { - domain: "nat.p.xn--876a.net", - hy2Port: 23456, - statsPort: 23457, - hy2ListenPort: 443, - statsListenPort: 9999, - }; - expect( - ( - await request.post("/api/install", { data: { action: "prepare", ports } }) - ).status(), - ).toBe(400); - expect( - (await request.post("/api/install", { data: "x".repeat(5000) })).status(), - ).toBe(413); - const prepare = await request.post("/api/install", { - headers: { Authorization: `Bearer ${token}` }, - data: { action: "prepare", ports }, - }); - expect(prepare.status()).toBe(200); - expect(prepare.headers()["cache-control"]).toBe("no-store"); - const bundle = await prepare.json(); - expect(bundle.privateKey).toBe(fixture.privateKey); - const config = parse(bundle.config); - expect(config.listen).toBe(":443"); - expect(config.trafficStats.listen).toBe(":9999"); - expect( - ( - await request.post("/api/install", { - headers: { Authorization: `Bearer ${token}` }, - data: { action: "complete" }, - }) - ).status(), - ).toBe(200); - await page.reload(); - await expect(row).toContainText("已安装"); - await expect(row.getByRole("link", { name: "下载 SH" })).toHaveCount(0); - expect( - ( - await request.post("/api/install", { - headers: { Authorization: `Bearer ${token}` }, - data: { action: "prepare", ports }, - }) - ).status(), - ).toBe(400); - await page.screenshot({ - path: "test-results/certificates-desktop.png", - fullPage: true, - }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.screenshot({ - path: "test-results/certificates-mobile.png", - fullPage: true, - }); - expect( - await page.evaluate( - () => document.documentElement.scrollWidth <= innerWidth + 1, - ), - ).toBe(true); - await page.getByRole("link", { name: "节点管理", exact: true }).click(); - const node = page - .locator(".entity-table__item") - .filter({ hasText: "nat-browser" }); - await expect(node).toContainText("nat.p.xn--876a.net"); - await node.locator(":scope > summary").click(); - await expect(node.getByLabel("对外端口", { exact: true })).toHaveValue( - "23456", - ); - await expect(node.getByLabel("SNI", { exact: true })).toHaveValue( - "nat.p.xn--876a.net", - ); - await expect(node.getByLabel("启用节点", { exact: true })).toBeChecked(); - expect(errors).toEqual([]); -}); diff --git a/tests/unit/anytls.test.ts b/tests/unit/anytls.test.ts new file mode 100644 index 0000000..9b8779a --- /dev/null +++ b/tests/unit/anytls.test.ts @@ -0,0 +1,162 @@ +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 { certificateFixture } from "../certificate-fixture"; +import { parse } from "yaml"; + +test("AnyTLS provisioning, per-user credentials, cumulative accounting and revocation", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "hy2-anytls-")); + process.env.DATABASE_PATH = path.join(dir, "db.sqlite"); + process.env.APP_URL = "https://panel.example.com"; + const { ensureDb } = await import("../../lib/db"); + const { encryptText, decryptText } = await import("../../lib/crypto"); + const { saveCertificate } = await import("../../lib/certificates"); + const { createInstallation, installationForDownload, provisionNode } = + await import("../../lib/provisioning"); + const { syncAnyTLSNode } = await import("../../lib/node-agent"); + const { anytlsPassword } = await import("../../lib/anytls"); + const { buildInstallScript } = await import("../../lib/install-script"); + const { + getNodeBySlug, + listUsers, + getSubscriptionSettings, + listEnabledNodesForPolling, + } = await import("../../lib/store"); + const { buildSubscriptionConfig } = await import("../../lib/subscription"); + const db = await ensureDb(); + try { + const fixture = certificateFixture(); + const certificateId = Number( + await saveCertificate( + "Wildcard", + fixture.certificate, + fixture.privateKey, + ), + ); + const id = await createInstallation({ + slug: "anytls-test", + name: "AnyTLS", + certificateId, + protocol: "anytls", + }); + const job = await installationForDownload(id); + assert.equal(job.protocol, "anytls"); + assert.match(buildInstallScript(job), /--port/); + const installToken = decryptText(job.token_encrypted)!; + const ports = { + domain: "jp2.p.xn--876a.net", + port: 51046, + listenPort: 51046, + statsPort: 19090, + controlPort: 19091, + }; + await assert.rejects( + () => + provisionNode(installToken, "prepare", { + ...ports, + controlPort: 19090, + }), + /端口/, + ); + const bundle = await provisionNode(installToken, "prepare", ports); + assert.ok(bundle.agent && bundle.config && bundle.agentSource); + assert.equal(bundle.agent.slug, "anytls-test"); + assert.match(bundle.agentSource, /reset=False/); + const initial = JSON.parse(bundle.config); + assert.equal(initial.inbounds[0].type, "anytls"); + assert.equal(initial.inbounds[0].listen_port, 51046); + assert.equal(initial.experimental.v2ray_api.listen, "127.0.0.1:19090"); + const user = (await listUsers())[0]; + const password = anytlsPassword(user); + assert.equal(initial.inbounds[0].users[0].password, password); + assert.notEqual(password, decryptText(user.auth_secret_encrypted)); + await provisionNode(installToken, "complete"); + const node = (await getNodeBySlug(job.slug))!; + assert.equal(node.node_type, "anytls"); + assert.equal((await listEnabledNodesForPolling()).length, 0); + const subscription = parse( + buildSubscriptionConfig({ + user, + nodes: [node], + settings: await getSubscriptionSettings(), + }), + ); + assert.equal(subscription.proxies[0].type, "anytls"); + assert.equal(subscription.proxies[0].password, password); + assert.equal(subscription.proxies[0]["skip-cert-verify"], false); + const report = { + epoch: "boot:123:456", + traffic: { [user.auth_id]: { tx: 1000, rx: 500 } }, + streamCount: 2, + }; + await assert.rejects( + () => syncAnyTLSNode(job.slug, "0".repeat(64), report), + /凭据/, + ); + await assert.rejects( + () => + syncAnyTLSNode(job.slug, bundle.agent.token, { + ...report, + traffic: { [user.auth_id]: { tx: -1, rx: 2 } }, + }), + /无效/, + ); + await syncAnyTLSNode(job.slug, bundle.agent.token, report); + await syncAnyTLSNode(job.slug, bundle.agent.token, report); + let refreshed = (await listUsers())[0]; + assert.equal(refreshed.used_tx_bytes, 1000); + assert.equal(refreshed.used_rx_bytes, 500); + await syncAnyTLSNode(job.slug, bundle.agent.token, { + ...report, + traffic: { [user.auth_id]: { tx: 900, rx: 400 } }, + }); + refreshed = (await listUsers())[0]; + assert.equal(refreshed.used_tx_bytes, 1000); + await syncAnyTLSNode(job.slug, bundle.agent.token, { + ...report, + epoch: "boot:124:500", + traffic: { [user.auth_id]: { tx: 100, rx: 50 } }, + }); + refreshed = (await listUsers())[0]; + assert.equal(refreshed.used_tx_bytes, 1100); + // Make this account an ordinary user to exercise quota/expiry/disable rules. + db.prepare( + "UPDATE hy2_users SET is_admin=0,traffic_limit_bytes=1000 WHERE id=?", + ).run(user.id); + const capped = await syncAnyTLSNode(job.slug, bundle.agent.token, report); + assert.equal(capped.config.inbounds[0].users.length, 0); + db.prepare( + "UPDATE hy2_users SET traffic_limit_bytes=NULL,expires_at='2000-01-01' WHERE id=?", + ).run(user.id); + assert.equal( + (await syncAnyTLSNode(job.slug, bundle.agent.token, report)).config + .inbounds[0].users.length, + 0, + ); + db.prepare("UPDATE hy2_users SET expires_at=NULL,enabled=0 WHERE id=?").run( + user.id, + ); + assert.equal( + (await syncAnyTLSNode(job.slug, bundle.agent.token, report)).config + .inbounds[0].users.length, + 0, + ); + db.prepare( + "UPDATE hy2_users SET enabled=1,auth_secret_encrypted=? WHERE id=?", + ).run(encryptText("new-password"), user.id); + const updated = await syncAnyTLSNode(job.slug, bundle.agent.token, report); + assert.notEqual(updated.config.inbounds[0].users[0].password, password); + db.prepare("UPDATE hy2_nodes SET enabled=0 WHERE id=?").run(node.id); + assert.equal( + (await syncAnyTLSNode(job.slug, bundle.agent.token, report)).config + .inbounds[0].users.length, + 0, + ); + assert.deepEqual(db.pragma("foreign_key_check"), []); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index 9ca5cb3..01c8495 100644 --- a/tests/unit/migrations.test.ts +++ b/tests/unit/migrations.test.ts @@ -25,7 +25,11 @@ function snapshot(db: Database.Database) { ) .filter( ({ name }) => - !["managed_certificates", "node_installations"].includes(name), + ![ + "managed_certificates", + "node_installations", + "node_agent_counters", + ].includes(name), ) .map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]), ); @@ -45,7 +49,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 }), 5); + assert.equal(fresh.pragma("user_version", { simple: true }), 6); for (const { name } of remote .prepare("SELECT name FROM sqlite_master WHERE type='table'") .all() as { name: string }[]) { @@ -98,7 +102,7 @@ for (const existing of [ if (!existing.includes(field)) delete row[field]; } assert.deepEqual(after, before); - assert.equal(db.pragma("user_version", { simple: true }), 5); + assert.equal(db.pragma("user_version", { simple: true }), 6); db.close(); }); } @@ -128,8 +132,8 @@ 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 = 6"); + db.pragma("user_version = 7"); assert.throws(() => migrateDatabase(db), /版本/); - assert.equal(db.pragma("user_version", { simple: true }), 6); + assert.equal(db.pragma("user_version", { simple: true }), 7); db.close(); });