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 type InterfacePin = { id: string; name: string; ifIndex: string } type Options = { client: Pick identity: string version: string interfaces: readonly [InterfacePin, InterfacePin] assertCurrent: () => Promise } 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(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 } } }