AllowedIPs is not access control
Contents
AllowedIPs is a routing statement, not a permission. It tells the client which destinations to send into the tunnel — a route table on a machine your friend owns and can edit at will. Scoping a peer with it restricts nothing. Enforcement has to live where the friend has no vote: a forward-chain rule on the router. Writing the provisioning kit is the easy afternoon; deciding where enforcement lives is the part that matters.The onboarding kit started as a convenience. Give a friend VPN access with one command: provision.sh alice lan, out comes a .conf and a QR code, done. Then provision.sh bob transmission — Bob only needs the media box, so scope him to that one host. The kit allocates the next tunnel address, generates a keypair, adds the peer, writes the scope, emits the config. Clean. list.sh shows who's on, remove.sh revokes by name. As a piece of tooling it's satisfying.
The satisfying part is a trap, and it's worth naming before you build the same kit. The line in Bob's config that supposedly restricts him to the media box restricts nothing.
The line that looks like a fence#
Here's the config the script hands Bob:
[Interface]
PrivateKey = <bob's key>
Address = 10.8.0.7/32
[Peer]
PublicKey = <router's key>
Endpoint = xxxxxxxx.sn.mynetname.net:51820
AllowedIPs = 192.168.88.130/32
AllowedIPs = 192.168.88.130/32 reads like "Bob may only reach the media box." It isn't. In a WireGuard client config, AllowedIPs is a routing statement: it tells Bob's device which destinations to send into the tunnel. It is a route table on Bob's machine, and Bob owns his machine. He can edit that line to 0.0.0.0/0 in a text editor and route the entire LAN through the tunnel. Nothing on the router would stop him, because the router was never consulted — the "restriction" lived entirely on the client, enforced by the client, against the client's own interests.
On the server side WireGuard uses AllowedIPs for the opposite job — cryptokey routing, deciding which source addresses a given peer's key is allowed to claim. That prevents Bob from spoofing Alice's tunnel IP. It does not decide what Bob, correctly using his own address, is permitted to reach. Same field name, two peers, two different jobs, and neither one is "access control for where Bob can go."
Enforcement lives on the router#
The only place a scope can be enforced is the box Bob doesn't control. On RouterOS that's the forward chain, keyed on Bob's tunnel source address:
/ip/firewall/filter/add chain=forward action=accept in-interface=wg-vpn \
src-address=10.8.0.7 dst-address=192.168.88.130 \
comment="peer:bob"
/ip/firewall/filter/add chain=forward action=drop in-interface=wg-vpn \
src-address=10.8.0.7 \
comment="peer:bob"
Accept Bob to the one host, then drop everything else from Bob. The order matters and the trailing drop is the whole point: RouterOS's forward chain defaults to accept, so without that explicit drop, Bob edits his AllowedIPs, routes the whole subnet in, and the router waves it through. The drop is what turns a client-side suggestion into a server-side rule. A "full LAN" friend gets the accept without the drop; a friend pinned to a CIDR gets dst-address= set to that range. The scope is a property of the router's ruleset, tagged with the peer's name in a comment so it can be found and removed as a unit.
Revocation keys on that comment. From the provisioning host it is one ssh call, because remove takes the whole list find returns:
ssh mikrotik "/ip/firewall/filter/remove [find comment=\"peer:$name\"]; \
/interface/wireguard/peers/remove [find comment=\"peer:$name\"]"
Match with comment=, not the regex comment~: peer:bob as a regex also matches peer:bobby.
It stays per-peer because keys are per-peer. Every friend gets their own keypair — no shared secrets, no "rotate the key and re-onboard everyone" — so remove.sh bob deletes Bob's peer and Bob's firewall rules and touches nobody else. One name in, one person's access out.
The rake in the invalid flag#
One gotcha the kit hit while writing rules programmatically: a freshly added RouterOS firewall rule can momentarily print with the I (invalid) flag while the in-interface=wg-vpn reference resolves. A script that adds a rule and immediately greps a terse print for X or I to confirm health can read that transient I and panic. Don't trust the flag letter in a race; ask the rule directly:
:put [/ip/firewall/filter/get [find comment="peer:bob" action=drop] invalid]
Bob has two rules under that comment, and get wants exactly one, so the find names the drop rule.
It settles to false once the interface reference binds. The flag was mid-resolution, not broken — a distinction a terse print doesn't make and a direct get does.
A name that outlives the address#
Last piece, because a config with a raw IP endpoint is a time bomb: the ISP maps a public address today, but a raw 1.2.3.4:51820 in fifteen handed-out configs breaks the day that address rotates, and re-issuing everyone's config is the worst kind of chore. MikroTik's cloud DDNS gives the router a stable name:
/ip/cloud/set ddns-enabled=yes
/ip/cloud/print
The dns-name field in that print is the router's name under sn.mynetname.net, and every config uses it: Endpoint = xxxxxxxx.sn.mynetname.net:51820. The name tracks the public IP; when the address changes, MikroTik's DDNS updates the record. A WireGuard client resolves the endpoint name when the tunnel is brought up, not on every handshake, so a client picks up the new address the next time its tunnel comes up. The configs never have to change. One stable name, N disposable peers behind it.
The shape of it#
The tooling is the easy part. provision.sh allocating addresses and shelling out wg genkey is an afternoon. The hard part is the design decision underneath it: enforcement belongs on the machine the friend doesn't own, and every client-side "restriction" is a preference the client can revoke. AllowedIPs is a routing hint you're trusting a friend to honor. The forward-chain drop is a rule the friend can't reach. When those two disagree — and a friend who edits one line makes them disagree — only one of them is holding.
The kit builds on the WireGuard server from the previous post. That post's lesson was that a config is a secret you have to hand over carefully. This one's is that the config is also a promise you should never rely on the other end to keep.
Filed under: wireguard, vpn, mikrotik, firewall, networking
Read next#
- The iPhone camera wanted to Google my private key — the WireGuard server this kit builds on, and how handing someone a config can leak it
- Transmission is a remote-code-execution box by default — a control the controlled party can rewrite is not a control
- Hardening the box you're logged in through — changing the router this kit talks to without cutting the connection the change goes over
Sources#
- WireGuard: cryptokey routing —
AllowedIPsas source-address validation on the server and as an outbound route selector on the client; the two roles of the same field. - MikroTik: WireGuard — peer configuration and the
allowed-addresssemantics on the RouterOS side. - MikroTik: Filter (forward chain) — default
acceptpolicy on the forward chain and why per-peer scoping needs an explicit trailing drop. - MikroTik: Cloud —
/ip cloudDDNS producing a stable*.sn.mynetname.netname that tracks the public IP.