feat: 接入 AnyTLS 节点托管和用户流量同步
This commit is contained in:
@@ -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<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();
|
||||
}
|
||||
Reference in New Issue
Block a user