Files
hy2-panel/lib/node-agent.ts
T

110 lines
3.5 KiB
TypeScript

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<string, { tx: number; rx: number }> = {};
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();
}