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;unreachableandprohibit: 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: truekeeps 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'sfrr.service). strongholdd writes FRR's integrated config (/etc/frr/frr.conf) and, while FRR runs, hasfrr-reload.pyapply 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'snetworkwith 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'sGET /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.