110 lines
3.5 KiB
TypeScript
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();
|
|
}
|