VPN¶
WireGuard tunnels for remote access (phones and laptops) and for links to
other sites. WireGuard is in the kernel and NetworkManager runs it, so the
box needs only wireguard-tools (for wg show) and qrencode (QR codes
for the WireGuard apps), both in Fedora.
IPsec (libreswan) is for sites with other vendors' gear and clouds' VPN gateways ("IPsec", below). OpenVPN ("OpenVPN", below) is for clients that have the OpenVPN app and a certificate; WireGuard covers both uses for peers that speak it.
Config¶
# vpn.yaml
wireguard:
- id: wg0 # also the interface name
zone: vpn # a zone like a network's; not a WAN zone
address: 10.71.50.1/24 # the box's address and the tunnel's subnet
subnet-id: 50 # IPv6: this /64 of the site's ULA prefix (optional)
port: 51820 # UDP, the default; opened on the WANs
endpoint: vpn.example.com # what clients connect to; default: its WAN's static address
wan: wan2 # the WAN peers reach it on; default: the endpoint's WAN, else the first
client-dns: [10.71.50.1] # default: the tunnel address when the box serves DNS; [none] for none
client-routes: [all] # what clients send through: all (default), networks by id, subnets
peers:
- id: alex-phone
public-key: "kOd3FVBggwpjD3AlZKXUxNTzJT0+f3MJdUdR8n6ZBn8="
address: 10.71.50.10 # in the tunnel's subnet
preshared-key: true # the PSK is a secret on the box
- id: office # another site
public-key: "ZCbqSXIJKFknBi8bfu2wVcdeSlMcBmzu+V3E2MdlfnU="
address: 10.71.50.2
networks: [192.168.5.0/24, 2001:db8:5::/48] # behind it (IPv4 or IPv6); routed into the tunnel
endpoint: office.example.com:51820 # this box connects to it
keepalive: 25 # seconds; keeps NAT on the way open
- A tunnel is like a network. It has a zone, so the firewall matrix
decides what its peers may reach (
vpn → lan,vpn → wan, ...); give clients a zone of their own. Outbound NAT, DNS (Unbound listens on the tunnel address and answers its subnet, with the zone's view), the packet tester and multi-WAN's inside networks all count it, and the sites behind peers, like the other networks. A tunnel's id can be used where a network's can (NAT rule sources,allow-from). - Checks: the subnet must not overlap a network, another tunnel or a
site behind a peer; each peer's address is in the tunnel's subnet, and
public keys and addresses are unique. A peer can't take
0.0.0.0/0: a tunnel isn't a WAN. - Firewall: Stronghold adds the rule that lets the tunnel's UDP port in from the WAN zones.
IPv6¶
- A tunnel with a
subnet-idgets that /64 of the site's ULA prefix (fd53:4800:7100:32::1/64for 50), like a network; the subnet IDs of networks and tunnels are all different. Each peer's address there is its IPv4 host number (10.71.50.10:::a), so nothing more is set per peer. - Client configs get both addresses, the box's two addresses as DNS
(when it serves DNS), and IPv6 routes to match:
allis also::/0, a network also its ULA (and static) /64, a tunnel its /64; an IPv6 subnet can be given too. - Out to the internet the tunnel's ULA addresses are translated to
the WAN's address (NAT66, the
stronghold_nat6table, on the WANs with IPv6). A /64 of the delegated prefix would need no NAT, but a client's config is fixed and the ISP's prefix can change. The networks' own ULA addresses are reached directly. - DNS (Unbound) listens on the tunnel's IPv6 address and answers its /64, with the zone's view, as for IPv4.
- Sites behind peers may have IPv6 subnets too (
networks): they are in the peer's allowed IPs, routed into the tunnel and checked against the box's own prefixes (a network's ULA and static /64, not a delegated one, which changes) for overlap. The box doesn't translate them (NAT66 is the tunnel's own /64's), and WAN policy routing (IPv4) leaves them alone. The site's router needs the route back to the tunnel's /64. Only unit tested: the lab has no second IPv6 site.
Keys¶
- The tunnel's private key never goes into the config repository. It is
a secret on the box (
/var/lib/stronghold/secrets/wireguard/<id>.key, root only,internal/secrets), made when the tunnel is first applied; the UI andstronghold vpn statusshow the public key. It also ends up in the NetworkManager profile (/etc/NetworkManager/system-connections, root only), which NetworkManager needs. Encrypting the secrets at rest withsystemd-creds(TPM-bound) is left for later: the profile holds the key in the clear anyway. - Clients: "Add client" in the VPN tab has strongholdd make a key pair and a preshared key. The peer gets the public key; the preshared key is stored on the box when the change is applied; the private key is only in the client config shown then, as text and as a QR code for the WireGuard apps. The box doesn't keep it, so the config can't be shown with it again (it shows a placeholder); add the client again for a new one.
- Sites: their public key is entered by hand; a preshared key, if the other side uses one, is pasted ("set…").
How it runs¶
internal/nmrenders each tunnel as a NetworkManagerwireguardprofile,sh-<id>, with its peers and the zone. NetworkManager adds a route for each peer's allowed IPs (its address and the networks behind it) to the main table.GET /v1/vpn/status(the API) readswg show all dumpfor each peer's last handshake, endpoint and traffic; a peer counts as connected with a handshake in the last 3 minutes (WireGuard renews every 2 while in use).- A tunnel uses one WAN: the one its endpoint is on (or
wan). Its WireGuard packets carry that WAN's table as firewall mark (wireguard.fwmark), so they leave through it whichever WAN is active. WireGuard routes its answers itself, without the connection's mark, and if that route leaves through another interface than the one a peer came in on, it drops the address and answers from the active WAN; peers then never get an answer. (The source rules indocs/wan.mdcover the same for other answers from the box's WAN addresses.) - Changes keep the sessions. A changed tunnel is reapplied in place
(
nmcli device reapply), so adding a client doesn't cut off the others. NetworkManager doesn't reapply secrets, so strongholdd sets the preshared keys withwg set; a new private key brings the tunnel up again.
HA¶
- The tunnel runs on the active box only: its profile doesn't come up by
itself on a pair, and
internal/habrings it up and down with the role, like a mac-move WAN. A standby with the same key would answer peers too. - Both boxes have the same key. The standby takes the active box's
secrets over the sync network: the peer state carries a digest of them,
and when it differs the standby fetches them (
/v1/secrets) before the config. The answer is encrypted for a fresh X25519 key the standby sends (with one of the active box's, HKDF and AES-GCM), so someone who only listens on the sync link doesn't see them, and like the rest of the peer API the request and answer are signed with the pairing key (docs/ha.md), so nobody else can ask for them. Only the VPN's keys are shared (secrets/wireguard/); other secrets, like the DNS update key, stay each box's own. - Keys are made only by
SetVPN(the UI,stronghold vpn set), which runs on the active box. A box without a pair also makes missing keys when it applies a config (one from the repository), but a pair doesn't, so the two never make different ones. - After a failover a client keeps its config: the new active box has
the same key and the virtual (or moved) WAN address. Its sessions with
the old box are gone, and a client would only notice after about 15
seconds (WireGuard's keepalive and rekey timeouts). So the new box
starts them itself (
wireguard.Resume): the active box tells the standby where it last saw each peer (in its peer state, every 2 seconds), and on taking over the box sets those endpoints and sends each peer a keepalive, which starts a handshake. In a pair the tunnel's packets are translated to the address peers use (stronghold_nat), as the box starts from its own; conntrack entries for the tunnel's port that came over from the other box are deleted first, so that keeps the port too. - In the lab: a planned switch costs the VPN about 1 second (5 pings
at 0.2 s), powering off the active box about 6 seconds (4 of them
noticing it is gone, as for all traffic); before
Resume, about 15.
Tailscale and headscale¶
Besides its own WireGuard tunnels, the box can be a node of a tailnet:
Tailscale's, a headscale server elsewhere, or a headscale on the box
itself. Both are in Fedora (tailscale, headscale), installed when
they are turned on: tailscaled on a box from packages as Fedora's
package, in image mode in a container of its own (stronghold-tailscale:
the host image has no tailscale, and Fedora runs tailscaled unconfined);
headscale as a service function.
# vpn.yaml
tailscale:
zone: tailnet # tailscale0's zone: the firewall's policies decide what the tailnet reaches
login-server: local # this box's headscale; "" Tailscale's; or https://hs.example.net
hostname: home # default: the host name; in a pair, home-gw1 and home-gw2
advertise-routes: [lan, guest, 192.168.9.0/24] # networks by id, or subnets
exit-node: true # offer the box's internet
accept-routes: false # use other nodes' subnets
headscale:
server-url: https://hs.example.net # what nodes connect to: TLS from Let's Encrypt on port 443
acme-email: admin@example.net
base-domain: ts.home.home.arpa # MagicDNS (default ts.<site>.home.arpa)
wan: wan # where its port opens (default every WAN)
- The node (
internal/tailscale): tailscaled with its own firewall rules off (--netfilter-mode=off):tailscale0is in thezone, like a network, and Stronghold's firewall decides. No SNAT for the routed networks (the box is their gateway, so the answers come back through it); the exit node's traffic gets the automatic outbound NAT. The box keeps its own DNS (--accept-dns=false). The WAN takes tailscaled's UDP port (41641) for direct connections; without it nodes go through Tailscale's relays. - Logging in: with Tailscale or a headscale elsewhere, an auth key
(the VPN tab, or the
vpnsection'stailscale-auth-key; a secret on the box, shared by a pair), or, without one, the login URL the box's status shows. Withlogin-server: localthe box makes its own key from its headscale. The control server approves the routes: headscale on the box approves its own node's (policyautoApprovers); elsewhere approve them there (or give the box's userautoApprovers). - Trouble with the tailnet (the control server down, a bad key) is a warning and shows in the status: it never rolls back a change.
- In a pair both boxes are nodes (each its own name), but only the active one advertises the routes and the exit node; on a failover the new active box starts advertising them (the control server tells the other nodes in a few seconds).
- headscale on the box (
internal/headscale): its config and policy are rendered (/etc/headscale), with Tailscale's relays (DERP), a policy that lets every node reach every other (the box's firewall decides what they reach on its networks), and MagicDNS names. With anhttp://server URL it serves without TLS (behind a proxy, or a lab). Not on an HA pair yet: its database is each box's own. - Devices join with a key from the VPN tab (or
stronghold vpn headscale key alice):tailscale up --login-server https://hs.example.net --auth-key hskey-….stronghold vpn headscalelists the nodes;routes ID SUBNETSapproves another subnet router's routes,delete IDremoves a node.stronghold vpn tailscaleshows the box's node.
IPsec¶
Site-to-site tunnels with libreswan, for the other side that speaks IPsec
and not WireGuard: a router of another make, a cloud's VPN gateway. In
vpn.yaml, beside wireguard:
ipsec:
- id: office
zone: vpn # what the firewall calls the other side
if-id: 1 # its interface is ipsec1
remote: 203.0.113.9 # an address, a name, or "any"
local-networks: [lan] # networks (ids) or subnets this side offers
remote-networks: [192.168.20.0/24]
# wan: wan # default: the first WAN
# local-id / remote-id: "@gw.example.com" (default: the addresses)
# auth: cert # certificates instead of a pre-shared key
# crl: http://ca.example/crl.pem # the CA's revocation list (with certificates)
# ipv6: true # IKE and ESP over IPv6 (also when remote is an IPv6 address)
# ikev1: true # for gear without IKEv2
# ike: aes256-sha2_256;dh19 esp: aes256-sha2_256
# initiate: true # the box starts it (default; not for "any")
# disabled: true
- Route-based. The tunnel is an interface of its own,
ipsec<if-id>(an XFRM interface, libreswan'sipsec-interface), inzonelike a network; the remote networks are routed into it, so the firewall matrix decides what crosses, and the outbound NAT, DNS and the other functions see it as another inside network. The remote subnets mustn't overlap a network or another tunnel's (validated). The box's own traffic to the other side comes from its address in the first local network. - Pre-shared key. Stored as a secret on the box
(
ipsec/<id>.psk) and never shown again:stronghold vpn ipsec psk IDasks for it,--generatemakes one and prints it once for the other side. 16 to 256 printable characters, no quotes or backslashes. A pair shares it like WireGuard's. - Certificates (
auth: cert, IKEv2): the box's certificate (PEM, its chain after it), its private key and the CA certificate(s) the other side's must be signed by, given withstronghold vpn ipsec cert ID --cert FILE --key FILE --ca FILE(--removetakes them away), the vpn body'sipsec-certsor the UI. They are checked first (the key matches, not expired, the CA is a CA) and kept as secrets (ipsec/<id>.cert,.key,.ca), a pair sharing them. The certificate and key can instead be issued by the box (certificates.md, "For an IPsec tunnel":for: [ipsec:ID]), and then--ca FILEalone gives the CA. strongholdd puts them in libreswan's NSS database (/var/lib/ipsec/nss:openssl pkcs12,pk12util,certutil, the nicknamesh-<id>, the CA trusted assh-<id>-ca) when they are new or changed, tells a running libreswan to read them again (ipsec auto --rereadcerts: it keeps the CAs it had read, and says "untrusted issuer" for a new one), and replaces the connection then; a tunnel without all three isn't written and is warned about. Each side's identity is taken from its certificate (%fromcert) unlesslocal-idorremote-idsays otherwise (an@nameis matched against the certificate's names), soremote: anyneeds noremote-idwith them. Both sides must use the same kind of key (both RSA or both ECDSA): libreswan 5.3 takes oneauthbyfor the two ends, and a box with an RSA key against a peer's ECDSA one (or the other way round) failed withAUTHENTICATION_FAILEDin the lab, also withleftauthandrightauthset on one side. libreswan signs only with the kind of key it has, soauthbyisecdsaorrsasigby the key given (both for a key strongholdd can't tell). - Revocation (
crl: URL, http or https, PEM or DER): strongholdd fetches the CA's list when the tunnel is applied and every hour, and imports a new one into the NSS database (crlutil); a list that has expired or isn't one is warned about, and the tunnel runs as it did. libreswan looks at the lists in its database only withcrlcheckintervalset, which is global (config setup), so strongholdd writes that block intostronghold.confwhen a tunnel hascrl(the box's ownipsec.confis left alone) and restartsipsec.serviceonce when the block first appears or goes: the tunnels start again. A certificate revoked after the tunnel came up is refused at the next authentication (a rekey or a restart), not before; so when a new list has certificates the last one didn't (strongholdd keeps the serials it saw instronghold-<id>.revoked), it restartsipsec.service, and the new authentication refuses a revoked one. OCSP (libreswan'socsp-enable) is not offered. Tried in the lab (libreswan 5.3) with OpenBao's responder and with OpenSSL's: libreswan asked a responder only for a certificate that also had a CRL distribution point, and NSS then answeredSEC_ERROR_OCSP_SERVER_ERRORto the responses of ECDSA CAs (NSS's ownocspclntthe same; with an RSA CA it succeeded), so a certificate was refused (ocsp-strict) or accepted (not strict) whatever the responder said; with RSA certificates libreswan accepted a revoked one without asking at all. The revocation lists are the way. Tried in the lab (libreswan 5.3, SELinux enforcing): a certificate on the list is refused (SEC_ERROR_REVOKED_CERTIFICATE), and comes back when a newer list no longer has it; with OpenBao's PKI as the CA (certificates issued and renewed by the box from it, its list at/v1/pki/crl/pem). A certificate newly on a list is refused at once: strongholdd restartsipsec.service(all the tunnels start again, a few seconds), because a running pluto keeps the lists it read when it started. Taking only the one tunnel down and up let the revoked certificate back in, in most runs, even afteripsec auto --rereadcerts; the restart refused it every time (three runs). - Seen.
stronghold vpn ipsec(andGET /v1/vpn/ipsec, the VPN tab, Overview's IPsec tile) show, for a tunnel with certificates, when its certificate expires and how old its revocation list is or why it could not be had; the alerts warn two weeks before the certificate expires (alerts.md); Troubleshoot's "IPsec certificates and revocation lists" lists what libreswan's database holds. - IPv6. The subnets may be IPv6 (IKEv2):
fd00:1::/64inlocal-networksorremote-networks, and a network named inlocal-networksoffers its stable IPv6 prefixes (its ULA /64, a static one) too. The box offers a family only when the other side has subnets of it, so a site with IPv4 only is not asked for IPv6. The box's own traffic into the tunnel comes from its ULA address there. The tunnel itself stays IPv4 unlessipv6: true(below). The sites' IPv6 is routed and filtered like any inside network; WAN policy routing (IPv4) leaves it alone. - IKE and ESP over IPv6 (
ipv6: true, orremotean IPv6 address): the WAN needs IPv6 (ipv6: {dhcp: true}, router advertisements). The box's side is the WAN's global IPv6 address (else its unique local one), which strongholdd reads from the interface and writes asleft=(libreswan takes IPv4's from%interfacebut not IPv6's, tried); every minute it looks again, and writes the tunnel anew if the address changed. Until the WAN has an address the tunnel is left out and warned about. The firewall opens UDP 500 and 4500 and ESP on the WAN from the remote's IPv6 address. Tried in the lab between two libreswan boxes with certificates, IPv4 and IPv6 subnets both ways. - The other side can be an address or a name, or
anyfor a branch that connects from wherever it is (a dynamic address): thenremote-idsays who it is, the box only answers, and it must be IKEv2. With IKEv1 each pair of local and remote subnet is a tunnel of its own (libreswan'sleftsubnets), the way old gear wants it. - Firewall. UDP 500 and 4500 and ESP are opened on the tunnel's WAN
zone, from the remote address when it is one; the tunnel's interface joins
its zone.
stronghold firewall explainknows the remote subnets. - How it runs. libreswan on the host (its
ipsec.service; there is no SELinux domain of ours: Fedora'sipsec_t),libreswaninstalled when the first tunnel is applied. strongholdd writes/etc/ipsec.d/stronghold.conf(aconn sh-<id>each) andstronghold.secrets(0600), and loads only the connections that changed (ipsec auto --replace), starts those withinitiateand deletes the removed, so one tunnel's change doesn't drop the others. DPD is on (30 s). The WAN address is the interface's (%ifnamefor DHCP), or the pair's virtual address. - HA. The active box runs the tunnels (the standby has the same config and key and stays quiet), like WireGuard's.
stronghold vpn ipsec [status],GET /v1/vpn/ipsec(the tunnels, up or down, their SAs and bytes); the UI's second card on the VPN tab, the Overview's IPsec tile and Troubleshoot's "IPsec tunnels" (ipsec trafficstatusandshowstates). Ansible:stronghold_vpn'sipseclist, andstronghold_vpn_ipsec_keys(id to key) for the secrets.
Tried in the lab: the Fedora VM (libreswan 5.3.2, IKEv2, a pre-shared
key, a zone of its own) against a libreswan 5.3.2 in a container
on the VM's WAN network, 172.31.9.0/24 behind it. The tunnel comes up by
itself, a client on the LAN reaches the far subnet (and the far side the
client) through ipsec1, the box reaches it from its LAN address, and
turning the tunnel off and on loads and starts it again; no SELinux denial.
In the pair gw1/gw2 (21 October 2026): only the primary ran libreswan
(gw2 had it inactive). With gw1 powered off, gw2 became the primary and
the tunnel was up on it eight seconds later, the far side pinging a LAN
client through it at the first try; with gw1 back, it took the tunnel
back (up after about 40 s) and gw2 stopped libreswan. Other vendors' gear
is not tried; a cloud gateway will likely want ike and esp of its own.
OpenVPN¶
Servers for clients with the OpenVPN app (or any OpenVPN 2.4 or later) and
a certificate of a CA you have: the way for a laptop or phone that has
that and not WireGuard. In vpn.yaml, beside wireguard:
openvpn:
- id: road
zone: vpn # what the firewall calls the clients
address: 10.71.60.1/24 # the box (the subnet's first address) and the clients' subnet
# port: 1194 # protocol: udp (default) or tcp
# wan: wan # default: the first WAN with a static address
endpoint: vpn.example.net # what client configs connect to; default: the WAN's address
client-routes: [lan] # what the clients send through (all by default), as a tunnel's
# client-dns: [10.71.60.1] # default: the box's address when it serves DNS; [none]
# crl: http://ca.example/crl.pem # the CA's revocation list
# disabled: true
- Like a tunnel. The server is an interface named after its
id(road), with a subnet and a zone: the firewall matrix decides what the clients reach, and outbound NAT, DNS and the other functions see it as another inside network. The server's port (UDP or TCP) is opened on the WAN. The subnet mustn't overlap another (validated); the clients get the addresses after the box's. - Certificates. TLS 1.2 or later, the data channel AEAD
(AES-256-GCM, AES-128-GCM, ChaCha20-Poly1305), the control channel signed
and encrypted with a
tls-cryptkey the box makes (a secret,openvpn/<id>.tls-crypt, shared by a pair). The box's certificate, its key and the CA the clients' certificates come from are secrets too (openvpn/<id>.cert,.key,.ca), given withstronghold vpn openvpn cert ID --cert FILE --key FILE --ca FILE, the vpn body'sopenvpn-certs, Ansible'sopenvpn_certsor the UI; checked first (the key matches, not expired, the CA is a CA). Or issued by the box's issuers (certificates.md,for: [openvpn:ID]), and then--ca FILEalone. The clients' certificates are the CA's business (OpenBao, FreeIPA,openssl); every one it signed gets in unless it is revoked. - Revocation (
crl: URL): fetched when the server is applied and every hour; the server looks at the file at every connection, so a revoked certificate is refused at the next connection. A certificate newly on the list has the server restarted at once (its clients connect again, the revoked one is refused). Tried in the lab (OpenVPN 2.7, SELinux enforcing):certificate revoked, every time. - Client config.
stronghold vpn openvpn client ID > road.ovpn(also the VPN tab's "Download .ovpn",GET /v1/vpn/openvpn/ID/client-config): the server's address and port, the CA and thetls-cryptkey inline. The client's own certificate and key come from the CA and go in as<cert>and<key>blocks. - Seen.
stronghold vpn openvpn(GET /v1/vpn/openvpn, Overview's OpenVPN tile) shows each server, its connected clients (name, address, traffic, since), its certificate's expiry and the list's age; the alerts warn two weeks before the certificate expires; Troubleshoot's "OpenVPN servers' log" has what the servers say (who connected, whose certificate was refused, why). - How it runs. OpenVPN on the host, installed when the first server is
applied (D21), as Fedora's
openvpn-server@stronghold-<id>.servicewith/etc/openvpn/server/stronghold-<id>.confand the certificates beside it (openvpn_etc_t). A change to a server's files restarts it (its clients connect again). The primary of a pair runs the servers. A server needs its certificate, key and CA: without them it is not written, and strongholdd warns. - Tried in the lab. A server with a certificate issued by OpenBao
(
for: [openvpn:road]), a client on another VM with a certificate of the same CA: connected, a ping through the tunnel (the firewall's vpn → gateway cell allowed ICMP), the client listed; the client's certificate revoked at OpenBao: refused. Not tried: TCP,client-dns, a pair, Windows or phone clients. - Not done: IPv6 inside the tunnel, a client's certificate made by the box, user and password logins, one config for several servers.
NetBird¶
Not supported yet: NetBird isn't in Fedora (only in COPR repositories),
so the image can't have it. Its client is packageable (BSD-3-Clause;
the server parts are AGPL-3.0), so packaging netbird-client for Fedora
comes first; NetBird's own server (management, signal, relay) would need
packaging too for the box to run one. The box would then be a NetBird
peer and routing peer the way it is a tailnet node: wt0 in a zone, and
NetBird's own firewall handling off where it can be.
Rules for a client by name¶
A firewall rule's source or destination may be a WireGuard peer, written
tunnel/peer (wg0/phone): its address in the tunnel, and its IPv6
address when the tunnel has one. It follows the peer if its address
changes, and an unknown name is refused when the config is checked.
stronghold firewall explain takes the same names.
Not done yet¶
- OCSP (above); OpenVPN clients (the box as a client of another server).
- A client's config can't be re-shown with its private key (by design).