Skip to content

Routing: static routes, policy routes and BGP

interfaces.yaml's routing: routes to other networks, routes that discard, policy routes that send chosen connections out of a given WAN, and BGP with other routers. The WANs' own routing (a table per WAN, failover) is wan.md.

Config

# interfaces.yaml
routing:
  routes:
    - to: 10.20.0.0/16            # another site's networks...
      via: 192.168.1.254          # ...through a router on the lan
      metric: 50                  # optional
    - to: 2001:db8:20::/48
      via: fd5a:1b2c:3d4e:1::fe   # IPv6 too (a static or ULA address's /64)
    - to: 198.51.100.0/24
      type: blackhole             # dropped quietly; or unreachable, prohibit
  policies:
    - id: guests-via-wan2         # all of the guest network's connections
      networks: [guest]
      wan: wan2
    - id: voip
      sources: [192.168.1.20]     # addresses or prefixes
      destinations: [203.0.113.0/24]
      protocol: udp               # tcp or udp, with ports: "5060", "10000-20000"
      ports: ["5060", "10000-20000"]
      wan: wan2
      strict: true                # while wan2 is down: dropped, not rerouted

How

  • Routes through a next hop belong to the network whose subnet holds the next hop (its static IPv4 subnet, or its static or ULA IPv6 /64): they are routes of that network's NetworkManager connection, so they come and go with its link. On a WAN they go in the main table (table=254), not the WAN's own, which only marked traffic uses.
  • Routes that discard (blackhole: dropped quietly; unreachable and prohibit: the sender is told by ICMP) belong to no link: strongholdd keeps them in the main table under a routing protocol of its own, 199 (ip route show proto 199), adds the missing ones and removes those the config no longer has, and leaves every other route alone (internal/routes).
  • Policy routes are connection marks in the WAN manager's nftables table (inet stronghold_wan, wan.md): a new connection that matches (from the networks' interfaces or the source addresses, to the destinations, with the protocol and ports) gets the mark of its WAN's table, before the active WAN would get it, and keeps it, so its packets leave through that WAN (and are NATed to its address). Policies are matched in order; the first that matches wins.
  • When that WAN is down the policy's rule is left out, so its connections use the active WAN; when it is up again, new connections use it again (the ones on the active WAN stay there). strict: true keeps the rule while the WAN is down: the WAN's table has no route, and its unreachable rule drops them. Connections on a WAN that went down are removed from conntrack, as on a failover.
  • Only the internet: connections from a policy's networks to the inside networks (and the sites behind the tunnels) are left alone, so a policy for a network doesn't send its traffic to the other networks out of a WAN.

BGP

# interfaces.yaml
routing:
  routes:
    - to: 203.0.113.0/24          # an aggregate to announce: a route to it
      type: blackhole
  bgp:
    asn: 65001
    router_id: 192.168.1.1        # optional: the box's first static address
    announce: [lan, servers, 203.0.113.0/24]   # networks (their subnets) and prefixes
    neighbors:
      - id: core                  # internal BGP: the same AS
        address: 192.168.1.254
        asn: 65001
      - id: k8s                   # MetalLB or Cilium announcing service addresses
        address: 192.168.20.10
        asn: 65010
        max_prefixes: 200         # the session ends when it sends more (default 1000)
      - id: isp
        address: 198.51.100.1
        asn: 64500
        network: wan              # reached through (default: the network whose subnet holds the address)
        multihop: 2               # external BGP more than one hop away
        accept: default           # all (the default), default (a default route only), none
        disabled: true            # the session is kept down

A neighbor's password (TCP MD5) is a secret on the box, not config: stronghold bgp password core, the Networks tab's Routing card, or the API's interfaces change with bgp-passwords ("" removes one).

How

  • FRR, a service function in a container of its own (stronghold-routing, functions.md; on a box without the container, the host's frr.service). strongholdd writes FRR's integrated config (/etc/frr/frr.conf) and, while FRR runs, has frr-reload.py apply what changed: sessions that didn't change stay up. FRR's zebra puts the routes it learns in the main table (proto bgp, metric 20), which comes before the active WAN's rule (wan.md): learned routes win over the WAN failover for their destinations. A learned default route would too, so accept one (accept: default) only where that is the point.
  • What is announced is only announce: an outbound route map per IP version allows those prefixes and nothing else, so the box never passes one neighbor's routes to another (it is no one's transit). A network stands for its subnets: its static IPv4 subnet, its static IPv6 subnet and its ULA /64. A prefix is announced while the box has a route to it (FRR's network with its import check): a network's subnet always, an aggregate with a blackhole route (above).
  • What is accepted from a neighbor: all its routes, only a default route, or nothing; never a route into one of the box's own networks (their subnets or anything inside them, so a neighbor can't take a part of the LAN), and at most max_prefixes (the session ends above it).
  • IP versions: an IPv4 neighbor carries IPv4 routes, an IPv6 neighbor IPv6 routes (no bgp default ipv4-unicast; each neighbor is activated in its own family).
  • The firewall lets each neighbor that isn't disabled reach TCP 179 from its address, in the zone of the network it is reached through; nobody else.
  • In an HA pair each box speaks BGP from its own address (FRR's update-source, never the virtual one) with its own router ID. Both announce; telling the neighbors which box is active (the standby prepending its AS path) comes with active-active (design.md).
  • State: stronghold bgp, the API's GET /v1/bgp, the Routing card (each session's state, prefixes in and out), and the troubleshooting tool "BGP sessions and routes" (FRR's summary and routes; with a neighbor, what it sent and what the box announces to it).

Not yet

  • Policy routes to a gateway that isn't a WAN (an internal router, a tunnel).
  • OSPF and BFD (the same FRR); BGP route maps of one's own (communities, local preference, AS path prepending); BGP over link-local IPv6.

In the lab

stronghold-iso with a second WAN (wan2, libvirt's stronghold-wan2): 10.99.0.0/16 via 192.168.73.254 on the LAN's connection, blackhole 198.51.100.0/24 proto 199; with web-via-wan2 (the lan's TCP 80 to wan2) the laptop's HTTP left through wan2 (mark 101, NATed to wan2's address) and its HTTPS through wan (mark 100). wan2's link down: HTTP through wan; up again (after its health came back): through wan2.

BGP (2026-10-18): an FRR peer in the laptop's namespace (isolap, 192.168.73.101, AS 65002, TCP MD5 password) announcing 10.98.0.0/16 and 192.168.73.128/25 (a part of the LAN). The box (AS 65001) learned 10.98.0.0/16 (proto bgp, metric 20, in the main table), refused the /25, and announced the LAN and 198.51.100.0/24 (its blackhole route); 203.0.113.0/24, without a route, stayed unannounced. A changed limit went through frr-reload.py without the session going down; an interfaces apply left the BGP route alone; a restart of strongholdd left FRR alone; stopping the container took the route away at once (with its init as PID 1) and starting it brought the session back in seconds; BGP removed and not confirmed: FRR stopped, port 179 closed, the route gone, and all three back after the rollback. No SELinux denials.