@@ -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();
});