mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: release v3.0.0 risk telemetry
This commit is contained in:
@@ -38,6 +38,11 @@ const settingsSchema = z.object({
|
||||
subscriptionRiskCountrySuspend: z.coerce.number().int().min(2).max(100).optional(),
|
||||
subscriptionRiskIpLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(),
|
||||
subscriptionRiskTokenLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(),
|
||||
nodeAccessRiskEnabled: z.string().optional(),
|
||||
nodeAccessConnectionWarning: z.coerce.number().int().min(1).max(100000).optional(),
|
||||
nodeAccessConnectionSuspend: z.coerce.number().int().min(1).max(100000).optional(),
|
||||
nodeAccessUniqueTargetWarning: z.coerce.number().int().min(1).max(100000).optional(),
|
||||
nodeAccessUniqueTargetSuspend: z.coerce.number().int().min(1).max(100000).optional(),
|
||||
inviteRewardEnabled: z.string().optional(),
|
||||
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
|
||||
inviteRewardCouponId: z.string().trim().optional(),
|
||||
@@ -158,6 +163,18 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
||||
parsed.subscriptionRiskIpLimitPerHour ?? current.subscriptionRiskIpLimitPerHour,
|
||||
subscriptionRiskTokenLimitPerHour:
|
||||
parsed.subscriptionRiskTokenLimitPerHour ?? current.subscriptionRiskTokenLimitPerHour,
|
||||
nodeAccessRiskEnabled: optionalBoolean(
|
||||
parsed.nodeAccessRiskEnabled,
|
||||
current.nodeAccessRiskEnabled,
|
||||
),
|
||||
nodeAccessConnectionWarning:
|
||||
parsed.nodeAccessConnectionWarning ?? current.nodeAccessConnectionWarning,
|
||||
nodeAccessConnectionSuspend:
|
||||
parsed.nodeAccessConnectionSuspend ?? current.nodeAccessConnectionSuspend,
|
||||
nodeAccessUniqueTargetWarning:
|
||||
parsed.nodeAccessUniqueTargetWarning ?? current.nodeAccessUniqueTargetWarning,
|
||||
nodeAccessUniqueTargetSuspend:
|
||||
parsed.nodeAccessUniqueTargetSuspend ?? current.nodeAccessUniqueTargetSuspend,
|
||||
inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled),
|
||||
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
||||
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
||||
@@ -182,6 +199,12 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
||||
if (next.subscriptionRiskCountrySuspend < next.subscriptionRiskCountryWarning) {
|
||||
throw new Error("国家暂停阈值不能小于国家警告阈值");
|
||||
}
|
||||
if (next.nodeAccessConnectionSuspend < next.nodeAccessConnectionWarning) {
|
||||
throw new Error("节点连接暂停阈值不能小于警告阈值");
|
||||
}
|
||||
if (next.nodeAccessUniqueTargetSuspend < next.nodeAccessUniqueTargetWarning) {
|
||||
throw new Error("节点目标数暂停阈值不能小于警告阈值");
|
||||
}
|
||||
|
||||
if (next.smtpEnabled || next.emailVerificationRequired) {
|
||||
if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) {
|
||||
|
||||
@@ -91,7 +91,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title="撤销这个探测 Token?"
|
||||
description="撤销后,延迟和线路探测程序将无法继续上报数据。"
|
||||
description="撤销后,延迟、线路探测和节点日志风控程序将无法继续上报数据。"
|
||||
confirmLabel="撤销 Token"
|
||||
successMessage="探测 Token 已撤销"
|
||||
errorMessage="撤销失败"
|
||||
@@ -170,7 +170,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
此 Agent 仅用于 `/api/agent/latency` 和 `/api/agent/trace` 探测上报;节点客户端开通已改由 3x-ui 面板 API 处理。
|
||||
此 Agent 用于 `/api/agent/latency`、`/api/agent/trace` 探测上报;安装脚本会自动查找 3x-ui/Xray access log,找到后启用节点日志风控。Agent 只读日志,不修改 3x-ui 配置。
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -48,6 +48,11 @@ export default async function AdminSettingsPage() {
|
||||
subscriptionRiskCountrySuspend: config.subscriptionRiskCountrySuspend,
|
||||
subscriptionRiskIpLimitPerHour: config.subscriptionRiskIpLimitPerHour,
|
||||
subscriptionRiskTokenLimitPerHour: config.subscriptionRiskTokenLimitPerHour,
|
||||
nodeAccessRiskEnabled: config.nodeAccessRiskEnabled,
|
||||
nodeAccessConnectionWarning: config.nodeAccessConnectionWarning,
|
||||
nodeAccessConnectionSuspend: config.nodeAccessConnectionSuspend,
|
||||
nodeAccessUniqueTargetWarning: config.nodeAccessUniqueTargetWarning,
|
||||
nodeAccessUniqueTargetSuspend: config.nodeAccessUniqueTargetSuspend,
|
||||
inviteRewardEnabled: config.inviteRewardEnabled,
|
||||
inviteRewardRate: Number(config.inviteRewardRate),
|
||||
inviteRewardCouponId: config.inviteRewardCouponId,
|
||||
|
||||
@@ -37,6 +37,11 @@ interface AppConfig {
|
||||
subscriptionRiskCountrySuspend: number;
|
||||
subscriptionRiskIpLimitPerHour: number;
|
||||
subscriptionRiskTokenLimitPerHour: number;
|
||||
nodeAccessRiskEnabled: boolean;
|
||||
nodeAccessConnectionWarning: number;
|
||||
nodeAccessConnectionSuspend: number;
|
||||
nodeAccessUniqueTargetWarning: number;
|
||||
nodeAccessUniqueTargetSuspend: number;
|
||||
inviteRewardEnabled: boolean;
|
||||
inviteRewardRate: number;
|
||||
inviteRewardCouponId: string | null;
|
||||
@@ -389,9 +394,37 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
defaultValue={config.subscriptionRiskTokenLimitPerHour}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessRiskEnabled">节点日志风控</Label>
|
||||
<select
|
||||
id="nodeAccessRiskEnabled"
|
||||
name="nodeAccessRiskEnabled"
|
||||
defaultValue={String(config.nodeAccessRiskEnabled)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="true">开启,接收 Agent Xray 日志上报</option>
|
||||
<option value="false">关闭,只保留订阅接口风控</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessConnectionWarning">节点连接警告阈值</Label>
|
||||
<Input id="nodeAccessConnectionWarning" name="nodeAccessConnectionWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionWarning} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessConnectionSuspend">节点连接暂停阈值</Label>
|
||||
<Input id="nodeAccessConnectionSuspend" name="nodeAccessConnectionSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionSuspend} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessUniqueTargetWarning">不同目标警告阈值</Label>
|
||||
<Input id="nodeAccessUniqueTargetWarning" name="nodeAccessUniqueTargetWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetWarning} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessUniqueTargetSuspend">不同目标暂停阈值</Label>
|
||||
<Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
默认值对应原规则:24 小时内 4 城市警告、5 城市暂停;2 省/地区警告、3 省/地区暂停;2 国家警告、3 国家暂停;IP 180 次/小时,订阅 60 次/小时。
|
||||
默认值对应原规则:24 小时内 4 城市警告、5 城市暂停;2 省/地区警告、3 省/地区暂停;2 国家警告、3 国家暂停;IP 180 次/小时,订阅 60 次/小时。节点日志风控只在 Agent 配置 XRAY_ACCESS_LOG_PATH 后生效;连接数和不同目标数按 Agent 单次聚合窗口计算。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,7 @@ function WorldRiskMap({ summary }: { summary: SubscriptionRiskGeoSummary }) {
|
||||
viewBox="0 0 360 180"
|
||||
className="h-[15rem] w-full bg-[radial-gradient(circle_at_30%_20%,color-mix(in_oklch,var(--primary)_10%,transparent),transparent_30%),linear-gradient(135deg,var(--muted),var(--card))]"
|
||||
role="img"
|
||||
aria-label="订阅访问 IP 世界地图分布"
|
||||
aria-label="订阅访问与节点连接 IP 世界地图分布"
|
||||
>
|
||||
<rect width="360" height="180" rx="12" fill="transparent" />
|
||||
{[-120, -60, 0, 60, 120].map((longitude) => {
|
||||
@@ -164,7 +164,7 @@ export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionR
|
||||
<details className="group rounded-xl border border-border/70 bg-muted/20">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex items-center gap-2">
|
||||
<MapPin className="size-4 text-primary" /> IP 访问明细
|
||||
<MapPin className="size-4 text-primary" /> IP 访问/连接明细
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
|
||||
@@ -31,6 +31,14 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
|
||||
return "国家异常警告";
|
||||
case "COUNTRY_VARIANCE_SUSPEND":
|
||||
return "国家异常暂停";
|
||||
case "NODE_ACCESS_VOLUME_WARNING":
|
||||
return "节点高频警告";
|
||||
case "NODE_ACCESS_VOLUME_SUSPEND":
|
||||
return "节点高频暂停";
|
||||
case "NODE_ACCESS_TARGET_WARNING":
|
||||
return "目标分散警告";
|
||||
case "NODE_ACCESS_TARGET_SUSPEND":
|
||||
return "目标分散暂停";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +263,7 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无订阅风控事件"
|
||||
description="订阅链接出现跨城市、跨省份或跨国家访问异常后,会在这里进入人工跟进队列。"
|
||||
description="订阅链接或节点真实连接出现跨城市、跨省份或跨国家异常后,会在这里进入人工跟进队列。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getSubscriptionRiskEvents } from "./risk-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "订阅风控",
|
||||
description: "查看订阅访问异常、关联用户和人工处理状态。",
|
||||
description: "查看订阅访问与节点连接异常、关联用户和人工处理状态。",
|
||||
};
|
||||
|
||||
export default async function AdminSubscriptionRiskPage({
|
||||
@@ -22,7 +22,7 @@ export default async function AdminSubscriptionRiskPage({
|
||||
<PageHeader
|
||||
eyebrow="商品与订单"
|
||||
title="订阅风控"
|
||||
description="订阅链接跨城市或跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
|
||||
description="订阅链接或节点真实连接出现跨城市、跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
|
||||
/>
|
||||
|
||||
<AdminFilterBar
|
||||
|
||||
@@ -67,6 +67,14 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
|
||||
return "国家异常警告";
|
||||
case "COUNTRY_VARIANCE_SUSPEND":
|
||||
return "国家异常暂停";
|
||||
case "NODE_ACCESS_VOLUME_WARNING":
|
||||
return "节点高频警告";
|
||||
case "NODE_ACCESS_VOLUME_SUSPEND":
|
||||
return "节点高频暂停";
|
||||
case "NODE_ACCESS_TARGET_WARNING":
|
||||
return "目标分散警告";
|
||||
case "NODE_ACCESS_TARGET_SUSPEND":
|
||||
return "目标分散暂停";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +121,7 @@ export function SubscriptionAccessRiskSection({
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-[-0.02em]">订阅访问风控</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">记录订阅拉取 IP、地区变化和人工处理状态。</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">记录订阅拉取与节点真实连接 IP、地区变化和人工处理状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||
|
||||
@@ -47,7 +47,7 @@ export default async function SupportPage({
|
||||
subject: "订阅风控复核申请",
|
||||
category: "订阅风控",
|
||||
priority: "HIGH" as const,
|
||||
body: "我需要复核订阅风控限制。\n\n请在这里补充:近期访问订阅的设备、所在城市/国家、是否出差或旅行、是否曾分享订阅链接。\n\n系统判定:" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message,
|
||||
body: "我需要复核订阅风控限制。\n\n请在这里补充:近期访问订阅的设备、所在城市/国家、是否出差或旅行、是否曾分享订阅链接或通过其他设备转发节点。\n\n系统判定:" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
||||
148
src/app/api/agent/node-access/route.ts
Normal file
148
src/app/api/agent/node-access/route.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { isIP } from "node:net";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
|
||||
import { getIpGeoContext } from "@/lib/request-context";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
import { evaluateNodeAccessAbuseRisk, recordSubscriptionAccess } from "@/services/subscription-risk";
|
||||
|
||||
const MAX_EVENTS = 500;
|
||||
const MAX_TEXT_LENGTH = 200;
|
||||
|
||||
const nodeAccessEventSchema = z.object({
|
||||
clientEmail: z.string().trim().min(1).max(320),
|
||||
sourceIp: z.string().trim().refine((value) => isIP(value) !== 0, "sourceIp 必须是有效 IP"),
|
||||
inboundTag: z.string().trim().max(MAX_TEXT_LENGTH).optional().nullable(),
|
||||
network: z.string().trim().max(16).optional().nullable(),
|
||||
targetHost: z.string().trim().max(MAX_TEXT_LENGTH).optional().nullable(),
|
||||
targetPort: z.coerce.number().int().min(0).max(65535).optional().nullable(),
|
||||
action: z.string().trim().max(32).optional().nullable(),
|
||||
connectionCount: z.coerce.number().int().min(1).max(100000).optional().default(1),
|
||||
uniqueTargetCount: z.coerce.number().int().min(0).max(100000).optional().default(0),
|
||||
firstSeenAt: z.string().trim().max(64).optional().nullable(),
|
||||
lastSeenAt: z.string().trim().max(64).optional().nullable(),
|
||||
});
|
||||
|
||||
const nodeAccessPayloadSchema = z.object({
|
||||
events: z.array(nodeAccessEventSchema).min(1).max(MAX_EVENTS),
|
||||
});
|
||||
|
||||
function compactText(value: string | null | undefined) {
|
||||
const text = value?.trim();
|
||||
return text ? text.slice(0, MAX_TEXT_LENGTH) : null;
|
||||
}
|
||||
|
||||
function normalizeAction(action: string | null | undefined) {
|
||||
const normalized = action?.trim().toLowerCase();
|
||||
return normalized === "rejected" ? "rejected" : "accepted";
|
||||
}
|
||||
|
||||
function buildReason(event: z.infer<typeof nodeAccessEventSchema>, nodeId: string) {
|
||||
const parts = [
|
||||
"来源:节点 Xray access log",
|
||||
"节点:" + nodeId,
|
||||
event.inboundTag ? "入站:" + event.inboundTag : null,
|
||||
event.network ? "网络:" + event.network : null,
|
||||
event.targetPort ? "目标端口:" + event.targetPort : null,
|
||||
event.targetHost ? "样本目标:" + event.targetHost : null,
|
||||
"连接数:" + event.connectionCount,
|
||||
event.uniqueTargetCount ? "不同目标:" + event.uniqueTargetCount : null,
|
||||
event.firstSeenAt ? "首次:" + event.firstSeenAt : null,
|
||||
event.lastSeenAt ? "最近:" + event.lastSeenAt : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(";").slice(0, 1000);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const auth = await authenticateAgent(req);
|
||||
if (isAuthError(auth)) return auth;
|
||||
const { nodeId } = auth;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "请求体不是有效 JSON,期望格式:{ events: [...] }" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = nodeAccessPayloadSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "节点访问日志格式无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getAppConfig();
|
||||
if (!config.subscriptionRiskEnabled || !config.nodeAccessRiskEnabled) {
|
||||
return NextResponse.json({ ok: true, skipped: parsed.data.events.length, reason: "node_access_risk_disabled" });
|
||||
}
|
||||
|
||||
const clientEmails = [...new Set(parsed.data.events.map((event) => event.clientEmail))];
|
||||
const clients = await prisma.nodeClient.findMany({
|
||||
where: {
|
||||
email: { in: clientEmails },
|
||||
inbound: { serverId: nodeId },
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
userId: true,
|
||||
subscriptionId: true,
|
||||
},
|
||||
});
|
||||
const clientByEmail = new Map(clients.map((client) => [client.email, client]));
|
||||
|
||||
let processed = 0;
|
||||
let skipped = 0;
|
||||
let warnings = 0;
|
||||
let suspended = 0;
|
||||
|
||||
for (const event of parsed.data.events) {
|
||||
const client = clientByEmail.get(event.clientEmail);
|
||||
if (!client) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const action = normalizeAction(event.action);
|
||||
const allowed = action === "accepted";
|
||||
const result = await recordSubscriptionAccess({
|
||||
kind: "SINGLE",
|
||||
userId: client.userId,
|
||||
subscriptionId: client.subscriptionId,
|
||||
context: {
|
||||
ip: event.sourceIp,
|
||||
userAgent: "jboard-agent/xray-access-log",
|
||||
geo: getIpGeoContext(event.sourceIp),
|
||||
},
|
||||
allowed,
|
||||
reason: buildReason({
|
||||
...event,
|
||||
inboundTag: compactText(event.inboundTag),
|
||||
network: compactText(event.network),
|
||||
targetHost: compactText(event.targetHost),
|
||||
action,
|
||||
}, nodeId),
|
||||
evaluateRisk: allowed,
|
||||
riskConfig: config,
|
||||
sourceLabel: "节点真实连接",
|
||||
});
|
||||
|
||||
const abuseResult = allowed
|
||||
? await evaluateNodeAccessAbuseRisk({
|
||||
userId: client.userId,
|
||||
subscriptionId: client.subscriptionId,
|
||||
ip: event.sourceIp,
|
||||
connectionCount: event.connectionCount,
|
||||
uniqueTargetCount: event.uniqueTargetCount,
|
||||
targetHost: compactText(event.targetHost),
|
||||
targetPort: event.targetPort ?? null,
|
||||
config,
|
||||
})
|
||||
: { warned: false, suspended: false };
|
||||
|
||||
processed++;
|
||||
if (result.warned || abuseResult.warned) warnings++;
|
||||
if (result.suspended || abuseResult.suspended) suspended++;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, processed, skipped, warnings, suspended });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
|
||||
import { normalizeTraceHops, normalizeTraceText } from "@/lib/trace-normalize";
|
||||
import { classifyTraceRoute } from "@/lib/route-classify";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const auth = await authenticateAgent(req);
|
||||
@@ -32,7 +33,8 @@ export async function POST(req: Request) {
|
||||
for (const trace of body.traces) {
|
||||
if (!validCarriers.has(trace.carrier)) continue;
|
||||
const normalizedHops = normalizeTraceHops(trace.hops);
|
||||
const normalizedSummary = normalizeTraceText(trace.summary) || "路由信息";
|
||||
const submittedSummary = normalizeTraceText(trace.summary);
|
||||
const normalizedSummary = classifyTraceRoute({ summary: submittedSummary, hops: normalizedHops });
|
||||
const hopCount = Number(trace.hopCount);
|
||||
const normalizedHopCount =
|
||||
Number.isFinite(hopCount) && hopCount > 0
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { classifyTraceRoute } from "@/lib/route-classify";
|
||||
import { normalizeTraceHops } from "@/lib/trace-normalize";
|
||||
|
||||
const MAX_NODE_IDS = 100;
|
||||
|
||||
@@ -24,7 +26,7 @@ export async function GET(req: Request) {
|
||||
}
|
||||
result[row.nodeId].push({
|
||||
carrier: row.carrier,
|
||||
summary: row.summary,
|
||||
summary: classifyTraceRoute({ summary: row.summary, hops: normalizeTraceHops(row.hops) }),
|
||||
hopCount: row.hopCount,
|
||||
hops: row.hops,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
|
||||
@@ -79,7 +79,7 @@ function localizedName(record: { names?: object } | null | undefined) {
|
||||
return names["zh-CN"] ?? names.en ?? Object.values(names).find((value) => typeof value === "string") ?? null;
|
||||
}
|
||||
|
||||
function getGeoIpLocation(ip: string): RequestGeoContext {
|
||||
export function getIpGeoContext(ip: string): RequestGeoContext {
|
||||
if (ip === "unknown" || !isIP(ip)) return emptyGeoContext();
|
||||
|
||||
const reader = getGeoIpReader();
|
||||
@@ -244,7 +244,7 @@ export function getRequestGeo(headers: HeaderReader, ip = "unknown"): RequestGeo
|
||||
}
|
||||
|
||||
const headerGeo = { country, region, regionCode, city, latitude, longitude, source };
|
||||
return mergeGeoContext(headerGeo, getGeoIpLocation(ip));
|
||||
return mergeGeoContext(headerGeo, getIpGeoContext(ip));
|
||||
}
|
||||
|
||||
export function getClientRequestContext(headers: HeaderReader): ClientRequestContext {
|
||||
|
||||
77
src/lib/route-classify.ts
Normal file
77
src/lib/route-classify.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { normalizeTraceText, type NormalizedTraceHop } from "@/lib/trace-normalize";
|
||||
|
||||
function normalizeAsn(value: unknown) {
|
||||
const text = normalizeTraceText(value).toUpperCase();
|
||||
const match = text.match(/(?:^|\b)AS?\s*(\d{2,10})(?:\b|$)/);
|
||||
if (match) return match[1];
|
||||
return /^\d{2,10}$/.test(text) ? text : "";
|
||||
}
|
||||
|
||||
function isIpInPrefix(ip: string, prefix: string) {
|
||||
return ip === prefix.slice(0, -1) || ip.startsWith(prefix);
|
||||
}
|
||||
|
||||
function countMatches(values: string[], predicate: (value: string) => boolean) {
|
||||
return values.reduce((count, value) => count + (predicate(value) ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
export function classifyTraceRoute(input: {
|
||||
summary?: unknown;
|
||||
hops: Array<NormalizedTraceHop | { ip?: unknown; geo?: unknown; asn?: unknown; owner?: unknown; isp?: unknown }>;
|
||||
}) {
|
||||
const summary = normalizeTraceText(input.summary).toUpperCase();
|
||||
const hopTexts = input.hops.map((hop) => [
|
||||
normalizeTraceText(hop.ip),
|
||||
normalizeTraceText(hop.geo),
|
||||
normalizeTraceText("owner" in hop ? hop.owner : ""),
|
||||
normalizeTraceText("isp" in hop ? hop.isp : ""),
|
||||
normalizeTraceText("asn" in hop ? hop.asn : ""),
|
||||
].join(" ").toUpperCase());
|
||||
const combined = [summary, ...hopTexts].join(" ");
|
||||
const ips = input.hops.map((hop) => normalizeTraceText(hop.ip)).filter(Boolean);
|
||||
const asns = new Set<string>();
|
||||
|
||||
for (const hop of input.hops) {
|
||||
const directAsn = normalizeAsn("asn" in hop ? hop.asn : "");
|
||||
if (directAsn) asns.add(directAsn);
|
||||
}
|
||||
for (const match of combined.matchAll(/(?:^|\b)AS\s*(\d{2,10})(?:\b|$)/g)) {
|
||||
asns.add(match[1]);
|
||||
}
|
||||
|
||||
const hasAsn = (...values: string[]) => values.some((value) => asns.has(value));
|
||||
const hasText = (...values: string[]) => values.some((value) => combined.includes(value));
|
||||
const hasIpPrefix = (...prefixes: string[]) => ips.some((ip) => prefixes.some((prefix) => isIpInPrefix(ip, prefix)));
|
||||
|
||||
const cn2Evidence = hasAsn("4809")
|
||||
|| hasIpPrefix("59.43.")
|
||||
|| hasText("CN2", "CTGNET", "CHINANET NEXT CARRYING NETWORK", "CHINA TELECOM GLOBAL");
|
||||
const cn2GiaText = hasText("CN2 GIA", "CN2GIA", "GIA", "GLOBAL INTERNET ACCESS");
|
||||
const ordinaryTelecomHops = countMatches(hopTexts, (text) => (
|
||||
text.includes("AS4134")
|
||||
|| text.includes("CHINANET BACKBONE")
|
||||
|| text.includes("CHINANET 163")
|
||||
|| text.includes("163骨干")
|
||||
)) + countMatches(ips, (ip) => isIpInPrefix(ip, "202.97."));
|
||||
|
||||
if (cn2Evidence) {
|
||||
if (cn2GiaText || ordinaryTelecomHops <= 1) return "CN2 GIA";
|
||||
return "CN2 GT";
|
||||
}
|
||||
|
||||
if (hasAsn("9929", "10099") || hasText("CUII", "A网", "AS9929")) {
|
||||
return "AS9929";
|
||||
}
|
||||
if (hasText("CMIN2") || hasAsn("58807", "58809", "58813", "58819", "59807")) {
|
||||
return "CMIN2";
|
||||
}
|
||||
if (hasText("CMI") || hasAsn("58453")) {
|
||||
return "CMI";
|
||||
}
|
||||
if (hasAsn("4837") || hasText("AS4837")) {
|
||||
return "AS4837";
|
||||
}
|
||||
|
||||
const normalizedSummary = normalizeTraceText(input.summary);
|
||||
return normalizedSummary && normalizedSummary !== "普通线路" ? normalizedSummary : "普通线路";
|
||||
}
|
||||
@@ -77,6 +77,9 @@ export interface NormalizedTraceHop {
|
||||
ip: string;
|
||||
geo: string;
|
||||
latency: number;
|
||||
asn?: string;
|
||||
owner?: string;
|
||||
isp?: string;
|
||||
}
|
||||
|
||||
export function normalizeTraceHops(hops: unknown): NormalizedTraceHop[] {
|
||||
@@ -94,6 +97,9 @@ export function normalizeTraceHops(hops: unknown): NormalizedTraceHop[] {
|
||||
ip: normalizeTraceText(hopObject.ip),
|
||||
geo: normalizeTraceText(hopObject.geo),
|
||||
latency: Math.max(0, toSafeNumber(hopObject.latency, 0)),
|
||||
asn: normalizeTraceText(hopObject.asn) || undefined,
|
||||
owner: normalizeTraceText(hopObject.owner) || undefined,
|
||||
isp: normalizeTraceText(hopObject.isp) || undefined,
|
||||
};
|
||||
})
|
||||
.filter((hop) => hop.ip || hop.geo || hop.latency > 0);
|
||||
|
||||
@@ -142,6 +142,14 @@ export function reasonLabel(reason: SubscriptionRiskReason) {
|
||||
return "国家异常警告";
|
||||
case "COUNTRY_VARIANCE_SUSPEND":
|
||||
return "国家异常暂停";
|
||||
case "NODE_ACCESS_VOLUME_WARNING":
|
||||
return "节点高频警告";
|
||||
case "NODE_ACCESS_VOLUME_SUSPEND":
|
||||
return "节点高频暂停";
|
||||
case "NODE_ACCESS_TARGET_WARNING":
|
||||
return "目标分散警告";
|
||||
case "NODE_ACCESS_TARGET_SUSPEND":
|
||||
return "目标分散暂停";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,8 +342,8 @@ export function buildSubscriptionRiskReport(input: {
|
||||
const userLabel = user ? `${user.email}${user.name ? `(${user.name})` : ""}` : event.userId ?? "未知用户";
|
||||
const windowRange = `${formatDate(event.windowStartedAt)} 至 ${formatDate(event.createdAt)}`;
|
||||
const actionSuggestion = event.level === "SUSPENDED"
|
||||
? "建议保持暂停,等待用户确认是否本人跨地区使用、订阅链接是否外泄,并在工单中补充说明后再解除限制。"
|
||||
: "建议先联系用户确认近期访问来源;如果用户无法解释这些地区/IP,建议重置订阅链接并临时暂停相关订阅。";
|
||||
? "建议保持暂停,等待用户确认是否本人跨地区使用、订阅链接是否外泄或节点连接是否被共享,并在工单中补充说明后再解除限制。"
|
||||
: "建议先联系用户确认近期访问/连接来源;如果用户无法解释这些地区/IP,建议重置订阅链接并临时暂停相关订阅。";
|
||||
|
||||
return [
|
||||
"订阅风控风险报告",
|
||||
|
||||
@@ -24,6 +24,11 @@ type SubscriptionRiskConfig = Pick<
|
||||
| "subscriptionRiskRegionSuspend"
|
||||
| "subscriptionRiskCountryWarning"
|
||||
| "subscriptionRiskCountrySuspend"
|
||||
| "nodeAccessRiskEnabled"
|
||||
| "nodeAccessConnectionWarning"
|
||||
| "nodeAccessConnectionSuspend"
|
||||
| "nodeAccessUniqueTargetWarning"
|
||||
| "nodeAccessUniqueTargetSuspend"
|
||||
>;
|
||||
|
||||
interface RecordSubscriptionAccessInput {
|
||||
@@ -35,6 +40,7 @@ interface RecordSubscriptionAccessInput {
|
||||
reason?: string | null;
|
||||
evaluateRisk?: boolean;
|
||||
riskConfig?: SubscriptionRiskConfig;
|
||||
sourceLabel?: string | null;
|
||||
}
|
||||
|
||||
interface RiskDecision {
|
||||
@@ -99,8 +105,9 @@ function riskMessage(options: {
|
||||
countryLabels: string[];
|
||||
regionLabels: string[];
|
||||
cityLabels: string[];
|
||||
sourceLabel?: string | null;
|
||||
}) {
|
||||
const scope = getScopeLabel(options.kind);
|
||||
const scope = options.sourceLabel?.trim() || getScopeLabel(options.kind);
|
||||
const locationSummary = options.decision.reason.startsWith("COUNTRY")
|
||||
? `${options.countryCount} 个国家/地区:${formatKeyPreview(options.countryLabels)}`
|
||||
: options.decision.reason.startsWith("REGION")
|
||||
@@ -358,6 +365,7 @@ async function evaluateSubscriptionRisk(input: {
|
||||
userId?: string | null;
|
||||
subscriptionId?: string | null;
|
||||
ip: string;
|
||||
sourceLabel?: string | null;
|
||||
db: DbClient;
|
||||
config?: SubscriptionRiskConfig;
|
||||
}): Promise<RiskEvaluationResult> {
|
||||
@@ -433,6 +441,7 @@ async function evaluateSubscriptionRisk(input: {
|
||||
countryLabels,
|
||||
regionLabels,
|
||||
cityLabels,
|
||||
sourceLabel: input.sourceLabel,
|
||||
});
|
||||
|
||||
const { event, created } = await createRiskEvent({
|
||||
@@ -497,6 +506,142 @@ async function evaluateSubscriptionRisk(input: {
|
||||
return { warned: true, suspended: false, eventId: event.id };
|
||||
}
|
||||
|
||||
function decideNodeAccessAbuseRisk(input: {
|
||||
connectionCount: number;
|
||||
uniqueTargetCount: number;
|
||||
config: SubscriptionRiskConfig;
|
||||
}): RiskDecision | null {
|
||||
if (!input.config.nodeAccessRiskEnabled) return null;
|
||||
|
||||
if (input.config.subscriptionRiskAutoSuspend && input.uniqueTargetCount >= input.config.nodeAccessUniqueTargetSuspend) {
|
||||
return { level: "SUSPENDED", reason: "NODE_ACCESS_TARGET_SUSPEND" };
|
||||
}
|
||||
if (input.config.subscriptionRiskAutoSuspend && input.connectionCount >= input.config.nodeAccessConnectionSuspend) {
|
||||
return { level: "SUSPENDED", reason: "NODE_ACCESS_VOLUME_SUSPEND" };
|
||||
}
|
||||
if (input.uniqueTargetCount >= input.config.nodeAccessUniqueTargetWarning) {
|
||||
return { level: "WARNING", reason: "NODE_ACCESS_TARGET_WARNING" };
|
||||
}
|
||||
if (input.connectionCount >= input.config.nodeAccessConnectionWarning) {
|
||||
return { level: "WARNING", reason: "NODE_ACCESS_VOLUME_WARNING" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function nodeAccessAbuseMessage(input: {
|
||||
decision: RiskDecision;
|
||||
ip: string;
|
||||
connectionCount: number;
|
||||
uniqueTargetCount: number;
|
||||
targetHost?: string | null;
|
||||
targetPort?: number | null;
|
||||
}) {
|
||||
const metric = input.decision.reason.includes("TARGET")
|
||||
? "不同目标 " + input.uniqueTargetCount + " 个"
|
||||
: "连接 " + input.connectionCount + " 次";
|
||||
const targetValue = [input.targetHost, input.targetPort].filter(Boolean).join(":");
|
||||
const target = targetValue ? ",样本目标 " + targetValue : "";
|
||||
const action = input.decision.level === "SUSPENDED" ? "已自动暂停" : "已记录警告";
|
||||
return "节点真实连接行为异常,单个聚合窗口内出现 " + metric + ",来源 IP " + input.ip + target + "," + action + "。";
|
||||
}
|
||||
|
||||
export async function evaluateNodeAccessAbuseRisk(input: {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
ip: string;
|
||||
connectionCount: number;
|
||||
uniqueTargetCount: number;
|
||||
targetHost?: string | null;
|
||||
targetPort?: number | null;
|
||||
config?: SubscriptionRiskConfig;
|
||||
db?: DbClient;
|
||||
}): Promise<RiskEvaluationResult> {
|
||||
const db = input.db ?? prisma;
|
||||
const config = input.config ?? await getAppConfig(db);
|
||||
if (!config.subscriptionRiskEnabled || !config.nodeAccessRiskEnabled) {
|
||||
return { warned: false, suspended: false };
|
||||
}
|
||||
|
||||
const decision = decideNodeAccessAbuseRisk({
|
||||
connectionCount: input.connectionCount,
|
||||
uniqueTargetCount: input.uniqueTargetCount,
|
||||
config,
|
||||
});
|
||||
if (!decision) return { warned: false, suspended: false };
|
||||
|
||||
const windowStartedAt = new Date(Date.now() - config.subscriptionRiskWindowHours * 60 * 60 * 1000);
|
||||
const message = nodeAccessAbuseMessage({
|
||||
decision,
|
||||
ip: input.ip,
|
||||
connectionCount: input.connectionCount,
|
||||
uniqueTargetCount: input.uniqueTargetCount,
|
||||
targetHost: input.targetHost,
|
||||
targetPort: input.targetPort,
|
||||
});
|
||||
|
||||
const { event, created } = await createRiskEvent({
|
||||
kind: "SINGLE",
|
||||
userId: input.userId,
|
||||
subscriptionId: input.subscriptionId,
|
||||
ip: input.ip,
|
||||
decision,
|
||||
message,
|
||||
windowStartedAt,
|
||||
countryLabels: [],
|
||||
regionLabels: [],
|
||||
cityLabels: [],
|
||||
db,
|
||||
});
|
||||
|
||||
if (created) {
|
||||
const targetLabel = await getTargetLabel({ userId: input.userId, subscriptionId: input.subscriptionId }, db);
|
||||
await recordAuditLog({
|
||||
action: decision.level === "SUSPENDED" ? "risk.node_access.suspend" : "risk.node_access.warning",
|
||||
targetType: "UserSubscription",
|
||||
targetId: input.subscriptionId,
|
||||
targetLabel,
|
||||
message,
|
||||
metadata: {
|
||||
eventId: event.id,
|
||||
reason: decision.reason,
|
||||
ip: input.ip,
|
||||
connectionCount: input.connectionCount,
|
||||
uniqueTargetCount: input.uniqueTargetCount,
|
||||
targetHost: input.targetHost ?? null,
|
||||
targetPort: input.targetPort ?? null,
|
||||
windowStartedAt: windowStartedAt.toISOString(),
|
||||
},
|
||||
}, db);
|
||||
|
||||
if (decision.level === "WARNING") {
|
||||
await createNotification({
|
||||
userId: input.userId,
|
||||
type: "SUBSCRIPTION",
|
||||
level: "WARNING",
|
||||
title: "节点连接行为异常",
|
||||
body: "检测到你的订阅在节点侧出现异常高频连接或目标分散。如果不是你本人操作,请重置订阅访问并联系管理员。",
|
||||
link: "/subscriptions/" + input.subscriptionId,
|
||||
dedupeKey: "risk:node-access:warning:" + event.id,
|
||||
}, db);
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.level === "SUSPENDED") {
|
||||
const suspendedIds = await suspendScopeForRisk({
|
||||
kind: "SINGLE",
|
||||
userId: input.userId,
|
||||
subscriptionId: input.subscriptionId,
|
||||
message,
|
||||
});
|
||||
revalidateRiskViews(suspendedIds);
|
||||
return { warned: false, suspended: true, eventId: event.id };
|
||||
}
|
||||
|
||||
if (created) revalidateRiskViews([input.subscriptionId]);
|
||||
return { warned: true, suspended: false, eventId: event.id };
|
||||
}
|
||||
|
||||
export async function recordSubscriptionAccess(
|
||||
input: RecordSubscriptionAccessInput,
|
||||
db: DbClient = prisma,
|
||||
@@ -529,6 +674,7 @@ export async function recordSubscriptionAccess(
|
||||
userId: input.userId,
|
||||
subscriptionId: input.subscriptionId,
|
||||
ip: input.context.ip,
|
||||
sourceLabel: input.sourceLabel,
|
||||
db,
|
||||
config: input.riskConfig,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user