[donny@scyber ~]$ cat ./blog/home-network-as-code.md

Home Network as Code: Zero-Trust Segmentation on a Prosumer Router

July 22, 2026 12 min read
Networking Infrastructure Security

I run my home network the way I run production: every change is a small, reversible, verified script; the network is split into three firewall-isolated VLANs; and every device's DNS is filtered and encrypted, per client. It all sits on a prosumer router and a cheap managed switch — which means some of what I wanted turned out to be impossible. This post covers the build, the scripts, and the three places the hardware said no.

## Why Treat a Home Network Like Production?

Most home networks are an accident. You get a gateway from your ISP, you plug things in, and the defaults decide your security posture for you. The default is one flat network, where a $30 smart plug — made by a company you'll never hear from again — sits next to the laptop you do your banking on, and nothing stops it from opening a connection.

I do cloud and product security for a living, and at some point it started to bother me that my own house wasn't getting the same treatment. So I rebuilt it, using the same habits I bring to infrastructure I get paid to defend. The stakes at home are lower. The failure modes are the same.

I started at the edge. The ISP's gateway went into true bridge mode, so it stops being a router and becomes a dumb pipe, and my own router — a prosumer box running an OpenWrt-based firmware — became the only router on the public IP. One NAT instead of two, and one firewall to reason about instead of a double layer where you're never sure which box dropped the packet. The WAN side is default-deny (input DROP): nothing from the internet gets in unless I've explicitly allowed it, and I've allowed almost nothing.

OpenWrt is what makes the rest of this post possible. Under the web UI there's a real Linux userland and a real configuration system, which means segmentation, DNS, and remote access can all be driven by scripts instead of clicks. Clicks don't leave a record. Scripts do.

## Infrastructure as Code, for a Router

The phrase "network as code" gets thrown around loosely, so I'll be concrete about what I actually mean. Most changes to the router are small scripts that talk to it over SSH and drive OpenWrt's configuration system, UCI — mostly shell, with Python where the logic gets fun. The scripts follow five rules:

Dry-run by default. A script prints what it would do and changes nothing unless I pass --apply. Reading the transcript before committing has caught more bad changes than everything else on this list combined.

Snapshot before you mutate. Anything that changes state takes a full config backup first, so "undo" is always one restore away.

Verify, and roll back automatically on failure. A disruptive change arms a health check; if the network doesn't come back the way it should, the script restores the snapshot on its own rather than leaving me locked out of my own router.

Secrets never live in the repo. The SSH key is dedicated to this job, credentials are referenced by name from the OS keychain, and a secret-scanner runs before every commit so a key can't slip in by accident.

Lint and scan — matched to the language, not to one favorite tool. Linting and security scanning are different jobs: one catches bugs and sloppy style, the other flags genuinely unsafe patterns. So each script gets the checks that fit it — ShellCheck for the shell ones; Ruff for Python style and correctness, Bandit for Python security, and some others... A pre-commit hook runs the lot, so "I forgot to check" stops being a failure mode. It's ordinary code-review hygiene, pointed at my own house.

The skeleton of one of these scripts:

#!/usr/bin/env bash
set -euo pipefail

# apply-vlan-forwarding.sh — idempotent; DRY-RUN by default, --apply to commit.
ROUTER="${ROUTER:?set ROUTER (host from ~/.ssh/config)}"
APPLY="${1:-}"

run() {
  if [[ "$APPLY" == "--apply" ]]; then
    ssh "$ROUTER" "$1"
  else
    echo "DRY-RUN: $1"
  fi
}

# 1. Snapshot the full config before any mutation.
run "sysupgrade -b /tmp/cfg-backup-\$(date +%s).tar.gz"

# 2. Make the change (see the segmentation section for what this does).
run "uci add firewall forwarding"
run "uci set firewall.@forwarding[-1].src='trusted'"
run "uci set firewall.@forwarding[-1].dest='iot'"

# 3. Commit and reload only the firewall service.
run "uci commit firewall && /etc/init.d/firewall reload"

Run it bare and you get a transcript of intended commands. Run it with --apply and it does exactly what the transcript said. Nothing lives only in my head, and there's no "I think I changed that setting last spring." The scripts are the record.

## The Architecture at a Glance

Here's the whole thing at a glance — the trust boundaries and the DNS path. In the diagram, a thick arrow is allowed traffic; a dotted arrow is blocked or isolated.

graph TD NET["🌐 Internet"] --> ISP["ISP gateway
bridge mode — dumb pipe"] ISP --> WAN subgraph RTR["Prosumer router · OpenWrt — sole router, single-NAT"] direction TB WAN["WAN zone
input = DROP (default-deny)"] FW["Firewall zones +
inter-VLAN policy"] AGH["AdGuard Home
LAN resolver · intercepts :53"] REF["Avahi mDNS reflector
Trusted ↔ IoT only"] TS["Tailscale
remote access (subnet router)"] WAN --> FW end FW --> TRUST["🔒 Trusted
10.10.10.0/24
laptops · phones"] FW --> IOT["📷 IoT
10.20.20.0/24
cameras · plugs · TV · speakers"] FW --> GUEST["👥 Guest
10.30.30.0/24
internet-only"] %% Asymmetric inter-VLAN policy (the security core) TRUST ==>|"allow: cast / control"| IOT IOT -.->|"blocked"| TRUST GUEST -.->|"isolated"| TRUST GUEST -.->|"isolated"| IOT %% Per-client encrypted DNS TRUST ==>|":53 redirect"| AGH IOT ==>|":53 redirect"| AGH GUEST ==>|":53 redirect"| AGH AGH ==>|"DoQ · per-client profile"| NDNS["☁️ NextDNS
per-client filtering"] IOT -.->|"DoH/DoT egress blocked"| BLK["🚫 resolver bypass denied"] GUEST -.->|"DoH/DoT egress blocked"| BLK classDef trust fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px; classDef iot fill:#fff3e0,stroke:#e65100,stroke-width:2px; classDef guest fill:#eceff1,stroke:#455a64,stroke-width:2px; classDef block fill:#ffebee,stroke:#b71c1c,stroke-width:2px; classDef ext fill:#e3f2fd,stroke:#0d47a1,stroke-width:2px; class TRUST trust; class IOT iot; class GUEST guest; class BLK block; class NET,ISP,NDNS ext;

Diagram, in words:

The internet reaches an ISP gateway running in bridge mode, so an OpenWrt-based prosumer router is the sole router on the public IP. The router's WAN zone is default-deny. On the router, a firewall enforces three firewall-isolated VLANs — Trusted, IoT, and Guest — plus AdGuard Home (the LAN DNS resolver), a scoped Avahi mDNS reflector (Trusted↔IoT only), and Tailscale for remote access. Inter-VLAN policy is deliberately asymmetric: Trusted devices may reach IoT devices to cast to or control them, but IoT cannot initiate back into Trusted, and Guest is isolated from both. Every VLAN's plaintext DNS is redirected to AdGuard Home, which forwards to NextDNS over DNS-over-QUIC with a per-client profile; IoT and Guest are additionally blocked from DoH/DoT egress so they cannot bypass the resolver.

## Zero-Trust Segmentation, and Why It's Asymmetric

The single most valuable thing you can do to a flat home network is stop it from being flat. I split the network into three firewall-isolated VLANs, using a scheme tidy enough that I never have to look it up: the VLAN ID is the repeated octet in the subnet, and the gateway is always .1.

  • Trusted10.10.10.0/24 — personal laptops and phones.
  • IoT10.20.20.0/24 — cameras, smart speakers, plugs, a TV, a printer, a thermostat.
  • Guest10.30.30.0/24 — internet-only, isolated from everything else.

The policy between the segments is deliberately one-way. I want to cast video to the TV and pull up a camera feed from my phone, so Trusted has to reach IoT. But cheap cameras get compromised all the time, and a compromised camera should not be able to open a connection into the network where my real devices live. So: Trusted → IoT is allowed; IoT → Trusted is denied. Guest can talk to neither.

OpenWrt makes the one-way part almost free. Firewall zones don't forward traffic to each other by default — a forwarding only exists if you create it. So there's no block rule enforcing the asymmetry. There's one forwarding, in one direction, and nothing else:

# Allow Trusted -> IoT so I can cast/control devices.
uci add firewall forwarding
uci set firewall.@forwarding[-1].src='trusted'
uci set firewall.@forwarding[-1].dest='iot'
uci commit firewall

# There is deliberately NO iot -> trusted forwarding.
# The block is the thing I DIDN'T write. Default-deny does the work.

I trust this arrangement more than I'd trust a DROP rule. A DROP rule can be edited, reordered, or accidentally disabled. A forwarding that was never created can't be any of those things.

## Per-Client, Encrypted DNS

DNS is the most underrated control in a home network. Nearly everything a device does starts with a name lookup, which means the resolver sees intent before any connection is made — and can shape it. My setup runs AdGuard Home on the router as the LAN resolver, and forwards upstream to NextDNS over DNS-over-QUIC (DoQ, standardized in RFC 9250 on port 853). The queries leaving my network are encrypted; the resolution happening inside it is filtered per device.

Two details are doing most of the work here.

Devices can't opt out of the resolver. A firewall redirect transparently DNATs every VLAN's outbound plaintext :53 to AdGuard Home, so a gadget with a hardcoded 8.8.8.8 gets quietly answered by my resolver instead of Google's. That covers plaintext, but the modern bypass is encrypted DNS — a device shipping its own DoH or DoT resolver to route around me. DoT and DoQ both live on port 853, so those I simply reject at the edge for the untrusted segments:

# Force all plaintext DNS to AdGuard Home (per VLAN zone).
uci add firewall redirect
uci set firewall.@redirect[-1].name='Intercept-DNS-IoT'
uci set firewall.@redirect[-1].src='iot'
uci set firewall.@redirect[-1].proto='tcp udp'
uci set firewall.@redirect[-1].src_dport='53'
uci set firewall.@redirect[-1].dest='iot'
uci set firewall.@redirect[-1].dest_port='53'
uci set firewall.@redirect[-1].target='DNAT'

# Deny DoT/DoQ egress (port 853) for IoT so devices can't self-encrypt around me.
uci add firewall rule
uci set firewall.@rule[-1].name='Block-DoT-DoQ-IoT'
uci set firewall.@rule[-1].src='iot'
uci set firewall.@rule[-1].dest='wan'
uci set firewall.@rule[-1].proto='tcp udp'
uci set firewall.@rule[-1].dest_port='853'
uci set firewall.@rule[-1].target='REJECT'
uci commit firewall

DoH is the gap. It rides port 443 with all the normal HTTPS traffic, so you can't block the port without breaking the web. What you can do is deny the known public DoH endpoints — maintained blocklists exist for exactly this — and let NextDNS's bypass protection handle the stragglers. It's a partial control, and I track it as one.

The dashboard speaks in names, not IPs. AdGuard Home pins client identity by DHCP reservation, and I group clients by device class — all the cameras share one NextDNS profile, all the speakers another — so filtering policy is set per role instead of per gadget. The nice touch is on the encrypted upstream: NextDNS lets you encode a label in the DoQ endpoint hostname, so instead of every query showing up as "the router's IP," the dashboard attributes it to a readable class:

# AdGuard Home upstream, per device-class (DoQ to NextDNS).
# The hostname label before the profile ID is what the dashboard displays.
quic://cameras-<profile-id>.dns.nextdns.io
quic://speakers-<profile-id>.dns.nextdns.io

When a camera reaches for a domain it has no business touching, the query log now tells me it was a camera — not just "the router." No spreadsheet of MAC addresses required.

## Remote Access Without an Extra Front Door

I used to run a self-hosted WireGuard server on the router for getting back into the Trusted network from the road. It worked fine. It was also an open inbound port on my public IP — one more listening service to patch and monitor. I retired it in favor of Tailscale, which builds its mesh with outbound connections and NAT traversal, so nothing on my edge listens for the internet at all. Smaller attack surface, and less of my time spent being a VPN administrator. WireGuard didn't really lose, either — it's still the engine underneath Tailscale.

The router runs as a Tailscale subnet router, advertising the Trusted subnet so my laptop and phone can reach home devices without installing a client on each one:

# On the router: advertise the Trusted subnet to my tailnet.
tailscale up --advertise-routes=10.10.10.0/24

One war story from the migration. The first time I turned on full-tunnel mode from a client, everything hung: DNS resolved, small requests worked, anything substantial blackholed. I chased that for longer than I'd like to admit. It turns out "split tunnel works, full tunnel doesn't" is the textbook signature of a path-MTU problem — the tunnel's encapsulation overhead pushes packets past what some link on the path will carry, and the fragmentation-needed signal never makes it back. Setting the client MTU to 1280 (the IPv6 minimum, a safe floor for tunnels) fixed it instantly.

## Where Consumer Hardware Fights Back

Three times on this project, the hardware or the firmware couldn't do what I wanted. Those three fights taught me more than everything that went smoothly combined, so they get their own section.

A consumer smart-switch can't isolate its own management plane. I added a managed switch to hand out VLAN tags to wired devices, and only afterwards learned how it handles its own management traffic: the admin interface listens on every VLAN, and it takes a DHCP lease from whichever server answers first. Mine got its lease from the IoT segment. That put the switch's admin page on the same subnet as the cameras and the smart plugs — and the router can't help, because a firewall never sees traffic between two devices on the same subnet. There is no setting on this class of switch that fixes it. The best available move was pinning the switch to a static management IP on the Trusted subnet, so anything on IoT that wants to reach it now has to cross the router, where the IoT → Trusted block applies. That's containment, not isolation. Next time I'll read how a switch handles its management traffic before it's in the rack.

A vendor feature that couldn't do what its label promised. The router firmware ships a per-device VPN-policy feature — route this device through a VPN, leave the rest alone. Exactly what I wanted for one device on a custom VLAN. I configured it, and it silently did nothing. No error, no log entry, no traffic. Before assuming I'd fat-fingered it, I went and read the shell script that implements the feature. It turns out the whole thing is hardwired to the default LAN bridge: a device on a VLAN I created myself is invisible to it. The button was real; the code behind it had never heard of my use case. Reading the implementation cost me twenty minutes and saved me an evening in the support forums.

Decode the payload — a logged "answer" can be a block. A smart speaker and a couple of cameras joined Wi-Fi and then didn't work. The DNS query log looked healthy: every domain they asked for was getting answered. I nearly ruled DNS out and went hunting elsewhere. Then I looked at the actual response bytes:

$ dig +short @10.20.20.1 device-api.example-cloud.com
0.0.0.0
# 0.0.0.0 is not a resolution. It's a block that logs like an answer.

A privacy blocklist upstream was null-routing the device-cloud domains those gadgets phone home to, and in a query log a null-route looks identical to a real resolution. It's the same trap I see at work: a 200 response can wrap an error, an exit code of zero can hide a failure. Check the value, not the shape. The vendor dashboard has the same problem in a different costume — it doesn't render devices on custom VLANs at all (they show as "offline" while passing traffic just fine), so my sources of truth live a layer down: the association list, DHCP leases, the ARP/neighbor table, and connection-tracking.

## A Hardening Pass, Prompted by a Real Advisory

In July 2026, CISA, the NSA, the FBI, and international partners published a joint advisory on router hygiene — AA26-194A, "Improve Router Hygiene to Protect Against Russian State-Sponsored Targeting" (published July 13, 2026). It documents an FSB Center 16 actor that industry tracks as "Static Tundra," which goes after poorly configured routers — largely via SNMP with default or weak community strings, then exfiltrating configuration over TFTP. It's an enterprise- and Cisco-focused advisory, but a good advisory is also a checklist, so I audited my setup against it.

The audit was anticlimactic, which is the outcome you want: a segmented network with a default-deny WAN was already aligned with most of it. No SNMP agent running. No telnet, no TFTP. Management reachable only from the Trusted segment. I still found things to tighten — SSH went to key-only (password authentication disabled), a couple of stale inbound allow rules got deleted, and the resolver stopped binding the public interface. None of it was dramatic. All of it was the kind of thing that rots quietly if you never re-check.

## What It Bought Me — and What I'd Do Differently

So what did all this actually buy? A network I can reason about. A compromised camera can't pivot to my laptop. A guest device can't see anything but the internet. Every device's DNS is filtered and encrypted, and nothing on the untrusted segments can quietly route around it. And when something breaks, there's a script that describes the intended state, a backup that undoes the change, and low-level signals that tell the truth when the dashboard won't.

What would I do differently? I'd vet the managed switch before buying it — that one limitation cost me an afternoon and a compromise. And I'd keep choosing paths that don't exist over rules I have to remember to maintain. Almost everything that worked here reduces to two moves: deny by default, and make every change something I can read back later.

And the habits transfer. Dry-run before apply, snapshot before mutate, decode the payload, read the implementation when a feature lies — these are the same habits that keep production infrastructure honest. Home is just a cheaper place to drill them.

Full disclosure: like most things I build these days, this was done with heavy use of AI-assisted tooling — a workflow I've written about separately. I directed the work, made the design calls, and verified every change myself.

Curious how I keep AI-assisted work from quietly introducing the very gaps this post is about? That's the subject of The Security Side of Vibe Coding.

DS
author.profile

Donny Schreiber

Cloud and product security engineer at AWS, based in Boulder, Colorado. I write about cloud security, DevSecOps, infrastructure-as-code, and the security side of AI — drawn from daily practice.

Networking Infrastructure Boulder, CO
# related_posts.list()
vibe-coding-security.md

The Security Side of Vibe Coding

An honest look at what AI-generated code gets wrong — and the guardrails that catch it — from someone who uses these tools daily.

Published February 13, 2026
aws-vpc-ipam.md

Hierarchical, Multi-Region AWS VPC IPAM with Terraform

Hierarchical, multi-Region IP address management automated end-to-end in Terraform — the same segmentation instinct, at cloud scale.

Published July 26, 2025