Skip to content

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-id gets that /64 of the site's ULA prefix (fd53:4800:7100:32::1/64 for 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: all is 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_nat6 table, 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 and stronghold vpn status show 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 with systemd-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/nm renders each tunnel as a NetworkManager wireguard profile, 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) reads wg show all dump for 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 in docs/wan.md cover 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 with wg 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/ha brings 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): tailscale0 is in the zone, 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 vpn section's tailscale-auth-key; a secret on the box, shared by a pair), or, without one, the login URL the box's status shows. With login-server: local the box makes its own key from its headscale. The control server approves the routes: headscale on the box approves its own node's (policy autoApprovers); elsewhere approve them there (or give the box's user autoApprovers).
  • 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 an http:// 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 headscale lists the nodes; routes ID SUBNETS approves another subnet router's routes, delete ID removes a node. stronghold vpn tailscale shows 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's ipsec-interface), in zone like 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 ID asks for it, --generate makes 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 with stronghold vpn ipsec cert ID --cert FILE --key FILE --ca FILE (--remove takes them away), the vpn body's ipsec-certs or 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 FILE alone gives the CA. strongholdd puts them in libreswan's NSS database (/var/lib/ipsec/nss: openssl pkcs12, pk12util, certutil, the nickname sh-<id>, the CA trusted as sh-<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) unless local-id or remote-id says otherwise (an @name is matched against the certificate's names), so remote: any needs no remote-id with them. Both sides must use the same kind of key (both RSA or both ECDSA): libreswan 5.3 takes one authby for the two ends, and a box with an RSA key against a peer's ECDSA one (or the other way round) failed with AUTHENTICATION_FAILED in the lab, also with leftauth and rightauth set on one side. libreswan signs only with the kind of key it has, so authby is ecdsa or rsasig by 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 with crlcheckinterval set, which is global (config setup), so strongholdd writes that block into stronghold.conf when a tunnel has crl (the box's own ipsec.conf is left alone) and restarts ipsec.service once 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 in stronghold-<id>.revoked), it restarts ipsec.service, and the new authentication refuses a revoked one. OCSP (libreswan's ocsp-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 answered SEC_ERROR_OCSP_SERVER_ERROR to the responses of ECDSA CAs (NSS's own ocspclnt the 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 restarts ipsec.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 after ipsec auto --rereadcerts; the restart refused it every time (three runs).
  • Seen. stronghold vpn ipsec (and GET /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::/64 in local-networks or remote-networks, and a network named in local-networks offers 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 unless ipv6: 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, or remote an 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 as left= (libreswan takes IPv4's from %interface but 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 any for a branch that connects from wherever it is (a dynamic address): then remote-id says 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's leftsubnets), 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 explain knows the remote subnets.
  • How it runs. libreswan on the host (its ipsec.service; there is no SELinux domain of ours: Fedora's ipsec_t), libreswan installed when the first tunnel is applied. strongholdd writes /etc/ipsec.d/stronghold.conf (a conn sh-<id> each) and stronghold.secrets (0600), and loads only the connections that changed (ipsec auto --replace), starts those with initiate and 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 (%ifname for 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 trafficstatus and showstates). Ansible: stronghold_vpn's ipsec list, and stronghold_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-crypt key 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 with stronghold vpn openvpn cert ID --cert FILE --key FILE --ca FILE, the vpn body's openvpn-certs, Ansible's openvpn_certs or 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 FILE alone. 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 the tls-crypt key 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>.service with /etc/openvpn/server/stronghold-<id>.conf and 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).