# vpn-router A Debian package that configures a Linux host or virtual machine as a VPN router: - site-to-site IKEv2 IPSec with pre-shared key or certificate authentication (strongSwan swanctl) - road-warrior (P2S) access using IKEv2 EAP-TLS - a two-level certificate authority: a root, which may be kept offline, and issuing CAs for the VPN clients, for site-to-site and for general use, with certificate templates, published certificates and CRLs, and an optional ACME server - OpenVPN (P2S) access with a certificate and a password per user, the users kept in a local LDAP directory and managed with `vpn-router-user` or the web service - optional WireGuard endpoint - routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules - firewall rules for the ports Docker publishes, when Docker is installed - metrics of the router kept in PostgreSQL, served for an OpenTelemetry Collector and pushed over OTLP Nothing in the package is specific to a cloud provider. It is built on the external and internal NIC names, and a `mode` setting says how much of the rest you are supplying and how much is read from the system. Detection happens only in the mode that asks for it, so a router never quietly guesses which interface to NAT out of unless you told it to. Platform-specific additions live in separate modules and are selected explicitly. Installing the package configures nothing by default. The shipped platform is `none`, which means the files are laid down and the machine is left alone until you say otherwise. ## Installing The package can be installed by hand or as a step in automated provisioning. Both are supported equally, and both end up with the same two things: a configuration file and a service that applies it. ``` /etc/vpn-router/vpn-router.json the configuration systemctl restart vpn-router-setup apply it ``` If the host runs Docker, the recommended order is Docker first and the package second. The package secures the ports Docker publishes when it finds the Docker service; the steps for a host that already runs Docker, and for installing Docker later, are under [Docker](#docker). ### By hand ```sh apt-get install vpn-router # answer "none" to the platform question $EDITOR /etc/vpn-router/vpn-router.json systemctl restart vpn-router-setup ``` Answering `none` to the platform question defers everything: no further questions are asked and nothing on the machine is configured. The only change an install makes is the package's own `/etc/sysctl.d/` drop-in, which enables IP forwarding and relaxes `rp_filter` as a router needs. Set `platform` in the file when you are ready. ### Automated Preseed the answers and the router comes up configured, with no follow-up command. Note that `platform` must be something other than `none`, and `mode` says how much you are supplying: ```shell debconf-set-selections <<'EOF' vpn-router vpn-router/platform select generic vpn-router vpn-router/mode select manual vpn-router vpn-router/external_interface string eth0 vpn-router vpn-router/internal_interface string eth1 vpn-router vpn-router/int_addr string 10.1.1.4 vpn-router vpn-router/int_gateway_ip string 10.1.1.1 vpn-router vpn-router/local_fqdn string router.example.com vpn-router vpn-router/local_cidrs string 10.0.0.0/24 vpn-router vpn-router/remote_addrs string peer.example.net vpn-router vpn-router/remote_id string peer.example.net vpn-router vpn-router/remote_cidrs string 192.168.0.0/24 vpn-router vpn-router/psk password s3cr3t EOF DEBIAN_FRONTEND=noninteractive apt-get install -y vpn-router ``` With mode `interfaces` the `int_addr` and `int_gateway_ip` lines are dropped and read from the internal NIC instead; with mode `auto` the interface names go too. Alternatively write `/etc/vpn-router/vpn-router.json` directly before or after installing. A configuration-management tool should do that and then restart `vpn-router-setup`. See [examples/](examples/) for a cloud-init template and a shell installer, and [examples/azure/](examples/azure/) / [examples/gcp/](examples/gcp/) for full Terraform examples that deploy a router on Azure or GCP. ## Configuration `/etc/vpn-router/vpn-router.json` is JSON, mode 0600, and is created on first install. It is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies the answers given and asks before touching anything you altered by hand, and an upgrade asks no questions and leaves the file as it is. Settings the file leaves out take their defaults when it is read, so a file written by cloud-init or by hand needs only the settings that differ. A complete example is installed at `/usr/share/doc/vpn-router/vpn-router.json.example`. The file is an object of sections, each an object of settings. A section or setting that is left out takes its default, and an empty string or empty list means unset. Every value must have the type shown below; an unknown section or setting is an error. | Section | Setting | Type | Default | Meaning | |---|---|---|---|---| | `general` | `platform` | string | `none` | `none`, `generic`, `azure` or `gcp`. `none` defers configuration entirely | | `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` | | `interfaces` | `external` | string | | Name of the NIC facing the untrusted network | | `interfaces` | `internal` | string | | Name of the NIC facing the protected network | | `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, and the name of HTTPS, road-warrior and OpenVPN | | `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to road-warrior and OpenVPN clients | | `local` | `int_addr` | string | | This host's address on the internal network | | `local` | `int_gateway_ip` | string | | Next hop on the internal side | | `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below | | `p2s` | `enabled` | boolean | `false` | Road-warrior access | | `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients | | `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint | | `wireguard` | `address` | string | | The router's own address on `wg0` with the prefix of the peers' network, for example `192.168.200.1/24`; the network and the broadcast address of a network larger than /31 are refused | | `wireguard` | `listen_port` | integer | `51820` | UDP port | | `openvpn` | `enabled` | boolean | `false` | OpenVPN access for users | | `openvpn` | `address_pool` | string | | IPv4 CIDR assigned to OpenVPN clients | | `openvpn` | `port` | integer | `1194` | Port of the OpenVPN server on the external interface | | `openvpn` | `protocol` | string | `udp` | `udp` or `tcp` | | `docker` | `mode` | string | `auto` | `auto` secures Docker when its service is installed, `on` requires it, `off` removes the rules | | `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two | | `web` | `enabled` | boolean | `true` | The status web service | | `web` | `listen` | string | `both` | The interfaces the status web service listens on: `internal`, `external` or `both` | | `web` | `port` | integer | `7081` | TCP port of the status web service | | `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates | | `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status | | `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) | | `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) | | `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 | | `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) | | `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` | | `https` | `email` | string | | The e-mail address of the Let's Encrypt account | | `https` | `web_port` | integer | `7444` | HTTPS port of the status web service | | `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager | | `https` | `caddy_source` | string | `ubuntu` | Where Caddy is installed from: `ubuntu`, the Ubuntu archive, or `upstream`, Caddy's own repository (see HTTPS) | | `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it | | `pki` | `root_days` | integer | `7300` | Days the root CA's certificate is valid | | `pki` | `ca_days` | integer | `3650` | Days an issuing CA's certificate is valid | | `pki` | `key` | string | `rsa-4096` | Key of the root and the issuing CAs: `rsa-2048`, `rsa-3072`, `rsa-4096`, `ec-p256` or `ec-p384` | | `pki` | `crl_days` | integer | `7` | Days the CRLs of the issuing CAs are valid | | `pki` | `root_crl_days` | integer | `180` | Days the root CRL is valid, when the router signs it | | `pki` | `publish` | list | `http` | Where the CA certificates and CRLs are published: `http`, `http-80`, `https`, `https-default` (see Certificates) | | `pki` | `p2s_name` | string | `VPN Router P2S CA` | Common name of the P2S issuing CA | | `pki` | `s2s_name` | string | `VPN Router Site-to-Site CA` | Common name of the site-to-site issuing CA | | `pki` | `issuing_cas` | object | | The general-purpose issuing CAs, each a name and `{name, templates, acme}` (see Certificates) | | `pki` | `templates` | object | | Certificate templates, each a name and the options it changes or defines (see Certificates) | | `pki` | `extended_key_usages` | object | | Custom extended key usages templates may give, each a name and its OID in dotted form (see Certificates) | | `pki` | `root_key_warning` | boolean | `true` | `vpn-router-ca status` and `/ca` warn while the root key is on the router; `false` silences that warning | | `metrics` | `enabled` | boolean | `true` | The metrics service: collection, history and `/metrics` | | `metrics` | `listen` | string | `internal` | The interfaces `/metrics` is served on: `internal`, `external` or `both` | | `metrics` | `port` | integer | `9464` | TCP port of `/metrics` | | `metrics` | `https_port` | integer | `9465` | HTTPS port of `/metrics`, served by Caddy while `https.enabled` is true | | `metrics` | `service_account` | string | `metrics` | The service account `/metrics` takes, which `vpn-router-setup` adds and keeps | | `metrics` | `otlp_endpoint` | string | | An OTLP/HTTP URL the metrics are pushed to every minute, for example `https://collector.example.com:4318/v1/metrics` | | `metrics` | `otlp_username` | string | | The user name of HTTP Basic authentication at the OTLP endpoint | | `metrics` | `otlp_password` | string | | Its password | | `metrics` | `keep_details` | string | `24 hours` | How long the per-minute metrics are kept | | `metrics` | `keep_hourly` | string | `1 month` | How long the hourly summaries are kept | | `metrics` | `keep_daily` | string | `3 months` | How long the daily summaries are kept | | `metrics` | `keep_monthly` | string | `1 year` | How long the monthly summaries are kept | | `metrics` | `keep_yearly` | string | `10 years` | How long the yearly summaries are kept | | `backup` | `enabled` | boolean | `false` | Scheduled backups (see Backups) | | `backup` | `schedule` | string | `daily` | `hourly`, `daily` or `weekly` | | `backup` | `target` | string | `local` | `local`, an archive in `backup.path`, or `pbs`, a Proxmox Backup Server | | `backup` | `path` | string | `/var/backups/vpn-router` | The folder of local archives, an absolute path | | `backup` | `keep_hourly` | integer | `24` | Hourly local archives kept, with the hourly schedule | | `backup` | `keep_daily` | integer | `7` | Daily local archives kept, with the hourly and the daily schedule | | `backup` | `keep_weekly` | integer | `4` | Weekly local archives kept | | `backup` | `keep_monthly` | integer | `12` | Monthly local archives kept | | `backup` | `keep_yearly` | integer | `0` | Yearly local archives kept | | `backup` | `metrics` | boolean | `false` | The backup holds the database of the metrics and the refresh tokens | | `backup` | `rclone_remote` | string | | `remote:path` of `/etc/vpn-router/rclone.conf` the local archives are copied to | | `backup` | `rclone_mode` | string | `sync` | `sync` mirrors the archives, `copy` deletes nothing in the cloud | | `backup` | `pbs_repository` | string | | `user@realm!token@host[:port]:datastore` | | `backup` | `pbs_password` | string | | The secret of the API token | | `backup` | `pbs_fingerprint` | string | | The SHA-256 fingerprint of the server's certificate, when the system's CAs do not verify it | | `backup` | `pbs_namespace` | string | | The namespace of the datastore, such as `routers/azure` | | `backup` | `pbs_keyfile` | string | | An encryption key of `proxmox-backup-client key create`, an absolute path | ### Site-to-site connections `site_to_site` maps a connection name to its settings. A name starts with a lowercase letter and has at most 31 lowercase letters, digits and hyphens; it names the strongSwan connection, its child and its secret (`ike-psk-`). The install answers fill the connection `remote-site`; more are added in the file, with `vpn-config-edit`, or through `/site-to-site` of the status web service, which the Router Manager uses. | Setting | Type | Default | Meaning | |---|---|---|---| | `addrs` | list | | Remote gateway addresses or FQDNs | | `id` | string | | Remote IKE identity, an FQDN or an IPv4 or IPv6 address | | `cidrs` | list | | Remote subnets reachable through the tunnel | | `local_cidrs` | list | | Local subnets advertised into the tunnel | | `local_id_mode` | string | `fqdn` | The router's IKE identity: `fqdn` is `wan.local_fqdn`, `public_ip` the address it resolves to, `internal_ip` the internal address | | `auth` | string | `psk` | How both sides authenticate: `psk`, a pre-shared key, or `certificate` | | `psk` | string | | Pre-shared key, with `auth` `psk` | | `psk_file` | string | | Path to a file holding the key; wins over `psk` | | `remote_ca` | string | | With `auth` `certificate`, the PEM certificate of the CA that issued the peer's certificate; without it the router's site-to-site CA | A connection is set up once all of `addrs`, `id`, `cidrs`, `local_cidrs` and its local identity are known, with `psk` the key, and with `certificate` the router's site-to-site certificate; all such connections are written to `/etc/swanctl/conf.d/site-to-site.conf`. Traffic from a connection's `local_cidrs` to its `cidrs` is not masqueraded. With `auth` `certificate`, the router authenticates with its site-to-site certificate, which the site-to-site issuing CA signs for `wan.local_fqdn` and for the public or the internal address that a connection with `auth` `certificate` uses as its identity (`local_id_mode` `public_ip` or `internal_ip`); it is issued again when that set of names changes. The peer's certificate must name `id` and be issued by the router's site-to-site CA, or by the CA of `remote_ca`. A peer with no PKI of its own gets its certificate from the router (template `site-to-site`, see Certificates), with the root and the site-to-site CA certificates as its trust anchors. A minimal site-to-site configuration in `auto` mode: ```json { "general": { "platform": "generic", "mode": "auto" }, "wan": { "local_fqdn": "router.example.com" }, "local": { "cidrs": ["10.0.0.0/24"] }, "site_to_site": { "remote-site": { "addrs": ["peer.example.net"], "id": "peer.example.net", "cidrs": ["192.168.0.0/24"], "local_cidrs": ["10.0.0.0/24"], "psk": "s3cr3t" } } } ``` The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`id`) are an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configuration as given; strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`. WireGuard peers are P2S users' devices: a VPN user of the router's directory may have up to 16 peers, each with its public key and its IPs, IPv4 addresses or networks, kept in the user's entry (`vpnRouterWireGuardPeer`) and set with `PUT /users/` (`wireguard_peers`) or on the WireGuard tab of the Router Manager's Remote access management. A key is used by one peer only, the IPs of two peers may not overlap, and no peer's IPs cover the router's own address of `wireguard.address`. The package owns the `[Interface]` section of `/etc/wireguard/wg0.conf` and, between the lines `# BEGIN` and `# END WireGuard peers of the users of the router's directory`, a `[Peer]` section for each peer of a user who is not disabled; setup rewrites them when the settings or the peers change, and a change of a user's peers, disabling, enabling or removing a user with peers restarts setup. Every other `[Peer]` section, added by hand, is carried across untouched. While the directory cannot be reached, the users' peers in the file stay as they are. Every feature is independent. Leaving a group empty simply means that feature is not configured, and its generated files are removed. ### What `mode` decides `mode` states how much of the network configuration you are supplying, and therefore how much the package reads from the system: | `mode` | You provide | The package works out | |---|---|---| | `manual` | both interface names, `int_addr`, `int_gateway_ip` | nothing | | `interfaces` | both interface names | `int_addr` and `int_gateway_ip`, from those interfaces | | `auto` | nothing | the interface names too: the external one from the routing table, the internal one as the only other NIC with an address | `manual` is the only mode in which no detection code runs at all. `auto` is the convenient one and can pick the wrong interface, which is a trade you make deliberately by choosing it rather than something the package does behind your back. Detection ignores virtual interfaces, those the kernel lists under `/sys/devices/virtual`: loopback, bridges such as `docker0`, veth pairs and tunnels. Only the NICs of the machine count, so Docker does not change the result. If the host has more than two NICs with an address, `auto` stops and says so; use mode `interfaces` and name them. The addresses strongSwan binds on always come from the external NIC, in every mode, so they are never configured twice and cannot drift out of step with the host. If a named interface does not exist the service stops and says so. In `interfaces` and `auto` mode the internal gateway is taken from the routing table, falling back to the first host address of the connected subnet with a warning when the routing table has nothing to say. ## Docker Docker publishes container ports in its own firewall rules, which bypass UFW and can expose ports on the external interface. Docker evaluates the `DOCKER-USER` chain before its own rules, so the package filters there. With `docker.mode` set to `auto`, the package secures Docker when `docker.service` is installed, and does nothing otherwise. ### On a host that already runs Docker Installing Docker first is the recommended order. After installing the package: 1. Set `general.platform` in `/etc/vpn-router/vpn-router.json`. While it is `none`, nothing is configured, the Docker rules included. 2. List the TCP ports the running containers must expose to the internet, for example `"docker": { "exposed_ports": ["443"] }`. The default is none: once the rules are applied, every published port that is not listed is unreachable from the external interface, and remains reachable through the IPsec tunnel. 3. Apply the configuration with `systemctl restart vpn-router-setup`. Docker is running, so this also restarts `vpn-router-docker`, which installs the rules. 4. Check the result: ```sh iptables -S DOCKER-USER # "-j VPN-ROUTER-DOCKER" is the first rule iptables -S VPN-ROUTER-DOCKER # the rules of the external interface ``` ### When Docker is installed after the package The Docker rules are not applied when Docker starts for the first time, so run `systemctl restart vpn-router-setup` after installing Docker, then check as above. A reboot or a restart of Docker applies them as well. The rules sit in their own chain, `VPN-ROUTER-DOCKER`, entered by one jump at the top of `DOCKER-USER`; other rules in `DOCKER-USER` are left alone. For traffic that arrives on the external interface, in this order: 1. IPsec protected traffic is accepted. 2. TCP to the ports in `docker.exposed_ports` is accepted; none by default. 3. Packets of established and related connections are accepted. 4. Everything else is dropped. Traffic from every other interface, including the internal one and the Docker bridges, is not affected. The rules cover IPv4 only. `vpn-router-setup` starts after `docker.service`, so Docker is up when it looks for it; the ordering has no effect on a host without Docker. The `vpn-router-docker` service applies the rules after `docker.service` starts and removes them when Docker stops, so restarting Docker restores them. `systemctl restart vpn-router-setup` applies a changed configuration while Docker runs. With `docker.mode` set to `on`, the service fails when Docker is not installed or its daemon is not running; with `off` it removes the rules. ## Editing the configuration `vpn-config-edit`, run as root, shows and changes `/etc/vpn-router/vpn-router.json`: ```sh vpn-config-edit show vpn-config-edit set web.status_content standard vpn-config-edit set web.port 7082 vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4 vpn-config-edit set site_to_site.branch.addrs branch.example.net vpn-config-edit remove site_to_site.branch ``` `show` prints the file as indented JSON and does not change it. `set` takes a setting as `section.option`, or as `site_to_site.NAME.option` for a site-to-site connection, which it creates with the defaults when it does not exist, and converts the value to the setting's type: a whole number, `true` or `false`, a list separated by commas (an empty value is an empty list), or a string; an unknown setting is refused. `remove site_to_site.NAME` removes a connection. It checks the whole configuration as `vpn-router-setup` does, and only then writes the file, indented, with mode 0600, and reminds that `vpn-router-setup` applies it. The status web service writes the file the same way, and a lock keeps the two from overwriting each other's change. A refused request exits with status 2, any other failure with 1. ## Applying changes ```sh systemctl restart vpn-router-setup ``` The service also runs at boot. With platform `none` it does nothing and says so. Otherwise it regenerates the configuration files, resolves the interfaces and their addresses as `mode` directs, applies routes, generates WireGuard keys and injects firewall rules. It provisions the user directory in `slapd` while OpenVPN or road-warrior access is configured. It then starts `strongswan`, `wg-quick@wg0` and `openvpn-server@vpn-router` if the corresponding feature is configured, reloading or restarting them only when their inputs changed, and restarts `systemd-resolved` only when the road-warrior DNS drop-in changed. Turning a feature off in the configuration stops its service and removes its files. Every step is idempotent, so restarting with no changes does nothing. To regenerate the configuration files and PKI without starting, stopping or reloading anything: ```sh /usr/lib/vpn-router/configure ``` This does still write to the system: it renders into `/etc/swanctl/`, `/etc/systemd/resolved.conf.d/` and `/etc/wireguard/`, creates what the PKI is missing and adds the root CA to the host trust store. What it leaves alone is routes, firewall rules and services - that is what `systemctl restart vpn-router-setup` adds. ## Certificates The router has a two-level certificate authority, kept by `vpn-router-setup` and managed with `vpn-router-ca`, `vpn-router-user` and `/ca` of the status web service, which the Router Manager uses. It is held in `ou=pki,dc=vpn-router,dc=local` of the router's directory, next to the users: ``` cn=root the root CA; without vpnRouterPrivateKey while the root is offline cn= an issuing CA; with vpnRouterCertificateRequest while it waits for the offline root cn=-