fix(api): подтверждать полный сбор пути без скрытых ошибок
This commit is contained in:
1 parent
5fdd6ef58c
commit
d4ae1c3127
10 files changed
+102
-46
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { readStoredInternetPathPayload } from "../services/internet-path-snapshot.js"
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import {
|
||||
getInternetPathSettings,
|
||||
@@ -47,13 +48,11 @@ const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/internet-path/latest", async (_req, reply) => {
|
||||
const row = await getLatestInternetPathSnapshot()
|
||||
if (!row) return reply.send({ snapshot: null })
|
||||
let payload: unknown = row.payloadJson
|
||||
if (typeof row.payloadJson === "string") {
|
||||
try {
|
||||
payload = JSON.parse(row.payloadJson)
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
let payload: object
|
||||
try {
|
||||
payload = readStoredInternetPathPayload(row.payloadJson)
|
||||
} catch {
|
||||
return reply.code(502).send({error: "Сохранённый снимок Internet path повреждён"})
|
||||
}
|
||||
return reply.send({
|
||||
snapshot: payload,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
filterRules,
|
||||
@@ -12,6 +12,8 @@ import { MikrotikClient } from "./mikrotik.js"
|
||||
import type { InternetPathRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||
|
||||
import { collectConfirmedInternetPath, readInternetPathRows } from "./internet-path-snapshot.js"
|
||||
|
||||
const INTERNET_TARGET = "1.1.1.1"
|
||||
let collecting = false
|
||||
|
||||
@@ -66,9 +68,9 @@ async function buildRulesets() {
|
||||
|
||||
async function readRouteLookup(serverId: number): Promise<{ gateway: string | null; routingMark: string | null }> {
|
||||
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!row) return { gateway: null, routingMark: null }
|
||||
if (!row) throw new Error(`Internet path: устройство ${serverId} не найдено`)
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const routes = await client.get<Array<Record<string, string>>>("/ip/route").catch(() => [])
|
||||
const routes = await client.get<unknown>("/ip/route").then(raw => readInternetPathRows(raw, "routes " + serverId))
|
||||
const best = routes
|
||||
.filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0")
|
||||
.filter((r) => isTrue(r.active))
|
||||
@@ -88,12 +90,12 @@ async function readRouteLookup(serverId: number): Promise<{ gateway: string | nu
|
||||
async function readWanRuntime(serverId: number) {
|
||||
const server = (await listServersRead()).find((s) => Number(s.id) === serverId)
|
||||
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!server || !row) return null
|
||||
if (!server || !row) throw new Error(`Internet path: устройство ${serverId} не найдено`)
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const [dhcpRaw, ipAddrs, routes] = await Promise.all([
|
||||
client.get<Array<Record<string, string>>>("/ip/dhcp-client").catch(() => []),
|
||||
client.get<Array<Record<string, string>>>("/ip/address").catch(() => []),
|
||||
client.get<Array<Record<string, string>>>("/ip/route").catch(() => []),
|
||||
client.get<unknown>("/ip/dhcp-client").then(raw => readInternetPathRows(raw, "DHCP " + serverId)),
|
||||
client.get<unknown>("/ip/address").then(raw => readInternetPathRows(raw, "addresses " + serverId)),
|
||||
client.get<unknown>("/ip/route").then(raw => readInternetPathRows(raw, "routes " + serverId)),
|
||||
])
|
||||
const defaultRoute = routes
|
||||
.filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0")
|
||||
@@ -174,7 +176,7 @@ async function collectGreTunnels() {
|
||||
const all = await Promise.all(enabled.map(async (srv) => {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(srv)
|
||||
const rows = await client.get<Array<Record<string, string>>>("/interface/gre")
|
||||
const rows = await client.get<unknown>("/interface/gre").then(raw => readInternetPathRows(raw, "GRE " + srv.id))
|
||||
return rows.map((g, idx) => {
|
||||
const keepalive = String(g.keepalive ?? "0,0").split(",")
|
||||
const inner = parseInnerIps(String(g.comment ?? ""))
|
||||
@@ -202,8 +204,8 @@ async function collectGreTunnels() {
|
||||
: (String(g.running ?? "false") === "true" ? "up" as const : "degraded" as const),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
} catch (error) {
|
||||
throw new Error(`Internet path: GRE устройства ${srv.id}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}))
|
||||
return all.flat()
|
||||
@@ -242,28 +244,13 @@ export async function collectInternetPathSnapshotOnce(): Promise<InternetPathRun
|
||||
}
|
||||
collecting = true
|
||||
const started = Date.now()
|
||||
const settings = await getSettingsRow()
|
||||
try {
|
||||
const serversRead = await listServersRead()
|
||||
const homes = serversRead.filter((s) => s.type === "home-router")
|
||||
const [greTunnels, rulesets] = await Promise.all([await collectGreTunnels(), Promise.resolve(await buildRulesets())])
|
||||
const speedProbes = await mapSpeedProbes()
|
||||
const routeLookupByServerId: Record<string, { gateway: string | null; routingMark: string | null }> = {}
|
||||
const wanRuntimeByHomeId: Record<string, unknown> = {}
|
||||
for (const h of homes) {
|
||||
routeLookupByServerId[String(h.id)] = await readRouteLookup(Number(h.id)).catch(() => ({ gateway: null, routingMark: null }))
|
||||
wanRuntimeByHomeId[String(h.id)] = await readWanRuntime(Number(h.id)).catch(() => null)
|
||||
}
|
||||
const payload = {
|
||||
sampledAt,
|
||||
internetTarget: INTERNET_TARGET,
|
||||
servers: serversRead,
|
||||
greTunnels,
|
||||
filtersRulesets: rulesets,
|
||||
speedProbes,
|
||||
routeLookupByServerId,
|
||||
wanRuntimeByHomeId,
|
||||
}
|
||||
await getSettingsRow()
|
||||
const data = await collectConfirmedInternetPath(sampledAt, {
|
||||
servers: listServersRead, gre: collectGreTunnels, filters: buildRulesets, speed: mapSpeedProbes,
|
||||
route: readRouteLookup, wan: readWanRuntime,
|
||||
})
|
||||
const payload = {...data, internetTarget: INTERNET_TARGET}
|
||||
await db.insert(internetPathSnapshots).values({
|
||||
sampledAt,
|
||||
payloadJson: payload,
|
||||
@@ -278,7 +265,7 @@ export async function collectInternetPathSnapshotOnce(): Promise<InternetPathRun
|
||||
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
||||
job: "internet_path",
|
||||
sampledAt,
|
||||
homes: homes.length,
|
||||
homes: data.servers.filter(s => s.enabled && s.type === "home-router").length,
|
||||
snapshotSaved: true,
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {collectConfirmedInternetPath, readInternetPathRows, readStoredInternetPathPayload} from "./internet-path-snapshot.js"
|
||||
const servers=[{id:1,type:"home-router",enabled:true},{id:2,type:"home-router",enabled:false},{id:3,type:"exit-node",enabled:true}]
|
||||
const sampledAt="2026-10-04T00:00:00Z"
|
||||
const defaults={servers:async()=>servers,gre:async()=>[],filters:async()=>[],speed:async()=>[],route:async(id:number)=>{assert.equal(id,1);return {gateway:null,routingMark:null}},wan:async(id:number)=>{assert.equal(id,1);return {defaultGateway:null,defaultInterface:null,uplinks:[]}}}
|
||||
const good=await collectConfirmedInternetPath(sampledAt,defaults)
|
||||
assert.equal(good.collectionComplete,true);assert.deepEqual(Object.keys(good.wanRuntimeByHomeId),["1"]);assert.equal(good.sampledAt,sampledAt)
|
||||
// A failed mandatory source must not reach the persistence boundary.
|
||||
for(const source of ["servers","gre","filters","speed","route","wan"] as const){
|
||||
let saved=0
|
||||
await assert.rejects(async()=>{await collectConfirmedInternetPath(sampledAt,{...defaults,[source]:async()=>{throw Error("denied")}});saved++},/denied/)
|
||||
assert.equal(saved,0)
|
||||
}
|
||||
for(const source of ["route","wan"] as const)await assert.rejects(collectConfirmedInternetPath(sampledAt,{...defaults,[source]:async()=>null}),/не подтвердило/)
|
||||
assert.deepEqual(readInternetPathRows([],"routes"),[])
|
||||
assert.deepEqual(readInternetPathRows([{active:"true",gateway:"192.0.2.1"}],"routes"),[{active:"true",gateway:"192.0.2.1"}])
|
||||
for(const raw of [null,{},[null],[[]],[{active:12}],[{active:true}]])assert.throws(()=>readInternetPathRows(raw,"routes"),/повреждён/)
|
||||
console.log("internet path snapshot tests ok: complete, disabled home, six failed readers, missing runtime, malformed RouterOS")
|
||||
|
||||
assert.deepEqual(readStoredInternetPathPayload('{"collectionComplete":true}'),{collectionComplete:true})
|
||||
assert.deepEqual(readStoredInternetPathPayload(good),good)
|
||||
for(const raw of [null,"null","[1]","bad",[],1])assert.throws(()=>readStoredInternetPathPayload(raw))
|
||||
@@ -0,0 +1,32 @@
|
||||
// No database or RouterOS imports: this boundary can be checked with injected readers.
|
||||
type PathServer = {id: number | string; type: string; enabled: boolean}
|
||||
export async function collectConfirmedInternetPath<S extends PathServer, G, F, P, R extends object, W extends object>(sampledAt: string, readers: {
|
||||
servers(): Promise<S[]>; gre(): Promise<G>; filters(): Promise<F>; speed(): Promise<P>
|
||||
route(id: number): Promise<R | null>; wan(id: number): Promise<W | null>
|
||||
}) {
|
||||
const servers = await readers.servers()
|
||||
const homes = servers.filter(s => s.enabled && s.type === "home-router")
|
||||
const [greTunnels, filtersRulesets, speedProbes] = await Promise.all([readers.gre(), readers.filters(), readers.speed()])
|
||||
const routeLookupByServerId: Record<string, R> = {}
|
||||
const wanRuntimeByHomeId: Record<string, W> = {}
|
||||
for (const home of homes) {
|
||||
const [route, wan] = await Promise.all([readers.route(Number(home.id)), readers.wan(Number(home.id))])
|
||||
if (route == null || wan == null) throw new Error("Internet path: устройство " + home.id + " не подтвердило route/WAN")
|
||||
routeLookupByServerId[String(home.id)] = route
|
||||
wanRuntimeByHomeId[String(home.id)] = wan
|
||||
}
|
||||
return {collectionComplete: true as const, sampledAt, servers, greTunnels, filtersRulesets, speedProbes, routeLookupByServerId, wanRuntimeByHomeId}
|
||||
}
|
||||
|
||||
export function readInternetPathRows(raw: unknown, source: string): Array<Record<string, string>> {
|
||||
if (!Array.isArray(raw) || raw.some(row => !row || typeof row !== "object" || Array.isArray(row) || Object.values(row).some(value => typeof value !== "string"))) {
|
||||
throw new Error("Internet path: повреждён ответ " + source)
|
||||
}
|
||||
return raw as Array<Record<string, string>>
|
||||
}
|
||||
|
||||
export function readStoredInternetPathPayload(raw: unknown): object {
|
||||
const payload: unknown = typeof raw === "string" ? JSON.parse(raw) : raw
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Сохранённый снимок Internet path повреждён")
|
||||
return payload
|
||||
}
|
||||
Reference in new issue
Block a user