feat: 接入 AnyTLS 节点托管和用户流量同步

This commit is contained in:
2026-09-29 13:35:44 +08:00
parent 586c2399d1
commit ac5fed695c
24 changed files with 1413 additions and 185 deletions
+68 -10
View File
@@ -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: {