Files
MikrotikManager/backend/src/test/traffic-flow-chr-interface-counters.ts
T

71 lines
3.9 KiB
TypeScript

import { performance } from "node:perf_hooks"
import { encodeRosId, type MikrotikClient } from "../services/mikrotik.js"
import { isChrWireGuardInterfaceType } from "./traffic-flow-chr-transit-fixture.js"
type Row = Record<string, unknown>
type InterfacePin = { id: string; name: string; ifIndex: string }
type Options = {
client: Pick<MikrotikClient, "get">
identity: string
version: string
interfaces: readonly [InterfacePin, InterfacePin]
assertCurrent: () => Promise<void>
}
export const ownedWgFastPathFields = ["fp-rx-byte", "fp-tx-byte", "fp-rx-packet", "fp-tx-packet"] as const
export const ownedWgPacketFields = ["rx-packet", "tx-packet"] as const
const fields = [...ownedWgFastPathFields, ...ownedWgPacketFields]
const validId = (id: string) => /^\*[a-f0-9]{1,16}$/i.test(id)
const index = (value: unknown) => typeof value === "string" && /^[1-9][0-9]{0,9}$/.test(value) && Number(value) <= 0xffffffff
const observedUint = (value: unknown) => typeof value === "string" && /^(0|[1-9][0-9]{0,19})$/.test(value) &&
BigInt(value) <= 0xffffffffffffffffn
const deny = (reason: string): never => { throw Error("RouterOS owned counter read refused: " + reason) }
/** Uses only fixed read operations. Missing counter/index fields refuse the
* profile rather than substituting zero or guessing the wire ifIndex. */
export function createChrInterfaceCounterReader(options: Options) {
const { client, assertCurrent, identity, version } = options
const interfaces = structuredClone(options.interfaces)
if (!identity || !version || interfaces.length !== 2 || interfaces.some(pin =>
!validId(pin.id) || !/^nf[a-f0-9]{8}(ea|ma|mb|eb)$/.test(pin.name) || !index(pin.ifIndex)) ||
interfaces[0].id === interfaces[1].id || interfaces[0].ifIndex === interfaces[1].ifIndex ||
interfaces[0].name.slice(0, -2) !== interfaces[1].name.slice(0, -2) ||
!["ea,ma", "eb,mb"].includes(interfaces.map(pin => pin.name.slice(-2)).sort().join(","))) deny("invalid interface pins")
return async () => {
const startedAt = Date.now(), deadline = performance.now() + 10000
const fence = async () => {
if (performance.now() >= deadline) deny("observation expired")
await assertCurrent()
if (performance.now() >= deadline) deny("observation expired")
}
const read = async (path: string) => {
await fence()
const result = await client.get<unknown>(path, Math.min(1500, Math.ceil(deadline - performance.now())), undefined, 16384, fence)
await fence()
const rows = Array.isArray(result) ? result : [result]
if (rows.length !== 1 || !rows[0] || typeof rows[0] !== "object" || Array.isArray(rows[0])) deny("incomplete response")
return rows[0] as Row
}
const verifyIdentity = async () => {
const device = await read("/system/identity")
const resource = await read("/system/resource")
if (device.name !== identity || resource.version !== version) deny("device identity changed")
}
await verifyIdentity()
const rows: Row[] = []
for (const pin of interfaces) {
const row = await read("/interface/" + encodeRosId(pin.id))
if (row[".id"] !== pin.id || row.name !== pin.name || !isChrWireGuardInterfaceType(row.type) || row.ifindex !== pin.ifIndex ||
![true, "true", "yes"].includes(row.running as string | boolean) ||
![false, "false", "no"].includes(row.disabled as string | boolean)) deny("interface identity or state differs")
if (!fields.every(field => observedUint(row[field]))) deny("required counter unavailable")
rows.push({ ".id": pin.id, name: pin.name, type: "wg", ifindex: pin.ifIndex,
...Object.fromEntries(fields.map(field => [field, row[field]])) })
}
await verifyIdentity()
const observedAt = Date.now()
if (observedAt < startedAt || observedAt - startedAt > 10000) deny("observation wall clock changed")
return { startedAt, observedAt, rows }
}
}