71 lines
3.9 KiB
TypeScript
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 }
|
|
}
|
|
}
|