Files

189 lines
9.4 KiB
TypeScript

import type {
FlowAnalyticsDto,
FlowClientsDto,
FlowExportersDto,
FlowMapHopsDto,
FlowMonthlyDto,
FlowPurgeDto,
FlowStatsDto,
TrafficFlowHostFile,
TrafficFlowOverlayResult,
TrafficFlowSettingsDto,
TrafficFlowSettingsPatch,
TrafficFlowHostStatus,
} from "@mmapp/contracts/traffic-flow"
import { z } from "zod"
import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema, flowAnalyticsDtoSchema, flowMonthlyDtoSchema } from "@mmapp/contracts/traffic-flow"
import { requestJson } from "@/shared/api/http-client"
import { trafficFlowHostStatusSchema } from "@mmapp/contracts/traffic-flow"
export type { TrafficFlowHostFile }
export async function getTrafficFlowHostStatus(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
return flowConfirmed(trafficFlowHostStatusSchema, await requestJson(baseUrl, "/api/traffic/flow/host-status", {
...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(65_000)]),
}))
}
export async function reconcileTrafficFlowHost(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
const response = flowConfirmed(z.object({ ok: z.literal(true), hostStatus: trafficFlowHostStatusSchema }),
await requestJson(baseUrl, "/api/traffic/flow/host-reconcile", { ...init, method: "POST", signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(120_000)]) }))
return response.hostStatus
}
const flowSettingsSchema = trafficFlowSettingsDtoSchema.extend({ flowListenPort: z.number().int().min(1).max(65535), wgListenPort: z.number().int().min(1).max(65535) }).refine(data => (!data.hasHostPrivateKey || data.hostPublicKey.length > 0) && (!data.listenerBound || !!data.listenerAddress) && (data.lastDatagramAt === null || Number.isFinite(Date.parse(data.lastDatagramAt))) && new Set(data.peers.map(peer => peer.serverId)).size === data.peers.length, "Несогласованные настройки NetFlow")
const flowPatchSchema = trafficFlowSettingsPatchSchema.strict()
// Optional overlay/wire measurements may be unavailable independently of the
// confirmed primary counters. Preserve that distinction instead of inventing zero.
const optionalFlowMetric = z.preprocess(value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined, z.number().nonnegative().optional())
const flowAnalyticsSchema = flowAnalyticsDtoSchema.extend({
bytesPayload: optionalFlowMetric, bytesOverlay: optionalFlowMetric,
bytesMesh: optionalFlowMetric, bytesWire: optionalFlowMetric,
bpsOverlay: optionalFlowMetric, bpsWire: optionalFlowMetric,
}).refine(data => [...data.rxSeries, ...data.txSeries].every(value => Number.isFinite(value) && value >= 0), "Некорректные измерения трафика")
function flowConfirmed<T>(schema: z.ZodType<T>, raw: unknown): T {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Данные NetFlow не подтверждены: получен некорректный ответ сервера")
return parsed.data
}
function flowJson(baseUrl: string, path: string, init?: RequestInit) {
return requestJson<unknown>(baseUrl, path, { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) })
}
export async function getTrafficFlowSettings(baseUrl: string, init?: RequestInit): Promise<TrafficFlowSettingsDto> {
return flowConfirmed(flowSettingsSchema, await flowJson(baseUrl, "/api/traffic/flow/settings", { ...init, method: "GET" }))
}
export async function putTrafficFlowSettings(baseUrl: string, patch: TrafficFlowSettingsPatch, init?: RequestInit) {
const valid = flowPatchSchema.safeParse(patch)
if (!valid.success || !Object.values(valid.data).some(value => value !== undefined)) throw new Error("Укажите допустимые настройки NetFlow")
const body = valid.data
const response = flowConfirmed(z.object({ ok: z.literal(true), settings: flowSettingsSchema, listenerError: z.string().optional() }), await flowJson(baseUrl, "/api/traffic/flow/settings", { ...init, method: "PUT", body: JSON.stringify(body) }))
if (Object.entries(body).some(([field, value]) => value !== undefined && (response.settings as Record<string, unknown>)[field] !== value)) throw new Error("Сохранение NetFlow не подтверждено: сервер вернул другие настройки")
return response
}
export async function generateTrafficFlowKeys(baseUrl: string, init?: RequestInit) {
const schema = z.object({ ok: z.literal(true), created: z.boolean(), publicKey: z.string().min(1), settings: flowSettingsSchema }).refine(data => data.settings.hasHostPrivateKey && data.publicKey === data.settings.hostPublicKey)
return flowConfirmed(schema, await flowJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { ...init, method: "POST" }))
}
export async function getTrafficFlowHostFiles(baseUrl: string, init?: RequestInit): Promise<{ files: TrafficFlowHostFile[] }> {
const file = trafficFlowHostFileSchema.extend({ id: z.string().min(1), label: z.string().min(1), filename: z.string().min(1).refine(name => !/[\\/\r\n]/.test(name)), code: z.string().min(1) })
const schema = z.object({ files: z.array(file).min(1).refine(files => new Set(files.map(item => item.id)).size === files.length && new Set(files.map(item => item.filename)).size === files.length) })
return flowConfirmed(schema, await flowJson(baseUrl, "/api/traffic/flow/host-files", { ...init, method: "GET" }))
}
export async function applyTrafficFlowOverlay(
baseUrl: string,
serverId: string | number,
publicEndpoint?: string,
opts?: { disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
method: "POST",
body: JSON.stringify({
serverId,
publicEndpoint,
...(opts?.disableGreFastPath ? { disableGreFastPath: true } : {}),
}),
})
}
export async function getTrafficFlows(baseUrl: string, range = "5m"): Promise<FlowStatsDto> {
return requestJson<FlowStatsDto>(baseUrl, `/api/traffic/flows?range=${encodeURIComponent(range)}`)
}
function flowQuery(params: {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
}): string {
const q = new URLSearchParams()
if (params.range) q.set("range", params.range)
if (params.serverId) q.set("serverId", params.serverId)
if (params.userId) q.set("userId", params.userId)
if (params.iface && params.iface !== "__all__") q.set("iface", params.iface)
if (params.dedup === false) q.set("dedup", "0")
else if (params.dedup === true) q.set("dedup", "1")
if (params.excludeMesh === false) q.set("excludeMesh", "0")
else if (params.excludeMesh === true) q.set("excludeMesh", "1")
if (params.excludeOverlay === false) q.set("excludeOverlay", "0")
else if (params.excludeOverlay === true) q.set("excludeOverlay", "1")
const s = q.toString()
return s ? `?${s}` : ""
}
export async function getFlowExporters(baseUrl: string, range = "5m"): Promise<FlowExportersDto> {
return requestJson<FlowExportersDto>(baseUrl, `/api/traffic/flow/exporters?range=${encodeURIComponent(range)}`)
}
export async function getFlowClients(baseUrl: string, range = "5m"): Promise<FlowClientsDto> {
return requestJson<FlowClientsDto>(baseUrl, `/api/traffic/flow/clients?range=${encodeURIComponent(range)}`)
}
export async function getFlowMapHops(
baseUrl: string,
params: {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
} = {},
): Promise<FlowMapHopsDto> {
return requestJson<FlowMapHopsDto>(baseUrl, `/api/traffic/flow/map-hops${flowQuery({
range: params.range ?? "5m",
serverId: params.serverId,
userId: params.userId,
iface: params.iface,
dedup: params.dedup,
excludeMesh: params.excludeMesh,
excludeOverlay: params.excludeOverlay,
})}`)
}
export async function getFlowAnalytics(
baseUrl: string,
params: {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
},
): Promise<FlowAnalyticsDto> {
return flowConfirmed(flowAnalyticsSchema, await requestJson<unknown>(baseUrl, `/api/traffic/flow/analytics${flowQuery(params)}`))
}
export async function getFlowMonthly(
baseUrl: string,
params: { month: string; serverId?: string },
): Promise<FlowMonthlyDto> {
const q = new URLSearchParams()
q.set("month", params.month)
if (params.serverId) q.set("serverId", params.serverId)
const data = flowConfirmed(flowMonthlyDtoSchema, await requestJson<unknown>(baseUrl, `/api/traffic/flow/monthly?${q.toString()}`))
if (data.month !== params.month) throw new Error("Сервер вернул данные другого месяца. Повторите загрузку.")
return data
}
export async function purgeTrafficFlowData(baseUrl: string, init?: RequestInit): Promise<FlowPurgeDto> {
return flowConfirmed(flowPurgeDtoSchema, await flowJson(baseUrl, "/api/traffic/flow/purge", { ...init, method: "POST" }))
}
export async function rebuildTrafficFlowFacts(baseUrl: string): Promise<{
ok: true
buckets: number
facts: number
days: string[]
}> {
return requestJson(baseUrl, "/api/traffic/flow/rebuild-facts", { method: "POST" })
}
export { flowQuery }