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):
accept src-address=<DEVICE> dst-address=192.168.100.0/24— LAN exclusion (BEFORE mark)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¶
- Address-list (e.g.,
github-direct) — populated by an external script inmikrotik-tools - Mangle accept rule — matches
dst-address-list=<list-name>, placed beforevpn-zone-lan - 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'