Skip to content

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) sets no-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_netfilter stays 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 with iw reg set and, for the next boot, as cfg80211's ieee80211_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-delegation it asks for a prefix to use inside (DHCPv6 PD), of prefix-length if given. NetworkManager does both.
  • Inside: a network gets the /64 of its subnet-id out of the WAN's delegated prefix (from-wan-prefix), or a static address. With the site's ula, 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's shared IPv6 mode with prefix-delegation.subnet-id. ra: slaac+dhcpv6 also gives clients DHCPv6 addresses and names in the stable prefixes, ra: dhcpv6 (a static prefix) only those (docs/dhcp-dns.md, "DHCPv6"). ra: none sets 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").