Historical design or verification record. It is retained for context; use the dashboard guides and generated tool reference for current behavior.
08 — Offline config simulator
Status: implemented — all 5 phases complete. Most speculative task in the backlog — do it last. Loop protocol, exit gate and conventions: README.md. §9 is the source of truth for a
/looprun.
0. Scope in one line
Build a model of a router from its /export and trace a hypothetical packet through
firewall and routing without touching the device, so the assistant can answer
"would this rule break the VPN?" before Safe Mode is ever opened.
1. Why last
The value is obvious; the risk is fidelity. A simulator that is subtly wrong is worse than none, because it produces confident answers people act on. Every other task in this backlog either reports what is true or changes what is real. This one predicts, and prediction can be wrong in ways no test catches.
Two mitigations shape the whole design:
- Ship a narrow, correct core rather than a broad, approximate one: IPv4 filter, NAT and the routing decision, nothing else. No queues, no mangle side-effects on routing marks in v1, no connection-tracking state beyond a declared input state.
- Always report confidence and coverage. If the model met a construct it does
not implement, it says so in the result rather than silently ignoring it. An
answer with an unmodelled feature in the path is reported as
UNKNOWN, never asACCEPT.
2. Scope boundary (v1)
In: /ip firewall filter (input/forward/output), /ip firewall nat
(srcnat/dstnat/masquerade), /ip route selection incl. distance and check-gateway
state, /ip address for connected routes, interface lists, address lists,
connection-state as a declared input, and basic /ip firewall mangle routing-mark
assignment.
Out (v1): raw table, queues, layer7, IPv6 (v2), CAPsMAN, PPP dynamic routes, per-connection classifier, hairpin subtleties beyond simple dstnat, and anything requiring live connection tracking.
Out-of-scope constructs encountered in the path must surface in the result's
unmodelled[] array and downgrade the verdict to UNKNOWN.
3. Architecture
src/sim/model.ts PURE — device model built from the parsed export
src/sim/routing.ts PURE — longest-prefix + distance + routing-mark table selection
src/sim/firewall.ts PURE — chain traversal, rule matching, jump/return, action
src/sim/trace.ts PURE — orchestrates: packet + model → verdict + step-by-step path
src/tools/simulate.ts 4 toolsConsumes src/policy/parse.ts from task 06. That parser is a hard dependency —
do not write a second export parser here. If 06 is not done, 08 cannot start.
Everything is pure. The simulator never performs I/O. Its only input is a snapshot, which the tools load.
4. Packet spec
interface SimPacket {
srcAddress: string;
dstAddress: string;
protocol: "tcp" | "udp" | "icmp" | number;
srcPort?: number;
dstPort?: number;
inInterface: string;
connectionState: "new" | "established" | "related" | "invalid"; // declared
connectionMark?: string;
}connectionState is declared, not inferred — the model has no state table. Say
this plainly in the tool description; a user asking about an established connection
must say so.
5. Output
Not a verdict — a traversal:
ACCEPT | DROP | REJECT | UNKNOWN
step 1 routing decision: dst 8.8.8.8 → gateway 192.168.1.1 via ether1 (dst-address=0.0.0.0/0, distance 1)
step 2 chain=forward rule #3 (in=bridge out=ether1 conn-state=new) → jump to chain=wan-out
step 3 chain=wan-out rule #7 action=accept ← MATCHED
unmodelled: none
confidence: highEvery step names the rule index and its source line so a human can go look. That
traceability is what makes the output trustworthy even when the verdict is
UNKNOWN.
6. Tools (4)
| Tool | Risk | Purpose |
|---|---|---|
simulate_packet | READ | Trace one packet against a snapshot or the live config |
simulate_change | READ | Apply a proposed change to the model, then diff the verdicts for a set of packets — the killer feature |
explain_rule_reachability | READ | Which rules can never match given the ones above them (static shadow analysis, no packet needed) |
simulate_suite | READ | Run a saved set of packets as regression assertions |
Module src/tools/simulate.ts, label "Simulator", group Security.
simulate_change is the reason to build this: pair it with Change Plan so a plan
can be evaluated against a packet suite before it is applied. That combination —
"this change would break your VPN, here is the rule" — is the demo.
simulate_suite turns the simulator into a regression test for your firewall:
declare the flows that must work, and any change that breaks one is caught in the
plan phase.
7. Dashboard — new page Simulator
ui/observability/simulator.tsx:
- Packet builder — a form, with presets ("web from LAN", "SSH from WAN", "VPN peer to peer") because typing a 6-field packet spec is friction.
- Traversal view — the steps as a vertical flow, each rule expandable to its
config lines with the matching properties highlighted. Verdict as a large
unmistakable banner;
UNKNOWNstyled as a warning, never as success. - Before/after — for
simulate_change, two traversals side by side with the divergence step highlighted. On mobile, stacked with the divergence anchored. - Suite runner — saved packet suites, pass/fail per packet, run-on-demand.
- Reachability map — the firewall chain as a list with unreachable rules struck through. Genuinely useful on its own and needs no packet input.
Routes: GET /api/sim/suites, POST /api/sim/packet, POST /api/sim/change,
POST /api/sim/suite/:id/run, GET /api/sim/reachability.
8. Raycast
None in v1. The interaction is form-heavy and traversal output is wide; Raycast would be a poor fit and the effort is better spent on fidelity. Revisit only if users ask.
9. Implementation progress (loop state — source of truth)
Prerequisite: task 06 Phase 1 (
src/policy/parse.ts) must be complete. Do not start otherwise.
[x] Phase 1 — Device model + routing
-
src/sim/ip.ts— IPv4 arithmetic, address/port matchers that return null (not false) for anything they cannot parse. -
src/sim/model.ts— parsed export → interfaces, addresses, derived connected routes, static routes, address/interface lists, routing tables, firewall rules, and theunmodelled[]ledger. -
src/sim/routing.ts— longest-prefix, distance tie-break, routing-table selection, discard routes, no-route, next-hop → egress resolution. -
tests/sim/routing.spec.ts— 28 cases covering every listed scenario plus IPv6 refusal, disabled routes, check-gateway reporting and matcher edge cases. - Exit gate: global gate green (1167 tests).
Decisions carried forward:
- Connected routes are derived from
/ip address, at distance 0. They are not in the export, and a model without them routes LAN traffic out of the default gateway — the most obvious possible way to be wrong. - ECMP is reported, never resolved. Equal prefix and equal distance means the
device hashes per connection;
selectRoutereturnsoutcome: "ecmp"with both candidates and NO chosen route. - A matcher that cannot be parsed returns
null, notfalse. "I do not understand this" and "this does not match" have to stay distinguishable, or an unmodelled matcher silently reads as a non-match and the verdict flips. - The
unmodelledledger is built during model construction, not at match time, so a rule carryinglayer7-protocolis known to be unmodelled before any packet is traced. Raw-table rules are recorded too — they run before everything the simulator models.
[x] Phase 2 — Firewall traversal
-
src/sim/firewall.ts— chain traversal with jump/return as a real stack, matcher evaluation over the §2 property set, address-list and interface-list resolution,unmodelledcollection, plusunreachableRules()(static shadow analysis, no packet needed). -
tests/sim/firewall.spec.ts— 45 cases covering every listed scenario plus nested jumps, the implicit accept being reported, passthrough actions, and six reachability cases. - Exit gate: global gate green (1212 tests).
Decisions carried forward:
- Three-valued matching. A matcher returns match / no-match / unknown, and
unknownanywhere in a rule stops traversal with verdictUNKNOWN. Continuing as though an unevaluable rule were absent is exactly how a simulator produces a confident wrong ACCEPT. - Matcher evaluation order is observable and pinned by a test. A rule with
src-address=10.0.0.0/8 layer7-protocol=xagainst a 192.168 packet is a definite non-match — the address is checked first and fails — so traversal continues. Only a rule that might have matched stops it. out-interfacebefore the routing decision isunknown, not false. The trace fills it in after routing; evaluating it earlier would be a guess.- Action parameters are not matchers.
new-connection-mark,address-list,reject-withand friends never decide whether a rule fires. A test caught this: treating them as unknown matchers made every mangle ruleUNKNOWN. - Reachability analysis is deliberately conservative — only an exact-superset relation on the modelled matchers counts as shadowing. A false "this rule is dead" gets a working rule deleted, which is worse than missing one.
[x] Phase 3 — Trace + NAT + tools
-
src/sim/trace.ts— dstnat → manglemark-routing→ routing → filter → srcnat, with address rewriting between stages, plusrenderTraceanddiffTraces. -
tests/sim/trace.spec.ts— 21 cases: dstnat before routing, the filter chain seeing the rewritten destination, a dropped packet never reaching srcnat, redirect switching the chain to input, blackhole/no-route/ECMP, and the unmodelled-forces-UNKNOWN property. -
src/tools/simulate.ts(4 tools, all READ) + catalog entry (Simulator, group Security) + 4 schemas. -
docs/simulator.mdwith the "what this does NOT model" table first. - Exit gate: global gate +
bun run gen(4 new schemas only) — 1233 tests.
Two bugs the tests caught, both worth recording:
- NAT actions were being reported as unmodelled.
traverseChainonly knew filter verdicts, somasqueradelooked like an unknown action and every masquerading config traced asUNKNOWN. Traversal now takes the table kind and has a terminal-action set per table. - Mangle marking is passthrough by default, so the first-match
decidedByused for filter is the wrong answer there — a latermark-routingoverwrites an earlier one.applyMangleRoutingMarkwalks the chain itself and takes the last match (stopping early only onpassthrough=no).
[x] Phase 4 — Fidelity validation
- Measured against two live RouterOS 7.x devices (home router with DHCP WAN, policy routing and a 24-rule firewall; hosted VPS with an on-link /32). A third was unreachable — the blueprint asked for five varied exports and this is two, recorded as a shortfall rather than glossed over.
- Ground truth is the device's OWN active routing table and
check_route_path(both read-only). 12 route decisions: 10 exact matches, 2 correctly declined, 0 confidently wrong. - Every divergence became a fix or an explicit
unmodelledentry — none left silent. -
tests/sim/fidelity.spec.ts(13 cases) + anonymised fixtures reproducing the real config structures. The real exports are NOT committed: they are the user's live network topology, and the lesson is preserved by the fixture, not by the raw config. - Exit gate: global gate + the fidelity report in
docs/simulator.md.
Four findings, all from the live comparison:
- A DHCP-learned default route is absent from
/export. The simulator said "dropped, no route" for every internet-bound packet on the commonest setup there is. It now detects DHCP/PPPoE/OSPF/BGP sources and reportsUNKNOWN. /ip address … network=declares an on-link gateway outside the address's own subnet (standard on hosted VPSes). Ignoring it left the egress unresolved for the only route that mattered.- Route liveness is unknowable from an export. A static route with an
unresolvable next-hop, or with
check-gateway, now downgrades the verdict instead of being taken at face value — the device had deactivated exactly such a route. connection-nat-stateis evaluable after all, because the trace runs dstnat before the filter chain. It was the first rule of the real forward chain, so leaving it unknown made every trace on that deviceUNKNOWN.
Honest limit: routing has a real oracle (the device's FIB); the filter chain does not, short of injecting packets in a lab. Firewall expectations were derived by reading the rules.
[x] Phase 5 — Dashboard
-
src/observability/sim-routes.ts—GET /api/sim/suites,POST /api/sim/suites,POST /api/sim/packet,POST /api/sim/change,POST /api/sim/suite/:id/run,GET /api/sim/reachability. -
ui/observability/simulator.tsx(packet builder with presets, traversal flow, before/after with the divergence step marked, reachability list with dead rules struck through) +main.tsxwiring +types.ts. -
src/sim/suites.ts— suite persistence insnapshots.db. -
UNKNOWNis styled as a warning and captioned "This is not a pass", never as success — the one UI requirement the blueprint made explicit. - Exit gate: global gate +
bun run build:ui(observability.html 1.31 MB).
Blockers / notes
Hard dependency on task 06 Phase 1.Satisfied —src/policy/parse.tsis the only export parser, shared by both features.Fidelity is the real risk.Measured in Phase 4: 0 confidently-wrong answers out of 12 route decisions against live devices. Scope was NOT narrowed, because the divergences were fixable model gaps rather than approximation error — but two of the twelve are answered withUNKNOWN, and that is the honest ceiling of what an export can support.
All phases complete. Known gaps, none blocking:
- Firewall fidelity has no oracle. Routing was checked against the device's own FIB; filter expectations were derived by reading rules. Injecting real packets in a lab is the only way to do better.
- Two devices, not five. A third was unreachable, and the varied set the blueprint wanted (CHR, multi-WAN, switch) was not obtained.
outputchain is typed but never traversed — the trace models ingress paths (input/forward) only.- Suites are persisted and runnable through the API, but the dashboard page does not
yet expose a suite editor;
simulate_suiteis the way to run one today.