Interfaces: ports, bonds, bridges and VLANs¶
Design draft for the interface view. See design.md for the overall
decisions.
Concepts¶
People think in networks ("Guest is VLAN 30"), not in kernel interface names
(enp2s0.30). The UI is built around four objects:
| Object | What it is |
|---|---|
| Port | A physical NIC. Identified by its PCI path by default (survives a NIC swap in the same slot and moving the config to an identical box), or optionally by MAC, not by its kernel name. Has a user label and a position on the faceplate. |
| Bond | Two or more ports acting as one link (LACP or active-backup; other modes under "advanced"). Usable anywhere a port is. |
| Bridge | Ports and bonds acting like a switch: a network on the bridge is carried on every member. It has no interface of its own; each network on it becomes a bridge named after the network (see below). Created with "+ New interface". |
| Network | A named network: VLAN ID (or untagged), zone, addressing, MTU. The main object users work with. |
| Uplink | A port or bond together with the networks it carries. |
Rules:
- A port carries at most one untagged network and any number of tagged ones.
- A port in a bond can't carry networks itself; the bond does. The same goes for a port or bond in a bridge.
- Every network belongs to exactly one zone. Several networks may share a zone.
The view¶
Faceplate¶
The top of the page shows the ports as on the box itself, with live status:
┌───────────────────────────────────────────────────────────┐
│ [1]● [2]● [3]● [4]○ [5]● [6]● │
│ WAN └─ bond0 (LACP) ─┘ unused LAN SYNC │
│ 1G 1G 1G 2.5G 1G │
└───────────────────────────────────────────────────────────┘
● link up ○ no link
- Click a port to see its details: speed, duplex, MAC, driver, error counters, LACP partner state, and link health (from the link health monitor).
- Bonding: select ports and choose "Bond". Mismatched speeds trigger a warning.
- Finding a physical port: "Blink" flashes the selected port's LED
(
ethtool --identify). "Plug in now" asks the user to plug a cable into the port they want and picks the port that gets a link. - Ports can be relabelled and reordered to match the printed labels on the case.
VLAN matrix¶
Below it, networks × uplinks. Each cell is U (untagged), T (tagged) or empty:
port 1 bond0 port 5 port 6
Network VLAN Zone (WAN)
─────────────────────────────────────────────────────
WAN — WAN U
LAN — LAN U U
Guest 30 GUEST T T
IoT 40 IOT T
Mgmt 99 MGMT T
Sync — SYNC U
- Click a cell to cycle empty → T → U. A second U in the same column is refused, with an explanation.
- Click a row to edit the network: name, VLAN ID, zone, addressing, MTU.
- "Add network" creates a row; a new network starts out on no ports.
How it maps to NetworkManager¶
Implemented in internal/nm. Stronghold writes one keyfile per object to
/etc/NetworkManager/system-connections/sh-<id>.nmconnection, with a UUID
derived from the object, so the same config always gives the same files.
| UI object | NetworkManager |
|---|---|
| Port | ethernet connection sh-<port>, matched by PCI path (match.path) by default, or by MAC (ethernet.mac-address; TODO: the permanent one, since MAC-move mode changes the MAC on WAN) |
| Zone of a network | connection.zone on the network's connection; NetworkManager hands the interface to firewalld |
| Bond | bond connection with the ports as its members (lacp is mode 802.3ad; miimon=100) |
| Network untagged on one uplink | the addresses go on that port's or bond's own connection |
| Network tagged on one uplink | VLAN connection named after the network, on that port or bond |
| Network on several uplinks | bridge named after the network; its ports are the uplinks where it is untagged and a VLAN interface <uplink>.<vid> on each uplink where it is tagged |
| Port or bond with no untagged network | link up, no addresses (it carries VLANs only) |
| WAN | Never bridged; plain port or VLAN, optionally PPPoE (TODO) |
A bridge per network, with VLAN interfaces as its ports, replaces the VLAN-aware bridge sketched earlier: it needs no per-port VLAN settings, each bridge sees only its own network's traffic, and a port can still carry other VLANs directly (the kernel hands tagged frames to VLAN interfaces before the bridge sees them).
Once the config has ports, Stronghold owns the box's networking:
- A drop-in (
/etc/NetworkManager/conf.d/50-stronghold.conf) setsno-auto-default=*, so ports without a network don't get DHCP. - Other profiles that would autoconnect on a managed device get
autoconnect=no(logged), so they don't take it back at boot. They are kept, not deleted. - Profiles are brought up controllers first, then ports, then VLANs. A port without link or hardware that isn't there is skipped with a warning; NetworkManager brings it up when it appears.
- The kernel can't change a VLAN's ID or parent in place: if NetworkManager can't reuse the interface, it is deleted and created again.
Bridge only where needed. A network on a single uplink is a VLAN interface directly on that port or bond, with no bridge in the path. A bridge is created only for a network that spans several uplinks. Reasons:
- Routing is the gateway's main job, and a bridge adds work to every packet before routing (bridge lookup and VLAN filtering), which shows up mostly as lower packets per second with small packets.
- Small x86 NICs can't offload bridging to hardware, so traffic switched between two ports of the box goes through the CPU. A real switch does that better; the box should route.
- XDP programs (D6) attach to physical ports either way, so a bridge doesn't help there.
Spanning tree, if asked. A network's bridge runs no STP unless the
network says stp: true (the network's editor shows it for a bridge).
Without it, two of the bridge's ports cabled to the same switch make a loop
(found in the lab: lab.md); with it, the kernel's STP blocks one of them,
and a port forwards only after about 30 seconds of listening and learning
when its link comes up. Off by default, for that delay.
networks:
- id: lan
zone: lan
untagged: [port2, port3]
stp: true
Interfaces are named after their network (guest, iot, lan; at most
15 characters). The interface keeps its name when a network moves from one
uplink onto a bridge, so firewalld zones and other references don't change.
The move itself still briefly interrupts that network, and goes through
commit-with-confirm like any other change.
Rules for performance:
- Flowtables, if asked (below, "Flowtables"). Established flows skip the forwarding path, and the kernel's flowtable can push VLAN tags and go past a bridge directly to the real port, which removes most of the bridge's cost for established traffic (measured: below).
br_netfilterstays unloaded. It makes the firewall inspect bridged traffic too, and is the biggest single cost a bridge can add. Stronghold warns if something loads it.- VLAN tag offload in the NIC is left on (the default).
Safety¶
- Interface changes go through the same commit-with-confirm as everything else: strongholdd applies the change and re-applies the previous config unless it is confirmed within 60 seconds. NetworkManager checkpoints could add a second safety net that works even if strongholdd dies; not used yet.
- The UI marks the network the current browser session arrives through ("you"), and warns before applying a change to it.
- Applying a change shows a diff ("bond0: add port 3; Guest: tag on port 5") first.
HA integration¶
- Networks get a VRRP virtual IP field when HA is on.
- WAN networks pick a WAN mode (VRRP or MAC-move).
- The SYNC network is meant for a dedicated port; the UI warns if it shares an uplink with other networks.
Config model sketch¶
version: 1
ports:
- id: port1
label: "WAN"
match: { pci: "0000:01:00.0" }
- id: port2
match: { pci: "0000:02:00.0" }
- id: port3
match: { pci: "0000:03:00.0" }
- id: port5
match: { pci: "0000:05:00.0" }
mtu: 9000
bonds:
- id: bond0
mode: lacp
ports: [port2, port3]
networks:
- id: wan
zone: WAN
untagged: [port1]
ipv4: dhcp
ipv6: { dhcp: true, prefix-delegation: true }
- id: lan
zone: LAN
untagged: [bond0, port5]
ipv4: { address: 192.168.1.1/24 }
- id: guest
vlan: 30
zone: GUEST
tagged: [bond0, port5]
ipv4: { address: 192.168.30.1/24 }
Flowtables¶
interfaces.flowtables: true (the Networks tab: "Offload established
connections") offloads established TCP and UDP connections between the
networks' ports: they skip the forwarding path, NAT still done. Off by
default, until a pair's failover has been tried with it.
- A table of Stronghold's own,
inet stronghold_flow(internal/firewalld/flow.go): a flowtable on every network's uplinks (ports and bonds; the kernel finds the VLANs and bridges on them), and a forward chain after firewalld's, so only connections firewalld accepted are offloaded. - Left out by themselves, and said so: the WANs Suricata inspects in prevent mode (an offloaded connection would skip its queue); the load balancer's virtual and real servers (IPVS isn't tried with offloaded connections).
- Unchanged: the firewall's rules and hit counters (established connections were accepted before any rule anyway), shaping (offloaded packets still go through the qdisc), per-network traffic, packet capture, Suricata in detect mode (it reads the interface). A WAN failover deletes the dead WAN's connections, offloaded ones too.
- Changed: conntrack's per-connection byte counts stop at the offload (nothing in Stronghold reads them).
# interfaces.yaml
flowtables: true
Measured¶
2026-10-19, routed and NATed traffic through the package-mode lab box
(2 vCPUs, virtio; performance.md): 64-byte UDP received, kpps.
| no flowtable | flowtable | |
|---|---|---|
| LAN on a port | ~173 | ~295 |
| LAN on a bridge | ~107 | ~293 |
TCP was ~17 Gbit/s in every case (virtio's large segments hide the
per-packet cost). So the choices above hold: a bridge in the path costs a
third of the packet rate, and a flowtable (on the bridge's lower port)
takes it all back and more. With flowtables: true from the config (not
by hand), the same box received ~327 kpps.
Wi-Fi¶
A Wi-Fi radio is a port with Wi-Fi settings: an access point that shares one of the box's networks (and with it the internet, when that network's zone may reach the WAN), or a client that joins another network, for example as the WAN, which leaves a wired port free for the LAN. NetworkManager runs both (AP mode and client mode, with wpa_supplicant).
ports:
- id: port1
match: { pci: "0000:01:00.0" }
- id: wifi1 # an access point on the LAN, bridged with port1
match: { pci: "0000:02:00.0" }
wifi:
ssid: Home
security: wpa2 # the default; open only for first-boot setup
band: "2.4" # the only band so far (5 GHz later)
channel: 6 # 1-13 where the country allows; default: the driver's choice
networks:
- { id: lan, zone: lan, untagged: [port1, wifi1], ipv4: { address: 192.168.73.1/24 } }
wifi-country: SE # the regulatory domain; default 00, the world
ports:
- id: port1 # the only wired port: the LAN
match: { pci: "0000:01:00.0" }
- id: wifi1 # joins the upstream network: the WAN
match: { mac: "02:00:00:00:00:00" }
wifi: { mode: client, ssid: Upstream, security: wpa2 } # or wpa3, open
networks:
- { id: wan, zone: wan, untagged: [wifi1], ipv4: dhcp }
- { id: lan, zone: lan, untagged: [port1], ipv4: { address: 192.168.73.1/24 } }
- One untagged network per Wi-Fi port; no VLANs or bonds. An access point can share a network with wired ports (a bridge), but can't be a WAN. A client can be a WAN, but can't be bridged (the network it joins takes only its own MAC).
- The radio keeps its own MAC (
cloned-mac-address=permanent): clients see the same access point every time, an upstream network gives the same lease, and a port matched by MAC (USB) is found by the radio's permanent address. - The password is a secret on the box (
/var/lib/stronghold/secrets/wifi/<port>.psk), not in the config; it comes with the change (SetInterfaces,wifi-passwords). A Wi-Fi port without one is refused, and stays down. - UI: the Wi-Fi tab, shown when the box has a Wi-Fi radio: each radio off, an access point (which network it carries, or a new one with its own subnet and DHCP; whether its zone reaches the internet) or joining a network (with a scan; the WAN moves onto it, and the tab offers to put the network left without a port on the freed wired port).
- Not in an HA pair (each box would need its own radio and SSID plan).
- Country (
wifi-country, the Wi-Fi tab's "Wireless settings"): the regulatory domain for every radio, the ISO 3166 code of where the box is; it decides the channels and power allowed (channels 12 and 13 in Europe, for one). strongholdd sets it withiw reg setand, for the next boot, ascfg80211'sieee80211_regdom(/etc/modprobe.d/stronghold-wifi.conf), so an access point that starts at boot has the country's channels. Without it the world domain (00) applies, the most restrictive. - 2.4 GHz only for now; 5 GHz (and its DFS channels) comes later.
- The image carries the firmware for Intel, Atheros, MediaTek, Realtek and Broadcom Wi-Fi.
IPv6¶
# stronghold.yaml
ula: fd5a:1b2c:3d4e::/48 # the site's ULA prefix (made at first boot)
# interfaces.yaml
networks:
- id: wan
zone: wan
untagged: [port1]
ipv4: dhcp
ipv6: { dhcp: true, prefix-delegation: true, prefix-length: 56 }
- id: lan
zone: lan
untagged: [port2]
ipv4: { address: 192.168.73.1/24 }
ipv6: { from-wan-prefix: wan, subnet-id: 1 } # ra: slaac (default)
- id: lab
zone: lan
vlan: 20
tagged: [port2]
ipv4: { address: 10.71.20.1/24 }
ipv6: { address: 2001:db8:20::1/64, subnet-id: 2, ra: none }
- WAN: an address from the ISP's router advertisements and DHCPv6;
with
prefix-delegationit asks for a prefix to use inside (DHCPv6 PD), ofprefix-lengthif given. NetworkManager does both. - Inside: a network gets the /64 of its
subnet-idout of the WAN's delegated prefix (from-wan-prefix), or a staticaddress. With the site'sula, it also gets the ULA /64 of its subnet ID (fd5a:1b2c:3d4e:1::1/64), which stays the same when the ISP's prefix changes. - Advertisements (
ra: slaac, the default): the box advertises the network's prefixes (clients configure themselves, SLAAC) with itself as DNS server at its ULA address (else its static one) and the DHCP domain as search domain. That is NetworkManager'ssharedIPv6 mode withprefix-delegation.subnet-id.ra: slaac+dhcpv6also gives clients DHCPv6 addresses and names in the stable prefixes,ra: dhcpv6(a static prefix) only those (docs/dhcp-dns.md, "DHCPv6").ra: nonesets static addresses only. - DNS: Unbound listens on the ULA (and static) addresses and answers those prefixes, with the network's view; clients ask from their own ULA address, so split DNS doesn't depend on the ISP's prefix.
- Firewall: the zones and cells cover IPv6 as IPv4; nothing is NATed, so "from-wan: drop" is what keeps the internet out of the networks.
- In an HA pair: a delegated prefix on a mac-move WAN, and networks with virtual addresses; see docs/ha.md, "IPv6 in a pair".
- VPN tunnels get a ULA /64 with their
subnet-id(docs/vpn.md, "IPv6").