I have a MikroTik router at home. For a long time my way of managing it was to open Winbox, click through a few nested windows, change a value, and hope I remembered what the old one was.
That's fine when you have one internet connection. It stops being fine when you have more than one.
With two WANs you aren't changing "the internet settings" anymore. You have PPPoE clients, DHCP clients, default routes with different distances, NAT rules per interface, a failover mechanism, and port forwards that need to know which connection they belong to. RouterOS keeps each of those in a different menu. Change one, forget the matching change somewhere else, and you find out later, usually at a bad time.
So I did the thing programmers do when something annoys them enough. I spent far more time building a tool than I would have spent just being careful.
It's called net-pilot. It's a Rust service with a SQLite database and a small React UI, and it talks to the router through the RouterOS API. The whole idea fits in one sentence: I describe what I want, and it shows me a diff before it changes anything.
The database is the truth, the router is just the current state
This sounds obvious written down, but it's not how the project started.
For the first few days net-pilot read settings straight off the router and let me edit them in place. It was basically a nicer Winbox. Then on day five there's a commit in the history called change to db first config, and that's the moment the project actually became useful.
After that change, SQLite holds what I want:
- which physical ports are WAN and which are LAN
- each WAN connection, PPPoE or DHCP
- the failover order
- port forwards
- DHCP reservations
- per-WAN routing overrides, for when I want certain destinations to always go out a specific connection
The router is only "what currently exists". The app's main job is comparing the two and telling me where they disagree.
Once you think about it that way, a lot of things get simpler. Resetting the router isn't scary, because the config lives somewhere else. Moving to a new router is mostly "point it at the new IP and apply". And I can finally answer "what did I change?" without relying on memory.
A diff you can actually read
The comparison produces a list of differences, and I modeled that list as a Rust enum instead of a pile of strings:
#[derive(serde::Serialize, schemars::JsonSchema)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum DiffItem {
Bridge(BridgeDiff),
Pppoe(PppoeDiff),
Dhcp(DhcpDiff),
Dns(DnsDiff),
StaticRoute(StaticRouteDiff),
Srcnat(SrcnatDiff),
FailoverScript(FailoverScriptDiff),
FailoverScheduler(FailoverSchedulerDiff),
PortForward(PortForwardDiff),
PortRole(PortRoleDiff),
DhcpReservation(DhcpReservationDiff),
WanRoutingTable(WanRoutingTableDiff),
WanMangleRule(WanMangleRuleDiff),
WanAddressListEntry(WanAddressListEntryDiff),
WanRoutingRule(WanRoutingRuleDiff),
}Most of those inner types are themselves enums with cases like Missing, Changed and Orphaned. A PPPoE diff, for example, doesn't just say "something is different". It says whether the interface changed, whether the username changed, whether the password changed, and whether the default-route setting changed.
That detail matters when you're about to push config to the box that gives your whole house internet. "3 changes pending" is not helpful. "WAN 2's PPPoE username differs from what's saved" is.
Because the API is typed end to end, the OpenAPI spec describes every one of these variants, and the React client is generated from that spec. The UI gets a proper tagged union to switch on instead of guessing at JSON shapes. There's a pending-changes counter in the navigation, and I can apply one section at a time or everything at once.
Only touch what you created
The naive version of "remove things that aren't in the database" has an obvious problem. It would happily delete every NAT rule I had ever made by hand. Not great.
So net-pilot only manages things it owns, and it marks ownership with a comment prefix. Every port forward it creates gets a comment like net-pilot:home-server:
let desired_comments: Vec<String> = forwards
.iter()
.map(|f| format!("net-pilot:{}", f.name.to_lowercase().replace(' ', "-")))
.collect();
for nat in managed_nat {
if !desired_comments.contains(&nat.comment) {
router.remove_nat_rule_by_id(&nat.id).await?;
}
}When it reads rules from the router, it filters to that prefix first. Anything without the prefix is invisible to it. An "orphaned" item in the diff means "this has my name on it, but I no longer have a row for it", which is exactly the case where deleting it is safe.
It's a low-tech trick, and it's the reason I trust the tool enough to press the apply button.
Hairpin NAT, or why my own server didn't work from inside my house
This one confuses almost everybody the first time.
Say you forward port 8443 on your public IP to a machine on your LAN. From your phone on mobile data, https://your-public-ip:8443 works. From your laptop at home, on the same Wi-Fi as the server, it just hangs.
Two things are going wrong.
First, the port-forward rule is attached to the WAN interface. Your laptop's packet never arrives on the WAN interface. It arrives on the LAN bridge, so the rule never matches.
Second, even if you fix that, the server sees a request from 192.168.88.x, which is on its own subnet. So it replies directly to your laptop instead of going back through the router. Your laptop sent a packet to the public IP and gets a reply from a private one, doesn't recognize it, and drops it.
The fix is two rules. A copy of the forward that also listens on the LAN bridge, and a masquerade rule so the replies come back through the router. net-pilot generates both, with their own comment prefixes so they're tracked like everything else:
/ip firewall nat add chain=dstnat action=dst-nat in-interface=bridge-lan \
protocol=tcp dst-port=8443 to-addresses=192.168.88.20 to-ports=443 \
comment="net-pilot:hairpin:home-server"
/ip firewall nat add chain=srcnat action=masquerade out-interface=bridge-lan \
comment="net-pilot:hairpin-srcnat"The masquerade rule is only created while at least one enabled forward exists, and it's removed again when the last one is disabled. I like that the database decides this. I never have to remember that the second rule exists.
Failover that doesn't need my app to be running
This is the design decision I'm happiest with.
My first instinct was for net-pilot to watch the connections and switch routes when one died. Then I thought about what happens when the machine running net-pilot reboots, or the process crashes, or I break it while working on it. The internet going down because my config editor is down would be a ridiculous failure mode.
So net-pilot doesn't do failover. It writes a RouterOS script that does failover, installs it on the router, and adds a scheduler entry that runs it every 5 seconds. After that, the router takes care of itself.
The logic is simple. Each WAN has a default route whose distance is its priority, so 1, 2, 3 and so on. Lower distance wins. On every run, the script pings 1.1.1.1 and 8.8.8.8 twice each through each connection. At least 2 replies out of 4 counts as healthy.
Here's a trimmed version of the part that makes the decision:
:set healthy ($replies >= 2);
:if (($healthy = false) && ($fc >= 2) && ($curDist != 250)) do={
/ip route set $routeId distance=250;
:log warning ("Failover: " . $ifName . " taking offline");
# notify me on Telegram
}
:if (($healthy = true) && ($rc >= 2) && ($curDist != $normalDist)) do={
/ip route set $routeId distance=$normalDist;
:log info ("Failover: " . $ifName . " recovered");
# notify me on Telegram
}fc and rc are failure and recovery counters. A connection has to fail two checks in a row before its route gets pushed to distance 250, and it has to pass two checks in a row before it's restored. Without that, one dropped ping would make the routes flap back and forth, which is arguably worse than just being down.
When a switch happens, the router sends me a Telegram message through tgn, a small notification service I run for myself. A dead connection shows up as a message on my phone instead of as a mystery.
And net-pilot shows the script in the diff like anything else. If I change the failover order in the UI, the script on the router is now out of date, and it shows up as a pending change.
A backup before every apply
Pushing config to a router is exactly the kind of thing you want to be able to undo.
When backups are enabled, every apply endpoint takes a backup of the router before it changes anything. It grabs two things: a readable /export over SSH, which is nice to diff by eye, and a binary .backup file, which is what you'd actually restore from. Both get sent to me on Telegram. There's also a scheduled daily backup at a time I choose.
If the backup fails, the apply doesn't run, and I get a Telegram message saying why. I'd rather not change the router at all than change it without a way back.
It started as a Go project, for about a day
The git history of this project is a little embarrassing and I kind of love it.
The first commits are Go, using templ for HTML, with a hand-rolled runtime for streaming parts of the dashboard as they loaded. I wrote tests for it and hardened it. Then, the same day, there's a commit called switch to rust streaming dashboard that deletes all of the Go.
The streaming and caching ideas I was playing with didn't disappear, though. They went into Ferrax, the Rust web framework I maintain, on that same day. Ferrax now has streaming HTML slots and a small cache because this router app wanted them first.
Five days later I moved the UI again, from server-rendered pages to a React single-page app with a generated API client. A streaming dashboard is fun. A config editor full of dialogs, reorderable failover lists and live diff counts really wants client-side state. I had to build it the wrong way first to see that.
What's still rough
It's a tool for my own network, and it shows.
- There's a health-check function that confirms there's an active default route and that
1.1.1.1answers a ping. It isn't wired into the apply flow yet. The plan is automatic rollback when an apply breaks connectivity. Right now the backup is the rollback, and I'm the one doing it. - It manages exactly one router.
- Router credentials sit in SQLite in plain text. That's fine for a box on my LAN and not fine for anything else.
Why I bothered
The idea behind net-pilot isn't new. Terraform, Kubernetes and pretty much every infrastructure tool worth using works the same way: keep the desired state somewhere safe, look at the real world, compute the difference, and only then act.
I just hadn't applied it to the one piece of infrastructure every person in my house depends on. Now I can change my network and know exactly what's going to happen before it does.
comments
view on github ->