Skip to content

Instantly share code, notes, and snippets.

@drewr
Last active May 9, 2026 18:58
Show Gist options
  • Select an option

  • Save drewr/34b31fd3d63162ad1a00cfca55d0d99b to your computer and use it in GitHub Desktop.

Select an option

Save drewr/34b31fd3d63162ad1a00cfca55d0d99b to your computer and use it in GitHub Desktop.
AirVPN IPv6 via WireGuard on MikroTik
# AirVPN WireGuard values — populated by wg-to-env.scm
AIRVPN_PRIVATE_KEY=
AIRVPN_TUNNEL_IPV4=
AIRVPN_TUNNEL_IPV6=
AIRVPN_MTU=1320
AIRVPN_SERVER_PUBKEY=
AIRVPN_PRESHARED_KEY=
AIRVPN_ENDPOINT_IP=
AIRVPN_ENDPOINT_PORT=
AIRVPN_KEEPALIVE=15
# Network — fill in manually
UPSTREAM_SSID=
UPSTREAM_WIFI_PASSWORD=
TRAVEL_SSID=
TRAVEL_WIFI_PASSWORD=
NEXTDNS_PROFILE_ID=
NEXTDNS_DEVICE_NAME=apTravel
DEVICE_NAME=apTravel
WG_LISTEN_PORT=13231
LAN_ULA_PREFIX=fd88:1::1/64

MikroTik hAP ax² Travel Router: Complete Setup Guide

What This Builds

A travel router based on the MikroTik hAP ax² (C52iG-5HaxD2HaxD) that:

  • Connects to an upstream WiFi network (hotel, office, etc.) using the 5 GHz radio as WAN
  • Provides a private LAN via the 2.4 GHz radio, wired Ethernet ports (2–5), and an optional secondary "travel" SSID on the 5 GHz radio
  • NATs LAN clients behind its upstream IP (IPv4)
  • Tunnels IPv6 through AirVPN via WireGuard so LAN clients get IPv6 connectivity without any per-device configuration
  • Filters DNS through NextDNS over DoH
  [Upstream WiFi AP]
         |
    (wifi1, 5GHz)                  ← station-bridge to upstream, WAN interface
         |
   [ MikroTik bridge ]
    /    |    \
  eth2  eth3  wifiTravel(5GHz vAP) ← LAN clients (wired + wireless)
  eth4  eth5
              wifi2(2.4GHz)        ← disabled, not used

  IPv6: all LAN traffic tunnelled through AirVPN WireGuard (wg airvpn)

Prerequisites

  • MikroTik hAP ax² (C52iG-5HaxD2HaxD), factory fresh or reset
  • Credentials for the upstream WiFi network you want to connect to
  • AirVPN account — download a WireGuard config for your preferred server from the AirVPN client area
  • Winbox (Windows/Mac app) or SSH to reach the router at 192.168.88.1
  • (Optional) NextDNS account and profile ID

1. Initial Access

A factory-fresh device has no WiFi password. Connect to the MikroTik-XXXXXX SSID or plug into any Ethernet port (2–5). Browse to http://192.168.88.1 or use Winbox and connect to 192.168.88.1 with username admin, no password.

Open a terminal: Winbox → New Terminal, or ssh admin@192.168.88.1.

Set an admin password before doing anything else:

/user set [find name=admin] password="YOUR_ADMIN_PASSWORD"

Create a root user and install your SSH public key

The default admin account is fine for initial setup, but a named root account with full privileges and key-based SSH access is easier to use from scripts and avoids typing a password every time.

On the MikroTik terminal:

/user add name=root group=full

Then, from your local machine, push your SSH public key in one command. This works by writing the key to a temporary file on the router and importing it — no password prompt after the initial connection:

ssh -4 \
    -o UserKnownHostsFile=/dev/null \
    -o StrictHostKeyChecking=no \
    root@192.168.88.1 \
    "/file print file=mykey.txt; \
     file set mykey.txt contents=\"$(cat ~/.ssh/id_ed25519.pub)\"; \
     /user ssh-keys import public-key-file=mykey.txt"
  • -4 forces IPv4 (avoids ambiguity before IPv6 is configured)
  • UserKnownHostsFile=/dev/null and StrictHostKeyChecking=no skip host key verification, which is appropriate here since you are on the LAN talking to a freshly reset device
  • Adjust id_ed25519.pub to match your key file if you use a different key type

After this you can SSH in without a password:

ssh -4 root@192.168.88.1

2. Create the Bridge Interface

The bridge ties together all LAN-facing interfaces (wired ports + 2.4 GHz AP + travel vAP) into a single Layer 2 segment.

/interface bridge
add name=bridge comment=defconf

3. Configure WiFi Security Profiles

RouterOS 7 uses named security profiles that can be shared across interfaces.

/interface wifi security

# Profile for authenticating TO the upstream network (WAN)
add name=Upstream \
    passphrase="YOUR_UPSTREAM_WIFI_PASSWORD" \
    ft=no

# Profile for your local travel AP (LAN)
add name=Travel \
    passphrase="YOUR_TRAVEL_AP_PASSWORD"

4. Configure WiFi Interfaces

5 GHz radio — WAN (station-bridge to upstream)

/interface wifi
set [find default-name=wifi1] \
    channel.band=5ghz-ax \
    channel.skip-dfs-channels=all \
    configuration.mode=station-bridge \
    configuration.ssid="YOUR_UPSTREAM_SSID" \   ← SSID of the hotel/office/home AP
    security=Upstream \
    security.ft=no \
    security.ft-over-ds=no \
    disabled=no

station-bridge mode connects the 5 GHz radio as a WiFi client of the upstream AP. The MikroTik obtains its own IP from the upstream DHCP server (see step 8) and NATs LAN clients behind it.

2.4 GHz radio — disabled

/interface wifi
set [find default-name=wifi2] disabled=yes

Secondary travel AP (virtual interface on 5 GHz)

This creates a second SSID on the 5 GHz radio. Useful if you want a separate network for guests, or a consistently-named SSID you connect your devices to regardless of what the upstream network is called.

/interface wifi
add name=wifiTravel \
    master-interface=wifi1 \
    configuration.mode=ap \
    configuration.ssid="YOUR_TRAVEL_SSID" \     ← e.g. "Amsterdam"
    security=Travel \
    disabled=no

5. Add Ports to the Bridge

The default config puts wifi1 in the bridge; remove it so it stays a standalone WAN interface. Then add the travel vAP as a LAN member.

/interface bridge port
remove [find interface=wifi1]
add bridge=bridge interface=wifiTravel

6. Interface Lists

/interface list member
add interface=wifi1  list=WAN
add interface=airvpn list=WAN

7. LAN IP Address

The default configuration uses 192.168.88.0/24. If you want a different subnet, change it here before continuing:

/ip address
set [find interface=bridge] address=192.168.X.1/24

8. WAN: DHCP Client

The router obtains its IPv4 address from the upstream WiFi AP.

/ip dhcp-client
add interface=wifi1 \
    default-route-tables=main \
    name=wan-dhcp \
    comment=defconf

9. DHCP Server for LAN Clients

The default configuration serves 192.168.88.10–192.168.88.254 on the bridge interface. If you changed the LAN subnet in step 7, update the pool and network here to match.


10. Install CA Certificates

DoH requires the router to verify the TLS certificate of the DNS server. RouterOS ships without a CA bundle, so fetch and import one before configuring DNS. This requires basic IPv4 connectivity to already be working (steps 7–9).

/tool fetch url=https://curl.se/ca/cacert.pem
/certificate import file-name=cacert.pem

11. DNS

The router acts as a DNS proxy for LAN clients. Here it is configured to use NextDNS over DoH for privacy and filtering. Replace YOUR_NEXTDNS_PROFILE_ID with your profile ID from nextdns.io, and adjust the upstream resolvers to taste.

/ip dns
set allow-remote-requests=yes \
    servers=1.1.1.3,1.0.0.3 \
    use-doh-server="https://dns.nextdns.io/YOUR_NEXTDNS_PROFILE_ID/YOUR_DEVICE_NAME" \
    verify-doh-cert=yes

NextDNS resolves via IPv6 as well; pre-seed its addresses as static DNS entries so the router can reach the DoH server before it has DNS itself:

/ip dns static
add name=router.lan address=192.168.88.1 type=A comment=defconf
add name=dns.nextdns.io address=45.90.28.0  type=A
add name=dns.nextdns.io address=45.90.30.0  type=A
add name=dns.nextdns.io address=2a07:a8c0:: type=AAAA
add name=dns.nextdns.io address=2a07:a8c1:: type=AAAA

12. NAT (IPv4)

Masquerade all LAN traffic behind the router's upstream IP.

/ip firewall nat
add action=masquerade chain=srcnat \
    ipsec-policy=out,none \
    out-interface-list=WAN \
    comment="defconf: masquerade"

13. Firewall (IPv4)

The factory default rules are sufficient. They provide stateful filtering on both the input and forward chains, accepting established/related traffic and dropping anything arriving from WAN that wasn't initiated from the LAN side. Adjust if you need to expose services or tighten restrictions beyond the defaults.


14. Firewall (IPv6)

Same as IPv4 — the factory defaults are sufficient. They block known-bad address ranges, accept ICMPv6 and established/related traffic, and drop unsolicited inbound connections from the WAN side. Adjust as needed.


15. IPv6 via AirVPN WireGuard

The upstream WiFi network provides IPv4 only. We tunnel IPv6 through AirVPN using WireGuard. Because AirVPN assigns a single /128 (not a prefix), LAN clients get ULA addresses from a locally assigned /64, masqueraded behind the tunnel address via NAT66 — the IPv6 equivalent of the IPv4 masquerade already in place.

Rather than embedding secrets in these instructions, a pair of Guile Scheme scripts in this repo build the RouterOS setup script from a .env file.

Setup

Install Guile if needed. The repo includes a flake.nix, so if you have Nix:

nix develop

Otherwise install directly:

# macOS
brew install guile

# Debian/Ubuntu
apt install guile-3.0

Copy the env template and fill in the network values:

cp .env.example .env
$EDITOR .env

Populate the AirVPN values by piping your downloaded WireGuard config through the parser. This overwrites only the AIRVPN_* keys, leaving everything else intact:

cat AirVPN_*.conf | guile wg-to-env.scm

Generate the RouterOS script:

guile generate-rsc.scm > setup.rsc

Apply it on the router:

ssh root@192.168.88.1 "/import file-name=setup.rsc"

What LAN clients see

  • Each device auto-configures a ULA IPv6 address via SLAAC (no manual setup)
  • IPv6 default gateway is the router
  • DNS is served by the MikroTik (NextDNS DoH handles resolution)
  • Outbound IPv6 is masqueraded behind the AirVPN tunnel address at AirVPN's exit node
  • IPv4 is unaffected — still routes directly through wifi1

Verification

WiFi uplink

/interface wifi print

wifi1 should show R (running) in the flags column once it associates with the upstream AP.

IPv4 connectivity

/ip dhcp-client print

wan-dhcp should show bound with an address from the upstream network.

/ping 1.1.1.1

AirVPN tunnel

/interface wireguard peers print

The last-handshake field should be non-zero and recent (within 30 s given persistent-keepalive=15s).

/ipv6 route print

The ::/0 route should show reachable via airvpn.

IPv6 from a LAN client

ping6 ipv6.google.com
{
description = "MikroTik config generator";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs }: let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in {
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = [ pkgs.guile ];
};
});
};
}
#!/usr/bin/env guile
!#
;;; generate-rsc.scm — Read .env and emit a RouterOS setup script to stdout.
;;;
;;; Usage: guile generate-rsc.scm > setup.rsc
(use-modules (ice-9 rdelim)
(ice-9 regex)
(srfi srfi-1))
(define (read-lines port)
(let loop ((acc '()))
(let ((line (read-line port)))
(if (eof-object? line)
(reverse acc)
(loop (cons line acc))))))
(define (read-env path)
(if (not (file-exists? path))
(error "Missing .env — copy .env.example and fill in values")
(filter-map
(lambda (line)
(let ((m (string-match "^([A-Z_][A-Z0-9_]*)=(.+)$" line)))
(and m (cons (match:substring m 1) (match:substring m 2)))))
(call-with-input-file path read-lines))))
(define (required env key)
(let ((v (assoc-ref env key)))
(when (or (not v) (string=? v ""))
(error (string-append "Required variable not set in .env: " key)))
v))
(define (optional env key default)
(let ((v (assoc-ref env key)))
(if (and v (not (string=? v ""))) v default)))
(define (out . args)
(for-each display args)
(newline))
(define (generate env)
(let* ((upstream-ssid (required env "UPSTREAM_SSID"))
(upstream-pass (required env "UPSTREAM_WIFI_PASSWORD"))
(travel-ssid (required env "TRAVEL_SSID"))
(travel-pass (required env "TRAVEL_WIFI_PASSWORD"))
(nextdns-id (required env "NEXTDNS_PROFILE_ID"))
(nextdns-name (optional env "NEXTDNS_DEVICE_NAME" "apTravel"))
(device-name (optional env "DEVICE_NAME" "apTravel"))
(wg-port (optional env "WG_LISTEN_PORT" "13231"))
(lan-ula (optional env "LAN_ULA_PREFIX" "fd88:1::1/64"))
(private-key (required env "AIRVPN_PRIVATE_KEY"))
(mtu (optional env "AIRVPN_MTU" "1320"))
(server-pubkey (required env "AIRVPN_SERVER_PUBKEY"))
(preshared-key (required env "AIRVPN_PRESHARED_KEY"))
(endpoint-ip (required env "AIRVPN_ENDPOINT_IP"))
(endpoint-port (required env "AIRVPN_ENDPOINT_PORT"))
(keepalive (optional env "AIRVPN_KEEPALIVE" "15"))
(tunnel-ipv6 (required env "AIRVPN_TUNNEL_IPV6")))
(out "# Generated by generate-rsc.scm")
(out "# Model: hAP ax2 (C52iG-5HaxD2HaxD)")
(out)
(out "# --- Identity ---")
(out "/system identity set name=" device-name)
(out "/system note set show-at-login=no")
(out "/system ntp client set enabled=yes")
(out "/system ntp client servers add address=pool.ntp.org")
(out)
(out "# --- Users ---")
(out "/user add name=root group=full")
(out "# From your local machine, push your SSH public key:")
(out "# ssh -4 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no root@192.168.88.1 \\")
(out "# \"/file print file=mykey.txt; file set mykey.txt contents=\\\"$(cat ~/.ssh/id_ed25519.pub)\\\"; /user ssh-keys import public-key-file=mykey.txt\"")
(out)
(out "# --- Bridge ---")
(out "/interface bridge add name=bridge comment=defconf")
(out)
(out "# --- WiFi security profiles ---")
(out "/interface wifi security")
(out "add name=Upstream passphrase=\"" upstream-pass "\" ft=no")
(out "add name=Travel passphrase=\"" travel-pass "\"")
(out)
(out "# --- WiFi interfaces ---")
(out "/interface wifi")
(out "set [find default-name=wifi1] "
"channel.band=5ghz-ax "
"channel.skip-dfs-channels=all "
"configuration.mode=station-bridge "
"configuration.ssid=\"" upstream-ssid "\" "
"security=Upstream "
"security.ft=no "
"security.ft-over-ds=no "
"disabled=no")
(out "set [find default-name=wifi2] disabled=yes")
(out "add name=wifiTravel "
"master-interface=wifi1 "
"configuration.mode=ap "
"configuration.ssid=\"" travel-ssid "\" "
"security=Travel "
"disabled=no")
(out)
(out "# --- Bridge ports ---")
(out "/interface bridge port")
(out "remove [find interface=wifi1]")
(out "add bridge=bridge interface=wifiTravel")
(out)
(out "# --- Interface lists ---")
(out "/interface list member")
(out "add interface=wifi1 list=WAN")
(out "add interface=airvpn list=WAN")
(out)
(out "# --- WAN DHCP client ---")
(out "/ip dhcp-client add interface=wifi1 default-route-tables=main name=wan-dhcp comment=defconf")
(out)
(out "# --- Management restrictions ---")
(out "/ip neighbor discovery-settings set discover-interface-list=LAN")
(out "/tool mac-server set allowed-interface-list=LAN")
(out "/tool mac-server mac-winbox set allowed-interface-list=LAN")
(out)
(out "# --- CA certificates (required before DoH) ---")
(out "# Ensure wifi1 is associated with the upstream AP before running these.")
(out "# /tool fetch url=https://curl.se/ca/cacert.pem")
(out "# /certificate import file-name=cacert.pem")
(out)
(out "# --- DNS ---")
(out "/ip dns set allow-remote-requests=yes "
"servers=1.1.1.3,1.0.0.3 "
"use-doh-server=\"https://dns.nextdns.io/" nextdns-id "/" nextdns-name "\" "
"verify-doh-cert=yes")
(out "/ip dns static add name=router.lan address=192.168.88.1 type=A comment=defconf")
(out "/ip dns static add name=dns.nextdns.io address=45.90.28.0 type=A")
(out "/ip dns static add name=dns.nextdns.io address=45.90.30.0 type=A")
(out "/ip dns static add name=dns.nextdns.io address=2a07:a8c0:: type=AAAA")
(out "/ip dns static add name=dns.nextdns.io address=2a07:a8c1:: type=AAAA")
(out)
(out "# --- NAT ---")
(out "/ip firewall nat add action=masquerade chain=srcnat "
"comment=\"defconf: masquerade\" ipsec-policy=out,none out-interface-list=WAN")
(out)
(out "# --- WireGuard (AirVPN IPv6 tunnel) ---")
(out "/interface wireguard add name=airvpn "
"private-key=\"" private-key "\" "
"listen-port=" wg-port " "
"mtu=" mtu)
(out "/interface wireguard peers add interface=airvpn "
"public-key=\"" server-pubkey "\" "
"preshared-key=\"" preshared-key "\" "
"endpoint-address=" endpoint-ip " "
"endpoint-port=" endpoint-port " "
"allowed-address=::/0 "
"persistent-keepalive=" keepalive "s")
(out)
(out "# --- IPv6 ---")
(out "/ipv6 address add address=" tunnel-ipv6 " interface=airvpn advertise=no")
(out "/ipv6 route add dst-address=::/0 gateway=airvpn")
(out "/ipv6 address add address=" lan-ula " interface=bridge advertise=yes")
(out "/ipv6 firewall nat add action=masquerade chain=srcnat "
"out-interface=airvpn comment=\"airvpn ipv6 masquerade\"")
(out "/ipv6 nd add interface=bridge ra-lifetime=1800 advertise-dns=yes other-configuration=no")
(out)
(out "# --- Allow WireGuard UDP through IPv4 input firewall ---")
(out "/ip firewall filter add action=accept chain=input "
"comment=\"allow AirVPN WireGuard UDP\" "
"protocol=udp dst-port=" wg-port " "
"in-interface-list=WAN "
"place-before=[find comment=\"defconf: drop all not coming from LAN\"]")))
(define (main)
(generate (read-env ".env")))
(main)
#!/usr/bin/env guile
!#
;;; wg-to-env.scm — Parse a WireGuard config from stdin and merge
;;; the extracted values into .env, preserving any existing entries.
;;;
;;; Usage: cat airvpn.conf | guile wg-to-env.scm
(use-modules (ice-9 rdelim)
(ice-9 regex)
(srfi srfi-1)
(srfi srfi-13))
(define (read-lines port)
(let loop ((acc '()))
(let ((line (read-line port)))
(if (eof-object? line)
(reverse acc)
(loop (cons line acc))))))
(define (parse-kv line)
"Return (key . value) for a WireGuard 'Key = Value' line, or #f."
(and (not (string-prefix? "#" (string-trim line)))
(let ((m (string-match
"^[[:space:]]*([A-Za-z]+)[[:space:]]*=[[:space:]]*(.*?)[[:space:]]*$"
line)))
(and m (cons (match:substring m 1) (match:substring m 2))))))
(define (wg-ref conf key) (or (assoc-ref conf key) ""))
(define (split-addresses addr)
"Split 'ipv4/mask,ipv6/mask' into (list ipv4-cidr ipv6-cidr)."
(let* ((parts (filter (lambda (s) (not (string-null? (string-trim s))))
(string-split addr #\,)))
(v4 (or (find (lambda (p) (not (string-contains (string-trim p) ":"))) parts) ""))
(v6 (or (find (lambda (p) (string-contains (string-trim p) ":")) parts) "")))
(list (string-trim v4) (string-trim v6))))
(define (split-endpoint ep)
"Split 'host:port' into (list host port)."
(let ((m (string-match "^(.+):([0-9]+)$" (string-trim ep))))
(if m
(list (match:substring m 1) (match:substring m 2))
(list (string-trim ep) ""))))
(define (read-env path)
"Read KEY=VALUE file into an alist."
(if (file-exists? path)
(filter-map
(lambda (line)
(let ((m (string-match "^([A-Z_][A-Z0-9_]*)=(.*)$" line)))
(and m (cons (match:substring m 1) (match:substring m 2)))))
(call-with-input-file path read-lines))
'()))
(define (env-merge base updates)
"Prepend updates to base, removing any shadowed keys from base."
(let ((update-keys (map car updates)))
(append updates
(filter (lambda (kv) (not (member (car kv) update-keys string=?)))
base))))
(define (write-env path env)
(call-with-output-file path
(lambda (port)
(for-each (lambda (kv) (format port "~a=~a\n" (car kv) (cdr kv)))
env))))
(define (main)
(let* ((conf (filter-map parse-kv (read-lines (current-input-port))))
(addrs (split-addresses (wg-ref conf "Address")))
(ep (split-endpoint (wg-ref conf "Endpoint")))
(env (read-env ".env"))
(updates `(("AIRVPN_PRIVATE_KEY" . ,(wg-ref conf "PrivateKey"))
("AIRVPN_TUNNEL_IPV4" . ,(car addrs))
("AIRVPN_TUNNEL_IPV6" . ,(cadr addrs))
("AIRVPN_MTU" . ,(wg-ref conf "MTU"))
("AIRVPN_SERVER_PUBKEY" . ,(wg-ref conf "PublicKey"))
("AIRVPN_PRESHARED_KEY" . ,(wg-ref conf "PresharedKey"))
("AIRVPN_ENDPOINT_IP" . ,(car ep))
("AIRVPN_ENDPOINT_PORT" . ,(cadr ep))
("AIRVPN_KEEPALIVE" . ,(wg-ref conf "PersistentKeepalive")))))
(write-env ".env" (env-merge env updates))
(format (current-error-port) ".env updated.\n")))
(main)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment