OPERATIONS GUIDE

v6.11.0

Offline Simulator

Practical setup, operations and limits for Offline Simulator.

Trace a hypothetical packet through NAT, routing and the firewall without touching the device — so "would this rule break the VPN?" can be answered before Safe Mode is ever opened.

Four tools, in the Simulator module (Security group). All four are READ and none of them changes anything, on any device:

ToolWhat it does
simulate_packetTrace one packet against a snapshot, supplied text, or the live config
simulate_changeApply proposed lines to a copy, re-trace, diff the verdicts
explain_rule_reachabilityWhich rules can never match (static, no packet needed)
simulate_suiteRun a set of packets with expected verdicts — a firewall regression test

What this does NOT model

This section is the feature, not a disclaimer. A simulator that is subtly wrong is worse than none, because it produces confident answers people act on. So the model states its limits, and anything on a packet's path that falls outside them downgrades the verdict to UNKNOWN — never to ACCEPT.

Out of scope in v1:

Not modelledConsequence
/ip firewall rawRuns before everything modelled; its presence is reported
IPv6An IPv6 address is refused, not coerced
Layer-7, content, TLS SNINeeds packet payload
connection-bytes/-rate/-limitNeeds live connection tracking
time, random, psdNot a function of the packet alone
tcp-flags, fragment, dscpNot tracked on the simulated packet
Queues, mangle side-effects beyond mark-routingNo effect on the verdict modelled
Hairpin NAT subtletiesOnly simple dstnat/srcnat is traced
ECMP next-hop choiceA per-connection hash; both candidates are reported, verdict UNKNOWN
check-gateway livenessAn export cannot say whether a route is currently active — it is flagged

Connection state is declared, not inferred. There is no connection-tracking table here. A question about an established connection has to say connection_state=established; the default is new.

What it does model

/ip firewall filter (input/forward/output) with jump/return, /ip firewall nat (dstnat/srcnat/masquerade/redirect), the routing decision including connected routes, distance, discard routes and routing-table selection, /ip address, address lists, interface lists, and mangle mark-routing in prerouting.

Order of operations, which is what most hand-written mental models get wrong:

dstnat (prerouting) → ROUTING DECISION → filter (input | forward) → srcnat (postrouting)

dstnat happens before routing, so a port-forwarded packet is routed to its rewritten destination and the filter chain sees the internal address.

Reading a traversal

ACCEPT  —  ACCEPT by chain=forward rule #1 (line 31)

  step 1  routing (line 25): 8.8.8.8 → 0.0.0.0/0 gateway 203.0.113.1 via ether1 (static, distance 1)
  step 2  forward rule #0 (line 30): connection-state=established,related did not match
  step 3  forward rule #1 (line 31): matched (in-interface-list=LAN out-interface-list=WAN) → ACCEPT

  NAT:
    srcnat rule #0 (line 34): masquerade → source becomes (egress address of ether1)

  unmodelled: none
  confidence: high

Every step names the chain, the rule index and the source line in the export, so the model's reasoning can be checked instead of trusted. That traceability is what makes the output useful even when the verdict is UNKNOWN.

confidence is high only when nothing on the path was unmodelled; it drops to medium or low as unmodelled constructs accumulate, and the verdict becomes UNKNOWN at the same time.

The pairing that makes it worth having

plan_changes commands=[...]          # what the change would do to one device
simulate_change changes=[...] \      # what it would do to a packet
  src_address=192.168.88.50 dst_address=10.10.0.2 protocol=udp dst_port=13231 \
  in_interface=bridge

…and simulate_suite turns the flows you depend on into assertions:

simulate_suite packets='[
  {"name":"LAN → internet","src_address":"192.168.88.50","dst_address":"8.8.8.8",
   "in_interface":"bridge","dst_port":443,"expect":"accept"},
  {"name":"WAN → SSH","src_address":"198.51.100.9","dst_address":"203.0.113.10",
   "in_interface":"ether1","dst_port":22,"expect":"drop"}
]'

An UNKNOWN verdict counts as a failure in a suite: a flow whose fate the model cannot determine is not a flow you have verified.

Fidelity report

Measured against two live RouterOS 7 devices (a home router with a DHCP WAN, policy routing and a 24-rule firewall; a hosted VPS with an on-link /32 and a WireGuard tunnel). Ground truth is the device's own active routing table (/ip route print terse where active=yes) and check_route_path, both read-only.

A third device was unreachable at the time of measurement, and the blueprint's "five varied exports" was therefore not met — this is two, and it is worth repeating the exercise on more.

Routing decisions: 12 destinations across both devices

ResultCount
Matched the device exactly10same egress interface and next-hop
Reported UNKNOWN/limited2correctly declined to answer (below)
Confidently wrong0—

The two it declined are the interesting ones, and both were silent wrong answers before this pass:

  1. A DHCP-learned default route. The home router reaches the internet via a route its DHCP client installed; that route is not in /export. The simulator used to answer "dropped, no route" for every internet-bound packet on the commonest setup there is. It now detects a DHCP/PPPoE/OSPF/BGP source in the config and says so, and a trace with no route on such a device is UNKNOWN, not a drop.

  2. Route liveness. The export contained a static route whose gateway is not reachable, so the device had deactivated it and used a different one. An export cannot express "is this route currently active", so a route with an unresolvable next-hop — or with check-gateway set — now downgrades the verdict rather than being taken at face value.

Two model bugs were fixed as a direct result:

  • /ip address … network= is now honoured. A hosted VPS commonly has address=203.0.113.33 network=10.0.0.1 — a /32 whose gateway is declared on-link and is nowhere near its own subnet. Ignoring network= left the egress interface unresolved for the only route that mattered on that device.
  • connection-nat-state is now evaluated instead of being unknown. The trace runs dstnat before the filter chain, so "has this been dstnat'ed" is a fact by then — and it was the first rule of the real forward chain, so leaving it unknown made every trace on that device UNKNOWN.

Firewall traversal

Verdicts on the real 24-rule chain complete without hitting an unmodelled matcher, except where the config genuinely uses one (ipsec-policy on two accept rules, tcp-flags on an MSS-clamp rule). Those paths report UNKNOWN with the specific matcher named.

There is an honest limit here: firewall ground truth was derived by reading the rules, not by injecting packets into the live device. Routing has a real oracle; the filter chain does not, short of a lab.

What the numbers mean

The headline is not "10/12". It is zero confidently-wrong answers — every case the model could not get right, it declined, named the reason and pointed at the line. That is the property this feature is for; a higher match rate bought by guessing would be worse.

The regressions found here are pinned in tests/sim/fidelity.spec.ts against anonymised reproductions of the real config structures, so a live router's lesson cannot be quietly undone later.

Architecture

src/sim/ip.ts        PURE — IPv4 arithmetic; matchers return null, not false, when unparseable
src/sim/model.ts     PURE — parsed export → interfaces, routes, lists, rules, unmodelled ledger
src/sim/routing.ts   PURE — longest-prefix + distance + table selection
src/sim/firewall.ts  PURE — chain traversal, three-valued matching, reachability analysis
src/sim/trace.ts     PURE — the full path, NAT rewriting, verdict + confidence
src/tools/simulate.ts  the four tools

The export parser is src/policy/parse.ts — shared with Policy-as-Code, deliberately not a second one. A simulator that disagreed with the linter about what the config says would be the worst possible failure for a feature whose only product is confidence.

On this page