bmo.guru/ ~

Routely: building a router I can't lock myself out of

Three weeks pair-programming with Claude on a Linux router with pfSense-class features, a UniFi-style UI, and one rule above all: every change rolls itself back unless I confirm it.

Why build a router?#

Years of IT work left me with a very specific fear: the moment you hit "apply" on a firewall change, the page stops loading, and you realize you just cut off the network you were configuring. Remotely. Sometimes at someone else's office.

So when I started thinking about replacing my home router, I wanted two things that don't usually come together:

  • pfSense-level features: real firewall rules, VLANs, multi-WAN, IDS/IPS, VPN, traffic shaping, DNS filtering.
  • A UniFi-style UI: clean, live, and obvious, without being tied to someone else's hardware.

And one rule above all: it should be really hard to lock myself out.

That turned into Routely: a router appliance that runs on plain Ubuntu Server 24.04 on any box with two network ports. I built it over about a month. This is a tour of what it does and a few things I learned along the way.

The Routely dashboard: two WANs, a live topology, networks, clients and security at a glance

What's in it#

The short version, roughly in the order it was built:

  • Networks and VLANs, DHCP with reservations, DNS, and IPv6 (DHCPv6 prefix delegation from the ISP, a /64 per network).
  • Firewall and NAT: zone-style rules, port forwards with hairpin NAT, aliases (named groups of hosts, devices or ports), and a live firewall log streamed to the browser.
  • Security: Suricata IDS/IPS, IP blocklists and GeoIP blocking, and DNS filtering for ads and malware with per-network exemptions.
  • WireGuard VPN, where adding a phone means scanning a QR code.
  • Multi-WAN: failover and weighted load balancing, health checks, and policy routes with a kill switch.
  • Smart queues (CAKE) so a big download doesn't wreck everyone's latency.
  • Parental controls with schedules ("school nights 21:30 to 07:00") and a "pause internet" button.
  • UPnP/NAT-PMP, dynamic DNS, an NTP server, Wake-on-LAN.
  • Backups, a revision history and a one-click revert for every change.
  • Zero-touch first boot and a recovery console on the box's own screen.
  • And yes, a light mode, because someone asked.

It's about 35k lines of Go, 28k lines of TypeScript and 12k lines of tests.

How it's put together#

The design comes down to one decision: the web UI has no privileges. The UI is a Next.js app running as an ordinary user. Everything that touches the kernel happens in a small Go daemon, routelyd, and the two talk only over a local Unix socket. The browser never talks to the daemon directly.

Routely's architecture

The config is one declarative document. Every change creates a new immutable revision. Rendering is a pure function from config to nftables ruleset, dnsmasq config and so on, which made it easy to golden-test. Applying a revision means one atomic nftables load plus reconciling interfaces and routes over netlink. The daemon supervises every helper process (dnsmasq, dhcpcd, Suricata, chrony, miniupnpd) and owns all the routes itself. For example, dhcpcd runs with nogateway, so it can't fight the multi-WAN logic.

Rule #1: don't lock me out#

Commit-confirm#

This is the feature I'm proudest of, and the one the whole design is built around. Every change applies live, then rolls itself back unless you confirm it within 60 seconds. If the change killed your connection, you don't have to do anything: wait a minute and the old config comes back on its own.

The commit-confirm banner: "Revision #90 is live — confirm it or it rolls back automatically", with a countdown, Revert now and Keep changes

(The default window is 60 seconds; I stretched it for the screenshot.)

Try it here. One change is fine and one cuts you off:

Commit-confirm, simulated
<style>
  .wrap { display: grid; gap: 12px; max-width: 560px; }
  .row { display: flex; gap: 8px; flex-wrap: wrap; }
  .scr { border: 1px solid var(--rule-strong); border-radius: 8px; padding: 14px; min-height: 120px; position: relative; overflow: hidden; }
  .ban { display: none; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; background: color-mix(in srgb, var(--accent-2) 16%, transparent); border: 1px solid color-mix(in srgb, var(--accent-2) 45%, transparent); margin-bottom: 10px; }
  .ban .t { flex: 1; color: var(--ink); font-size: 12px; }
  .ban .c { color: var(--accent-2); font-size: 18px; font-weight: 600; min-width: 34px; text-align: right; }
  .st { font-family: var(--serif); font-size: 15px; color: var(--mid); }
  .st b { color: var(--ink); font-weight: 600; }
  .dead { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; flex-direction: column; gap: 6px; background: var(--bg); color: var(--mute); text-align: center; padding: 10px; }
  .dead .big { color: var(--red); font-size: 14px; }
  .log { font-size: 11px; color: var(--mute); min-height: 60px; white-space: pre-line; }
  button.primary { border-color: var(--accent); color: var(--accent); }
</style>
<div class="wrap">
  <div class="row">
    <button id="good">Apply: add a port forward</button>
    <button id="bad">Apply: block LAN → router</button>
  </div>
  <div class="scr">
    <div class="ban" id="ban"><span class="t" id="bt">Revision is live. Confirm it or it rolls back automatically.</span><span class="c" id="cd">10</span><button id="keep" class="primary">Keep</button></div>
    <div class="st" id="st">Running <b>revision #41</b>. Everything is fine.</div>
    <div class="dead" id="dead"><span class="big">This site can't be reached</span><span>Your own firewall rule just blocked you. Don't panic, just wait…</span><span id="dc"></span></div>
  </div>
  <div class="log" id="log"></div>
</div>
<script>
const $ = id => document.getElementById(id);
let rev = 41, timer = null, left = 0, cut = false;
const log = m => { $("log").textContent = (new Date().toTimeString().slice(0, 8)) + "  " + m + "\n" + $("log").textContent.split("\n").slice(0, 4).join("\n"); };
function apply(bad) {
  if (timer) return;
  rev++; cut = bad; left = 10;
  $("good").disabled = $("bad").disabled = true;
  log(`applied revision #${rev} (rollback in ${left}s unless confirmed)`);
  $("st").innerHTML = `Running <b>revision #${rev}</b>${bad ? "" : " with a new port forward"}.`;
  $("ban").style.display = "flex"; $("dead").style.display = bad ? "flex" : "none";
  tick(); timer = setInterval(tick, 1000);
}
function tick() {
  $("cd").textContent = left; $("dc").textContent = `rollback in ${left}s`;
  if (left-- <= 0) { clearInterval(timer); timer = null; rev--; done(`no confirmation: rolled back to revision #${rev}`); $("st").innerHTML = `Back on <b>revision #${rev}</b>. ${cut ? "You're connected again, and you didn't have to touch anything." : "The change was reverted."}`; }
}
function done(m) { log(m); $("ban").style.display = "none"; $("dead").style.display = "none"; $("good").disabled = $("bad").disabled = false; }
$("good").onclick = () => apply(false);
$("bad").onclick = () => apply(true);
$("keep").onclick = () => { if (!timer || cut) return; clearInterval(timer); timer = null; done(`revision #${rev} confirmed: permanent`); $("st").innerHTML = `Running <b>revision #${rev}</b>. Confirmed.`; };
</script>

The real thing lives in the daemon, not the browser, so closing the tab, losing Wi-Fi or rebooting your laptop doesn't matter. The timer keeps running on the router.

More safety nets#

  • Impact preview. Before applying, the daemon checks whether the change would cut off the device you're using right now: which network you're on, whether a rule blocks the UI, whether the LAN subnet is moving. If it would, it tells you, and where the UI will be afterwards.
  • Last-known-good boot. If the router reboots into a config that never got confirmed, it boots the last good one instead. If even that fails, it falls back to a minimal safe mode.
  • A console on the box itself. Plug in a monitor and tty1 shows a menu: status, revert the last change, re-run detection, reset the admin password, and the big red button, give the network back to Ubuntu.
  • An installer that can undo itself. Installing hands the network ports from Ubuntu's netplan over to Routely. If the router isn't healthy within 3 minutes, the installer hands them back. uninstall.sh restores the machine exactly as it was, including which services were enabled before.

Zero-touch first boot#

The other thing I cared about: plug it in and it should just work, with customizing coming later. On first boot Routely probes every port at once for DHCP, PPPoE and IPv6 router advertisements, runs a real internet check on each, and picks the WAN on its own. It then chooses a LAN subnet that doesn't conflict with anything upstream, binds each role to the port's MAC address (so a kernel renaming eth0 can't swap WAN and LAN), and starts serving DHCP.

In the test lab that takes about 17 seconds from start to clients being online. Then the setup page shows what it did and why:

The first-boot setup page: "You're online", with the detected internet connection, the home network it created, and which physical port is which

It also notices the awkward cases and says so in plain language: double NAT behind an ISP router, CGNAT, a second DHCP server on the LAN (the classic "the AP is still in router mode"), or a NIC that's hanging. My target box has Intel e1000e NICs, which have a well-known habit of hanging under load, so there's a mitigation for that too.

A quick tour#

The firewall log streams live from the kernel (NFLOG → the daemon → server-sent events), and every entry says which rule matched: a firewall rule, the default policy, a blocklist, GeoIP, or a parental control.

The live firewall log, filtering by action, source and network, with each drop attributed to its rule

Security combines Suricata alerts, DNS filtering and threat lists in one place. Suricata sits after the firewall (so it only inspects traffic the firewall already allowed), and it fails open: if Suricata crashes or restarts, traffic keeps flowing uninspected rather than the internet going down. By default it only blocks real threats. "Policy violation" signatures, which flag things like VPN apps and remote-access tools, raise alerts but don't block, so legitimate apps keep working.

The security overview: DNS queries blocked, threats detected vs blocked per hour, top signatures and sources

Parental controls are built on schedules and aliases. "Bedtime" blocks the kids' devices on school nights, and a pause button cuts the game console off for 15 minutes, an hour, or until morning. Pausing also drops existing connections, so a download already in progress doesn't keep going.

Parental controls: a Bedtime schedule, a Homework hour, and a paused game console

And because every change is a revision, there's a complete history with diffs and one-click restore:

Config history: every revision with its summary, a diff, the JSON and a restore button

Plus the light mode:

The dashboard in light mode

Bufferbloat, fixed#

My favorite number from the project. The lab has a fake ISP line shaped to 50/10 Mbps with an oversized buffer, like a lot of real cable modems. Here's the ping time while saturating the download, before and after turning on smart queues (CAKE on the WAN, plus an IFB device to shape inbound traffic):

Ping under a saturating download (lab, 50/10 Mbps line)

There are also traffic classes: DSCP marks get saved into conntrack on the way out and restored on replies with act_ctinfo, so a video call stays smooth while Steam downloads in the background.

Testing a router without breaking my network#

The cheat code for this whole project was a Docker network lab. The lab is a full fake world:

  • two fake ISPs, one with IPv6 prefix delegation and one with a bufferbloated line;
  • clients on VLANs, plus an "attacker" and hosts that GeoIP places in other countries;
  • a road-warrior WireGuard client, a fake dynamic-DNS provider and an NTP source.

The real routelyd runs inside it in live mode, with real nftables, real dnsmasq and real Suricata. Every phase ended with a smoke test that's now at 553 checks: clients get DHCP, the guest network can't reach the LAN, the IPS drops the fake malware beacon, unplugging the primary ISP fails over, and so on.

There's a separate lab for first boot (11 scenarios: plain DHCP, PPPoE, CGNAT, cables in the "wrong" ports, a modem that boots late, a rogue DHCP server and more), plus an installer test that runs the real installer inside a systemd Ubuntu container, including its rollback, its watchdog and uninstall.

Things that bit us#

  • nftables time matching has a split personality. meta hour "21:30" is converted using the timezone of whatever loaded the ruleset, but meta day is evaluated in UTC. A schedule crossing midnight would be off by a day for part of the year. The fix: render schedules as UTC numbers and re-render when daylight saving time changes.
  • Ubuntu mounts /run noexec, so the dhcpcd hook script couldn't live there. When we moved it, one code path kept the old location and the WAN never learned its gateway. 74 lab checks failed at once, which is precisely why the lab exists.
  • GSO makes packet counters lie. With segmentation offload, the kernel can hand Suricata more packets than the firewall rule counted, so a "bypassed packets" stat computed from the difference went haywire. It's now measured only over periods when Suricata was actually down.
  • Installing chrony uninstalls systemd-timesyncd (they both provide time-daemon). The uninstaller would have left the machine with no time sync, so it now restores whichever one was there before.
  • A timestamp in a comment made the rendered ruleset different every time, which made tests flaky and caused pointless reloads. Never put now() in generated config.
  • Next.js 16's dev server blocks requests from origins it doesn't recognize. Opening the UI by LAN IP rendered fine, but no button did anything until we listed the machine's own addresses in allowedDevOrigins.

What's next#

So far all of this has run only in the lab, not on real hardware. Next up:

  1. Staging: install it on a two-port Dell OptiPlex behind my current router (double NAT, a separate test subnet), so nothing at home depends on it. Let it soak for a while.
  2. Cutover: move my current router's settings over (same LAN subnet, reservations, port forwards), turn the old NETGEAR into a plain access point, swap cables, and confirm within the countdown. The rollback plan is "swap the cables back".

After that: HTTPS for the UI, automatic updates with rollback, scheduled off-box backups, 2FA, notifications, and eventually Unbound, Tailscale integration and high availability.

I'll post again once it's actually routing my house. Hopefully without a "so I killed the network" section.