Berik Ashimov // senior.it.engineer
Download CV
POSTS / MIKROTIK

RouterOS 7 NAT Beyond Masquerade: srcnat, Hairpin, and Netmap

// series · MikroTik RouterOS 7 Guide part 3 of 35
  1. RouterOS 7 First Boot Done Right: From Default Config to Hardened Baseline
  2. A RouterOS 7 Firewall That Actually Protects: Chains, States, and Fasttrack
  3. RouterOS 7 NAT Beyond Masquerade: srcnat, Hairpin, and Netmap
  4. Bridge VLAN Filtering in RouterOS 7: One Bridge, Zero Lockouts
  5. DHCP and DNS on RouterOS 7: The Services Everyone Misconfigures
  6. ... 30 later
view the full series →

Most RouterOS NAT configurations are one masquerade rule copied from the default config, plus a stack of port forwards accreted over the years — half of them pointing at hosts that no longer exist. It works, in the sense that packets flow. Then one day the phone system starts dropping calls every time the PPPoE session flaps, or someone inside the office reports that the company website “works from my phone but not my laptop,” and suddenly you’re learning what masquerade actually does.

NAT on RouterOS lives in /ip firewall nat, evaluates like the filter table — top to bottom, first match per connection — and has exactly two chains that matter: srcnat, processed after routing for traffic leaving, and dstnat, processed before routing for traffic arriving. Everything else is which action you attach.

The actions are where the engineering lives. Masquerade and src-nat are not synonyms. Hairpin NAT is two rules, not one, and the popular one-rule version breaks your server logs. And netmap exists so that nobody should ever write 254 individual dst-nat rules again. Let’s take them in order.

One rule of the road first: NAT decisions are made once per connection, on the first packet, and stored in the connection tracker. Every NAT change you make applies only to new connections — existing entries keep their old translation until they expire. When testing NAT changes, clear the relevant connections or you’ll be debugging ghosts.

srcnat vs. Masquerade: Stop Cargo-Culting Masquerade

The default configuration gives you:

Terminal window
/ip firewall nat
add chain=srcnat out-interface-list=WAN action=masquerade

Masquerade is src-nat with a twist: instead of translating to a configured address, it looks up the current address of the outgoing interface for every new connection. That indirection is the entire feature — and it’s only useful when the WAN address is dynamic (DHCP, PPPoE).

If your router has a static public IP, use plain src-nat:

Terminal window
/ip firewall nat
add chain=srcnat src-address=10.0.0.0/24 out-interface-list=WAN \
action=src-nat to-addresses=203.0.113.10 comment="LAN out via static IP"

Why bother, when masquerade “also works”? Three reasons:

  1. Link flap behavior. When an interface running masquerade goes down and up, RouterOS purges related connection-tracking entries — reasonable for a dynamic IP that probably changed, disruptive when your IP is static and every long-lived flow (SIP, VPNs, database replication) gets cut for no reason. Plain src-nat doesn’t do this.
  2. Determinism. src-nat translates to the address you wrote, always. Masquerade translates to whatever the interface has right now — which, on a multi-address interface or during a renumber, may not be what you intended.
  3. Cost. The per-connection address lookup is small but real. On a router pushing high connection rates, small-times-millions matters.

Masquerade is for dynamic WANs. That’s it. If you know your IP, write it down in the rule.

For routers holding a block of public addresses, the same logic extends — different internal subnets can map to different public IPs with multiple src-nat rules, which keeps abuse reports and geolocation sane per department.

dstnat: Port Forwarding Without Regrets

Inbound is the dstnat chain — rewrite the destination before routing happens:

Terminal window
/ip firewall nat
# Forward WAN tcp/443 to the internal reverse proxy
add chain=dstnat in-interface-list=WAN protocol=tcp dst-port=443 \
action=dst-nat to-addresses=10.0.0.80 comment="www to proxy"
# Translate the port too: external 2222 -> internal 22
add chain=dstnat in-interface-list=WAN protocol=tcp dst-port=2222 \
action=dst-nat to-addresses=10.0.0.5 to-ports=22 comment="ssh to bastion"

Habits that prevent 2 a.m. archaeology:

  • Match on in-interface-list=WAN (or the specific dst-address), never on port alone. A bare dst-port=443 rule also grabs your LAN users’ outbound HTTPS to any destination — a classic self-inflicted outage.
  • Comment every rule with what it serves and who asked for it. The NAT table is where undocumented dependencies go to hide.
  • Remember the firewall: dst-nat rewrites the packet, but the forward chain still decides whether it passes. If you built the previous article’s ruleset, the connection-nat-state=!dstnat drop handles this automatically — any connection a dstnat rule touched is exempt from the WAN-originated drop. NAT rule and firewall permission stay in lockstep with zero extra rules.

On a dynamic WAN address you can’t match dst-address. Use dst-address-type=local to mean “any address that belongs to this router” — it’s also a building block for hairpin, next.

Hairpin NAT: The Problem Everyone Hits

The scenario: web server at 10.0.0.80, port-forwarded from your public 203.0.113.10. External users are fine. Then someone inside the LAN opens https://203.0.113.10 and it hangs.

What happened: the LAN client’s packet hits dstnat, destination becomes 10.0.0.80, and the router forwards it back into the LAN. The server sees a connection from 10.0.0.35 — a directly reachable neighbor — and replies straight to the client, bypassing the router. The client sent a SYN to 203.0.113.10 but got a SYN-ACK from 10.0.0.80. No connection.

The fix is a second translation for exactly this traffic pattern — masquerade LAN-to-LAN traffic that went through the port forward, so the server replies to the router and the return path stays symmetric:

Terminal window
/ip firewall nat
# The port forward, now also matching connections from inside
add chain=dstnat dst-address=203.0.113.10 protocol=tcp dst-port=443 \
action=dst-nat to-addresses=10.0.0.80 comment="www forward"
# Hairpin: LAN clients reaching the LAN server via the public IP
add chain=srcnat src-address=10.0.0.0/24 dst-address=10.0.0.80 \
protocol=tcp dst-port=443 out-interface-list=LAN \
action=masquerade comment="hairpin for www"

(Here masquerade is fine — the “stable IP” argument doesn’t apply to a LAN-facing translation, and it saves hardcoding the LAN gateway address.)

The honest cost: the server now sees these connections as coming from the router’s LAN address. Your access logs lose the real client IP for internal users. That’s why my actual recommendation is: hairpin is the fallback, split DNS is the fix. Serve www.example.com as 10.0.0.80 to internal clients via /ip dns static (covered later in this series) and the traffic never needs to hairpin at all. Deploy the hairpin rules anyway for the clients that bypass your DNS — there are always some.

netmap: 1:1 NAT for Whole Ranges

When you have a public subnet to map onto an internal one — say 203.0.113.0/26 onto 10.20.0.0/26 — do not write 62 rule pairs. netmap translates network-to-network while preserving host bits: .5 maps to .5, .17 to .17:

Terminal window
/ip firewall nat
# Inbound: public range -> internal range
add chain=dstnat dst-address=203.0.113.0/26 \
action=netmap to-addresses=10.20.0.0/26 comment="1:1 in"
# Outbound: internal range -> public range (the exact mirror of the rule above)
add chain=srcnat src-address=10.20.0.0/26 \
action=netmap to-addresses=203.0.113.0/26 comment="1:1 out"

The two rules must mirror each other exactly — same pair of networks, swapped roles. Netmap is pure arithmetic on the address bits; it will happily map to a network you fat-fingered, so print both rules and read them side by side before walking away.

Both directions matter, and this is the mistake I see most: dstnat netmap alone gets traffic in, but the servers’ outbound traffic then exits through your generic masquerade rule instead — and services that validate that connections originate from the expected public IP (mail servers, most obviously) fail in confusing ways. Netmap pairs, always.

Prefer netmap over per-host dst-nat when you genuinely control a routed public block. It’s O(1) rules regardless of subnet size, and it’s the closest NAT gets to honest routing.

NAT and Fasttrack

If you enabled fasttrack in your forward chain (previous article), understand how it composes with NAT:

NAT keeps working. The translation is a property of the connection-tracking entry, decided on the first packet — and the first packets of a connection are never fasttracked. By the time fasttracking kicks in, the NAT verdict is baked into the conntrack entry and hardware or fast-path forwarding applies it. Masquerade, src-nat, dst-nat, netmap: all fine at full speed.

Everything around NAT stops working. Fasttracked packets bypass mangle and queues — so the moment you want to do something with NATed traffic beyond translating it, fasttrack is in your way:

  • Per-user queues on NATed clients: shaped only until the flow fasttracks, then unlimited.
  • Mangle-based multi-WAN (connection marks steering NAT egress): marks aren’t applied to fasttracked packets; flows stick to whatever the first packet decided, and your failover logic never sees them.
  • MSS clamping in mangle for tunnels: skipped, hello mysterious PMTU issues.

The rule of thumb: fasttrack and translate-only NAT coexist perfectly; fasttrack and policy (queues, marks, accounting) on the same traffic do not. Exclude the traffic you need to touch from the fasttrack rule, or don’t fasttrack.

Common Pitfalls, Rapid Fire

  • Old connections outlive rule changes. After editing NAT, flush affected entries: /ip firewall connection remove [find dst-address~"10.0.0.80"] — or wait out the timeout and question your sanity.
  • Rule order. First matching rule in the chain wins per connection. A broad masquerade above a specific src-nat means the specific rule never fires. Specific first, general last — audit with counters.
  • Port-only dstnat matches grabbing outbound LAN traffic (see above). Always scope with interface list or dst-address.
  • NAT is not a firewall. dstnat exposes exactly what you forward, but srcnat “hiding” your LAN is a side effect, not a policy. The filter table is the policy; run the drop-by-default forward chain regardless.
  • Multiple WANs with one masquerade rule matched on a single out-interface: traffic failing over to WAN2 leaves untranslated. Match out-interface-list=WAN with both interfaces in the list.

Verification

Counters, connections, torch — in that order:

Terminal window
# Which NAT rules are actually matching?
/ip firewall nat print stats
# Inspect live translations - shows original and reply addresses per flow
/ip firewall connection print detail where dstnat
# Watch the translated traffic leave the right interface
/tool torch interface=ether1 src-address=203.0.113.10

For dstnat, test from truly outside (a VPS, a phone off WiFi) and from the LAN — the second test is how you find out you need the hairpin rules before your users do. For netmap, verify a middle host in the range end-to-end in both directions, not just the first address.

Closing Thoughts

NAT accumulates like sediment, so fight entropy structurally: masquerade only where the address is genuinely dynamic, static src-nat where it isn’t, netmap for ranges, one comment per rule, and let connection-nat-state=!dstnat in the firewall keep permissions synchronized with forwards automatically. And remember the conntrack rule — NAT changes apply to new connections only, so flush when you edit.

The deeper lesson: every NAT rule is technical debt against the day you get real routable addressing. Design your internal network as if NAT weren’t there — sane subnets, split DNS so internal clients never depend on hairpin — and NAT stays what it should be: a thin translation layer at the edge, not load-bearing architecture.

Next in the series: bridge VLAN filtering, the RouterOS 7 way — including the migration procedure that doesn’t end with a locked-out router and a paperclip in the reset hole.

// share