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