Skip to content

MikroTik VPN Routing — Standard Workflow

Prevent issues encountered during VPN setup. Run this checklist EVERY TIME when adding/modifying VPN routes or firewall rules.

Current VPN Architecture

3-tier failover: VN6 → VN9 → WAN (only 1 tunnel active at a time)

  protonvpn   (dist=1)  ← primary, enabled by default
  protonvpn2  (dist=2)  ← secondary, enabled when VN6 fails
  pppoe-out1  (dist=3)  ← last resort (direct WAN)

Script: check_vpn_tunnel.py (every 30s)
  State: /data/vpn_failover_state.json {tier, fail_count, recovery_counter}
  PING_COUNT=3, FAIL_THRESHOLD=2 (needs 2 consecutive failures)

Auto-detect: detect_vpn_ips.py (every 10min)
  Scans ntopng data for VPN-port traffic from LAN devices
  Adds to address-list protonvpn-detected + exclude routes

Auto-update: update_protonvpn_ips.py (daily 3:15 AM)
  Reads WG endpoints + resolves ProtonVPN domains
  Updates address-lists protonvpn-vn, protonvpn-sg

WAN IP monitor: monitor_wan_ip.py (every 5min)
  Detects WAN IP changes → notify Telegram

Pre-change: Snapshot current state

# Save current config for rollback
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip firewall export' > /tmp/fw-backup-$(date +%s).rsc
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip route export' >> /tmp/fw-backup-$(date +%s).rsc

Checklist: 5 layers for VPN routing

Every VPN routing change must verify ALL 5 layers. Missing any one = silent failure.

Layer 1: Routing table

# Verify vpn-route table has default route + exclusions
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip route print where routing-table=vpn-route'

Must have:

  • 0.0.0.0/0 → protonvpn (default via VPN)
  • 100.64.0.0/10 → pppoe-out1 (Tailscale excluded)
  • Any new exclusion for new VPN endpoints

When adding a new VPN endpoint (e.g., new WireGuard server):

# Add endpoint exclusion to BOTH routing tables
/ip route add dst-address=<NEW_ENDPOINT_IP>/32 gateway=pppoe-out1 comment="exclude-new-vpn-endpoint"
/ip route add dst-address=<NEW_ENDPOINT_IP>/32 gateway=pppoe-out1 routing-table=vpn-route comment="exclude-new-vpn-endpoint"

Layer 2: Mangle rules

# Verify routing marks + LAN exclusion
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip firewall mangle print where chain=prerouting'

Must have (in this order):

  1. accept src-address=<DEVICE> dst-address=192.168.100.0/24 — LAN exclusion (BEFORE mark)
  2. mark-routing src-address=<DEVICE> new-routing-mark=vpn-route — VPN routing mark

When adding a new device to VPN:

# Add LAN exclusion FIRST (before mark rule)
/ip firewall mangle add chain=prerouting src-address=<DEVICE_IP> dst-address=192.168.100.0/24 action=accept comment="<name>-skip-lan" place-before=[find comment="<name>-vpn-route"]

# Add VPN routing mark
/ip firewall mangle add chain=prerouting src-address=<DEVICE_IP> action=mark-routing new-routing-mark=vpn-route passthrough=no comment="<name>-vpn-route"

Layer 3: NAT masquerade

# Verify masquerade exists for VPN interface
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip firewall nat print where out-interface=protonvpn'

Must have:

  • chain=srcnat out-interface=protonvpn action=masquerade

When adding a new VPN interface:

/ip firewall nat add chain=srcnat out-interface=<NEW_VPN_IFACE> action=masquerade comment="masq-<name>"

Layer 4: MSS clamping

# Verify MSS clamp for VPN interface
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip firewall mangle print where out-interface=protonvpn'

Must have:

  • chain=forward protocol=tcp tcp-flags=syn out-interface=protonvpn action=change-mss new-mss=1300

When adding a new VPN interface:

/ip firewall mangle add chain=forward action=change-mss new-mss=1300 passthrough=yes tcp-flags=syn protocol=tcp out-interface=<NEW_VPN_IFACE> comment="<name>-mss-clamp"

Layer 5: Forward chain filter

# Verify forward rules allow LAN → VPN
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip firewall filter print where chain=forward'

Must have (BEFORE catch-all drop):

  • chain=forward in-interface=bridge1 out-interface=protonvpn action=accept

When adding a new VPN interface:

/ip firewall filter add chain=forward action=accept in-interface=bridge1 out-interface=<NEW_VPN_IFACE> comment="allow-lan-to-<name>" place-before=[find comment="drop-fwd-rest"]

Layer 6 (always check): Bridge fast-forward

# Verify fast-forward is OFF
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/interface bridge print' | grep fast-forward

Must be: fast-forward=no

If someone re-enables this, ALL mangle routing marks stop working silently.

Pattern: Domain-based Exclusions (7th scenario)

The 6 layers above cover per-device VPN routing. For per-destination bypass (e.g., "send GitHub traffic via WAN, everything else via VPN"), use a domain-fed address-list + a mangle accept rule placed BEFORE the mark rules.

This is NOT a new layer — it sits inside Layer 2 (mangle prerouting). Documented separately because the workflow differs.

Components

  1. Address-list (e.g., github-direct) — populated by an external script in mikrotik-tools
  2. Mangle accept rule — matches dst-address-list=<list-name>, placed before vpn-zone-lan
  3. Scheduler job — keeps the list fresh as IPs rotate

Mangle rule (one-time)

sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '
/ip firewall mangle add chain=prerouting action=accept \
    dst-address-list=github-direct comment="vpn-skip-github" \
    place-before=[find comment="vpn-zone-lan"]
'

Rule order in prerouting after adding:

1. vpn-skip-lan          (dst 192.168.100.0/24 → ACCEPT)
2. vpn-skip-forwarded    (src port-forwarded → ACCEPT)
3. vpn-skip-github       (dst github-direct → ACCEPT)   ← NEW
4. vpn-zone-lan          (src zone-lan → mark vpn-route)
5. vpn-zone-iot          (src zone-iot → mark vpn-route)

Verification (router-side)

# List size
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 \
  '/ip firewall address-list print count-only where list=github-direct'

# Rule is matching traffic?
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 \
  '/ip firewall mangle print stats where comment="vpn-skip-github"'
# BYTES + PACKETS should grow as GitHub traffic flows

Verification (client-side, from a zone-lan device)

# Resolve github.com — should land in a github-direct CIDR
dig +short github.com    # e.g., 140.82.112.4 → matches 140.82.112.0/20

# Test route — should go via WAN (pppoe-out1), NOT protonvpn
traceroute github.com    # first hop 192.168.100.1, then FPT upstream

Existing implementations

Script Schedule List Source
update_github_ips.py daily 3:20 AM github-direct GitHub meta API + DNS

To add a new domain exclusion: copy the script pattern, change LIST_NAME, META_BLOCKS, EXTRA_DOMAINS, add a scheduler entry, add the mangle rule.

Post-change: Verify

# 1. Verify VPN route checker (automated)
docker exec mikrotik-tools python3 /app/scripts/check_vpn_routes.py

# 2. Drop existing connections for the device
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip firewall connection remove [find src-address~"<DEVICE_IP>"]'

# 3. Watch WG traffic (should increase significantly)
tx1=$(curl -s -u lucndm:minhluc1 'http://192.168.100.1/rest/interface/wireguard/peers' | python3 -c "import sys,json;print(int(json.load(sys.stdin)[0].get('tx','0')))")
sleep 10
tx2=$(curl -s -u lucndm:minhluc1 'http://192.168.100.1/rest/interface/wireguard/peers' | python3 -c "import sys,json;print(int(json.load(sys.stdin)[0].get('tx','0')))")
echo "WG tx delta: $((tx2-tx1)) bytes (should be >5000 for real traffic)"

# 4. Verify forward counters increasing
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip firewall filter print stats where comment~"allow-lan-to"'

Common mistakes → symptoms → fixes

Mistake Symptom Fix
Missing NAT masquerade WG tx increases but no return traffic Layer 3
Missing MSS clamp TCP SYN hangs, Android "no internet" Layer 4
Bridge fast-forward ON Mangle matches but routing doesn't change Layer 6
Missing forward accept Packet reaches VPN interface but dropped Layer 5
Missing LAN exclusion in mangle DNS fails (router IP routed through VPN) Layer 2
Missing endpoint exclusion Recursive routing / tunnel death Layer 1
Exclusion only in main table (not vpn-route) Tailscale/other VPN traffic double-encrypted Layer 1

Rollback

# Disable VPN routing for a device
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip firewall mangle disable [find comment~"<name>-"]'

# Re-enable
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/ip firewall mangle enable [find comment~"<name>-"]'

# Full restore from backup
sshpass -p 'minhluc1' ssh lucndm@192.168.100.1 '/import file=/tmp/fw-backup-<timestamp>.rsc'