Reengineed the code. Generalized the package. Cloud configurators are modules.

This commit is contained in:
2026-08-10 09:02:45 +02:00
parent be4bb34bb0
commit 083ad9a596
40 changed files with 2367 additions and 650 deletions
+3
View File
@@ -8,12 +8,15 @@ RUN apt-get update \
build-essential \
dpkg-dev \
debhelper \
lintian \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY . src/
RUN cd src && dpkg-buildpackage -us -uc -b
RUN lintian --no-tag-display-limit /build/*.changes || true
RUN mkdir /out \
&& find /build -maxdepth 1 \( -name '*.deb' -o -name '*.buildinfo' -o -name '*.changes' \) \
-exec install -o "$HOST_UID" -g "$HOST_GID" -m 0644 {} /out/ \;
+2 -2
View File
@@ -3,7 +3,7 @@ set -e
mkdir -p out
container build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t cloud-router-builder "$@" .
container run --rm -v "$(pwd)/out:/mnt" cloud-router-builder sh -c 'cp -p /out/* /mnt/'
container build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
container run --rm -v "$(pwd)/out:/mnt" vpn-router-builder sh -c 'cp -p /out/* /mnt/'
echo "Build artifacts written to out/"
+1 -1
View File
@@ -1,4 +1,4 @@
cloud-router (1.0.0-1) unstable; urgency=medium
vpn-router (1.0.0-1) unstable; urgency=medium
* Initial release.
+23 -14
View File
@@ -2,22 +2,31 @@
set -e
. /usr/share/debconf/confmodule
db_input high cloud-router/local_addrs || true
db_input high cloud-router/local_fqdn || true
db_input high cloud-router/local_id_mode || true
db_input high cloud-router/local_cidrs || true
db_input high cloud-router/remote_addrs || true
db_input high cloud-router/remote_id || true
db_input high cloud-router/psk || true
db_input high cloud-router/remote_cidrs || true
db_input high cloud-router/router_int_gateway_ip || true
db_input high cloud-router/p2s_address_pool || true
db_input high cloud-router/wg_enabled || true
db_input high vpn-router/platform || true
db_input high vpn-router/external_interface || true
db_input high vpn-router/internal_interface || true
db_input high vpn-router/local_fqdn || true
db_input high vpn-router/local_id_mode || true
db_input high vpn-router/local_cidrs || true
db_input high vpn-router/int_gateway_ip || true
db_input high vpn-router/remote_addrs || true
db_input high vpn-router/remote_id || true
db_input high vpn-router/remote_cidrs || true
db_input high vpn-router/psk || true
db_input high vpn-router/p2s_enabled || true
db_input high vpn-router/wg_enabled || true
db_go || true
db_get cloud-router/wg_enabled
db_get vpn-router/p2s_enabled
if [ "$RET" = "true" ]; then
db_input high cloud-router/wg_address || true
db_input high cloud-router/wg_listen_port || true
db_input high vpn-router/p2s_address_pool || true
db_input high vpn-router/p2s_ca_name || true
db_go || true
fi
db_get vpn-router/wg_enabled
if [ "$RET" = "true" ]; then
db_input high vpn-router/wg_address || true
db_input high vpn-router/wg_listen_port || true
db_go || true
fi
+21 -11
View File
@@ -1,4 +1,4 @@
Source: cloud-router
Source: vpn-router
Section: net
Priority: optional
Maintainer: Sławomir Koszewski <slawek@koszewscy.waw.pl>
@@ -6,7 +6,7 @@ Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Rules-Requires-Root: no
Package: cloud-router
Package: vpn-router
Architecture: all
Depends: ${misc:Depends},
strongswan-swanctl,
@@ -17,13 +17,23 @@ Depends: ${misc:Depends},
ufw,
debconf,
openssl,
python3-jinja2
Description: Linux cloud router with IPSec and optional WireGuard
Configures a Linux host as a cloud router providing site-to-site IKEv2
IPSec (strongSwan swanctl) and road-warrior P2S VPN (EAP-TLS). WireGuard
is optionally enabled. Includes a PKI helper library (simple-ca.sh) for
managing the road-warrior certificate authority.
python3,
python3-jinja2,
iproute2,
systemd-resolved
Description: Linux VPN router with IPSec, road-warrior and WireGuard support
Configures a Linux host or virtual machine as a VPN router: site-to-site
IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS,
optional WireGuard, and the routing, NAT, MSS clamping and firewall rules a
router needs. Nothing in the package is specific to a cloud provider.
.
Site-specific values are collected via debconf at install time and written
to /etc/default/cloud-router. A one-shot systemd service (cloud-router-setup)
applies UFW rules and WireGuard keys on first boot.
Settings live in /etc/vpn-router/vpn-router.conf, which is created on first
install and owned by the administrator afterwards. Editing it and restarting
vpn-router-setup applies the change; the same service reconciles the system
at every boot. It is built on two inputs, the external and internal interface
names; the addresses on them are read from the system rather than configured
again, and nothing is auto-detected.
.
Platform-specific additions are provided by modules under
/usr/lib/vpn-router, selected explicitly by the platform setting. Modules for
Azure and GCP are included.
+1 -1
View File
@@ -1,5 +1,5 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: cloud-router
Upstream-Name: vpn-router
Upstream-Contact: Sławomir Koszewski <slawek@koszewscy.waw.pl>
Files: *
+4 -10
View File
@@ -1,10 +1,4 @@
etc/cloud-router
etc/cloud-router/pki
etc/wireguard
etc/swanctl/conf.d
etc/swanctl/x509ca
etc/swanctl/x509
etc/swanctl/private
etc/systemd/resolved.conf.d
usr/lib/cloud-router
usr/share/cloud-router/templates
etc/vpn-router
usr/lib/vpn-router
usr/lib/vpn-router/vpnrouter_platforms
usr/share/vpn-router/templates
+9 -4
View File
@@ -1,4 +1,9 @@
src/etc/sysctl.d/99-cloud-router.conf etc/sysctl.d/
src/opt/cloud-router/bin/simple-ca opt/cloud-router/bin/
src/usr/lib/cloud-router/configure usr/lib/cloud-router/
src/usr/share/cloud-router/templates/* usr/share/cloud-router/templates/
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
src/usr/lib/vpn-router/seed-config usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
src/usr/share/doc/vpn-router/vpn-router.conf.example usr/share/doc/vpn-router/
+37 -36
View File
@@ -4,47 +4,48 @@ set -e
case "$1" in
configure)
# ── Read debconf answers ──────────────────────────────────────────────
db_get cloud-router/local_addrs; CLOUD_ROUTER_LOCAL_ADDRS="$RET"
db_get cloud-router/local_fqdn; CLOUD_ROUTER_LOCAL_FQDN="$RET"
db_get cloud-router/local_id_mode; CLOUD_ROUTER_LOCAL_ID_MODE="$RET"
db_get cloud-router/local_cidrs; CLOUD_ROUTER_LOCAL_CIDRS="$RET"
db_get cloud-router/remote_addrs; CLOUD_ROUTER_REMOTE_ADDRS="$RET"
db_get cloud-router/remote_id; CLOUD_ROUTER_REMOTE_ID="$RET"
db_get cloud-router/psk; CLOUD_ROUTER_PSK="$RET"
db_get cloud-router/remote_cidrs; CLOUD_ROUTER_REMOTE_CIDRS="$RET"
db_get cloud-router/router_int_gateway_ip; CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP="$RET"
db_get cloud-router/p2s_address_pool; CLOUD_ROUTER_P2S_ADDRESS_POOL="$RET"
db_get cloud-router/wg_enabled; CLOUD_ROUTER_WG_ENABLED="$RET"
db_get cloud-router/wg_address; CLOUD_ROUTER_WG_ADDRESS="$RET"
db_get cloud-router/wg_listen_port; CLOUD_ROUTER_WG_LISTEN_PORT="$RET"
# --- Read the installer's answers ---
db_get vpn-router/platform; VPN_ROUTER_PLATFORM="$RET"
db_get vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$RET"
db_get vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$RET"
db_get vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$RET"
db_get vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$RET"
db_get vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$RET"
db_get vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$RET"
db_get vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$RET"
db_get vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$RET"
db_get vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$RET"
db_get vpn-router/psk; VPN_ROUTER_PSK="$RET"
db_get vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$RET"
db_get vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$RET"
db_get vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$RET"
db_get vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$RET"
db_get vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$RET"
db_get vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$RET"
# ── Render configuration files via Jinja2 templates ─────────────────
export CLOUD_ROUTER_LOCAL_ADDRS CLOUD_ROUTER_LOCAL_FQDN \
CLOUD_ROUTER_LOCAL_ID_MODE CLOUD_ROUTER_LOCAL_CIDRS \
CLOUD_ROUTER_REMOTE_ADDRS CLOUD_ROUTER_REMOTE_ID \
CLOUD_ROUTER_PSK CLOUD_ROUTER_REMOTE_CIDRS \
CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP CLOUD_ROUTER_P2S_ADDRESS_POOL \
CLOUD_ROUTER_WG_ENABLED CLOUD_ROUTER_WG_ADDRESS \
CLOUD_ROUTER_WG_LISTEN_PORT
export VPN_ROUTER_PLATFORM VPN_ROUTER_EXTERNAL_INTERFACE \
VPN_ROUTER_INTERNAL_INTERFACE VPN_ROUTER_LOCAL_FQDN \
VPN_ROUTER_LOCAL_ID_MODE VPN_ROUTER_LOCAL_CIDRS \
VPN_ROUTER_INT_GATEWAY_IP \
VPN_ROUTER_REMOTE_ADDRS VPN_ROUTER_REMOTE_ID \
VPN_ROUTER_REMOTE_CIDRS VPN_ROUTER_PSK \
VPN_ROUTER_P2S_ENABLED VPN_ROUTER_P2S_ADDRESS_POOL \
VPN_ROUTER_P2S_CA_NAME VPN_ROUTER_WG_ENABLED \
VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT
/usr/lib/cloud-router/configure
# --- Create the configuration file, only if it does not exist ---
/usr/lib/vpn-router/seed-config
db_set cloud-router/psk ""
# The key now lives in the configuration file; do not keep a copy.
db_set vpn-router/psk ""
# ── Apply system settings ─────────────────────────────────────────────
sysctl --system
netplan apply
systemctl daemon-reload
systemctl restart systemd-resolved
# Apply the sysctl drop-in shipped by this package so it takes effect
# without waiting for a reboot.
sysctl --system >/dev/null
# ── UFW: ensure SSH is allowed then enable ────────────────────────────
ufw allow 22/tcp
ufw --force enable
ufw reload
# ── strongSwan ────────────────────────────────────────────────────────
systemctl enable --now strongswan
# The firewall is not touched here. Enabling it, allowing SSH and
# setting the forward policy all belong to vpn-router-setup, which does
# them only once the configuration says this host is a router.
;;
esac
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
set -e
BEFORE_RULES=/etc/ufw/before.rules
# Remove the rule blocks this package inserted, together with the single blank
# line that precedes each one, so before.rules returns to its original content.
strip_ufw_blocks() {
[ -f "$BEFORE_RULES" ] || return 0
grep -q '^# ROUTER MSS RULES START$\|^# IPSEC RULES START$' "$BEFORE_RULES" || return 0
tmp="$(mktemp)"
awk '
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
skip = 1; pending = 0; next
}
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
skip = 0; next
}
skip { next }
/^$/ { pending++; next }
{
while (pending > 0) { print ""; pending-- }
print
}
END { while (pending > 0) { print ""; pending-- } }
' "$BEFORE_RULES" > "$tmp"
cat "$tmp" > "$BEFORE_RULES"
rm -f "$tmp"
}
case "$1" in
purge)
strip_ufw_blocks
rm -f /etc/swanctl/conf.d/remote-site.conf \
/etc/swanctl/conf.d/road-warrior.conf \
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
/etc/wireguard/wg0.conf \
/etc/vpn-router/vpn-router.conf
# Key material is not package state: /etc/vpn-router/pki, the WireGuard
# key pair and everything under /etc/swanctl are left in place.
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
;;
esac
#DEBHELPER#
+9 -1
View File
@@ -3,7 +3,15 @@ set -e
case "$1" in
remove|deconfigure)
systemctl disable --now strongswan || true
# Stop offering the tunnels, but leave strongSwan itself enabled: it is
# a shared service and may be in use by something other than this
# package. The connection files are removed on purge.
if [ -d /run/systemd/system ]; then
swanctl --terminate --ike remote-site >/dev/null 2>&1 || true
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
# This package enabled wg-quick@wg0, so it takes it down again.
systemctl disable --now wg-quick@wg0 >/dev/null 2>&1 || true
fi
;;
esac
+3
View File
@@ -1,3 +1,6 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_installsystemd:
dh_installsystemd --name=vpn-router-setup
+98 -48
View File
@@ -1,80 +1,130 @@
Template: cloud-router/local_addrs
Type: string
Description: Local WAN IP address(es)
Comma-separated list of local WAN IP addresses that strongSwan binds on
for the site-to-site and road-warrior tunnels (e.g. 10.1.2.3).
Template: vpn-router/platform
Type: select
Choices: generic, azure, gcp
Default: generic
Description: Platform module
Platform-specific additions to load. The router works without any of them;
a module only adds what makes sense on its platform, such as the platform
DNS resolver for road-warrior clients.
Template: cloud-router/local_fqdn
Template: vpn-router/external_interface
Type: string
Default:
Description: External network interface
Name of the interface facing the untrusted network, for example eth0 or
ens4. The addresses strongSwan binds on are read from it, so they are never
configured separately.
.
Leave empty to configure the router later by editing
/etc/vpn-router/vpn-router.conf.
Template: vpn-router/internal_interface
Type: string
Default:
Description: Internal network interface
Name of the interface facing the protected network, for example eth1 or
ens5. Routes for the local subnets are applied to it.
Template: vpn-router/local_fqdn
Type: string
Default:
Description: Local router FQDN
Fully-qualified domain name of this router (e.g. router.example.com).
Used as the road-warrior server identity and certificate CN.
Fully-qualified domain name of this router (for example
router.example.com). Used as the road-warrior server identity and
certificate common name.
Template: cloud-router/local_id_mode
Template: vpn-router/local_id_mode
Type: select
Choices: fqdn, public_ip, internal_ip
Default: fqdn
Description: IKE local identity mode
How to derive the IKE identity advertised to the remote site:
fqdn — use the FQDN (default; requires matching on remote side)
public_ip — resolve the public IP from DNS at first boot
internal_ip — use the local WAN IP address
.
fqdn - use the FQDN, which must match what the peer expects.
.
public_ip - resolve the FQDN and use the address it returns.
.
internal_ip - use the internal NIC address.
Template: cloud-router/local_cidrs
Template: vpn-router/local_cidrs
Type: string
Default:
Description: Local subnet CIDR(s)
Comma-separated list of local subnet CIDRs to advertise into the
site-to-site tunnel (e.g. 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
site-to-site tunnel (for example 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
Template: cloud-router/remote_addrs
Template: vpn-router/int_gateway_ip
Type: string
Default:
Description: Internal network gateway IP
Address of the next-hop gateway on the internal side, used to route the
local subnets listed above.
Template: vpn-router/remote_addrs
Type: string
Default:
Description: Remote site WAN IP address(es)
Comma-separated list of remote site WAN IP addresses for the
Comma-separated list of remote gateway addresses or FQDNs for the
site-to-site IPSec tunnel.
Template: cloud-router/remote_id
Template: vpn-router/remote_id
Type: string
Default:
Description: Remote site IKE identity
IKE identity of the remote peer (FQDN, without leading @).
IKE identity of the remote peer, without a leading @.
Template: cloud-router/psk
Template: vpn-router/remote_cidrs
Type: string
Default:
Description: Remote subnet CIDR(s)
Comma-separated list of remote subnet CIDRs reachable through the
site-to-site tunnel (for example 192.168.0.0/24).
Template: vpn-router/psk
Type: password
Description: Pre-shared key (PSK)
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the
value configured on the remote peer.
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the value
configured on the remote peer. Stored base64-encoded in the configuration
file and cleared from the debconf database after installation.
Template: cloud-router/remote_cidrs
Type: string
Description: Remote subnet CIDR(s)
Comma-separated list of remote subnet CIDRs for the site-to-site
tunnel (e.g. 192.168.0.0/24).
Template: cloud-router/router_int_gateway_ip
Type: string
Description: Internal network gateway IP
IP address of the next-hop gateway on the internal NIC (eth1).
Used in the netplan route for the local subnet.
Template: cloud-router/p2s_address_pool
Type: string
Description: Road-warrior address pool
CIDR block assigned to road-warrior VPN clients (e.g. 172.16.0.0/24).
Template: cloud-router/wg_enabled
Template: vpn-router/p2s_enabled
Type: boolean
Default: false
Description: Enable WireGuard VPN?
If true, WireGuard is configured on wg0 and its UFW rules are installed.
Description: Enable road-warrior (P2S) access?
If enabled, this router accepts IKEv2 EAP-TLS connections from individual
clients. Certificates are taken from /etc/vpn-router/pki, and a local
certificate authority is created there if that directory is empty.
Template: cloud-router/wg_address
Template: vpn-router/p2s_address_pool
Type: string
Default: 10.0.1.1/24
Description: WireGuard interface address
IP address and prefix length for the wg0 interface (e.g. 10.0.1.1/24).
Only used when WireGuard is enabled.
Default:
Description: Road-warrior address pool
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
Template: cloud-router/wg_listen_port
Template: vpn-router/p2s_ca_name
Type: string
Default: VPN Router CA
Description: Road-warrior CA name
Common name for the certificate authority created in /etc/vpn-router/pki
when that directory is empty. Ignored when certificates are supplied.
Template: vpn-router/wg_enabled
Type: boolean
Default: false
Description: Enable WireGuard?
If enabled, WireGuard is configured on wg0, a key pair is generated, and
the matching firewall rule is installed. Peers are added by editing
/etc/wireguard/wg0.conf.
Template: vpn-router/wg_address
Type: string
Default:
Description: WireGuard interface address
Address and prefix length for the wg0 interface (for example
192.168.200.1/24).
Template: vpn-router/wg_listen_port
Type: string
Default: 51820
Description: WireGuard listen port
UDP port that WireGuard listens on. Only used when WireGuard is enabled.
UDP port that WireGuard listens on.
@@ -0,0 +1,13 @@
[Unit]
Description=VPN Router Setup
Documentation=file:/usr/share/doc/vpn-router/README.md
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/lib/vpn-router/setup
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
Regular → Executable
+13 -1
View File
@@ -1,3 +1,15 @@
#!/bin/sh
set -e
VERSION="$(dpkg-parsechangelog --show-field Version -l debian/changelog)"
PACKAGE="$(dpkg-parsechangelog --show-field Source -l debian/changelog)"
DEB="out/${PACKAGE}_${VERSION}_all.deb"
if [ ! -f "$DEB" ]; then
printf 'ERROR: %s does not exist. Run ./build.sh first.\n' "$DEB" >&2
exit 1
fi
curl -v --user "slawek:$(cat ~/.gitea-packages-token)" \
--upload-file out/cloud-router_1.0.0-1_all.deb \
--upload-file "$DEB" \
"https://gitea.koszewscy.waw.pl/api/packages/slawek/debian/pool/noble/main/upload"
@@ -1,208 +0,0 @@
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Sławomir Koszewski
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""simple-ca — minimal CA for cloud-router IKEv2 PKI.
Directory layout:
/etc/swanctl/x509ca/ca.pem CA certificate (strongSwan trust anchor)
/etc/swanctl/private/ca.key CA private key
/etc/swanctl/x509/server.pem server certificate
/etc/swanctl/private/server.key server private key
/etc/cloud-router/pki/{name}_cert.pem user/road-warrior certificate
/etc/cloud-router/pki/{name}_key.pem user/road-warrior private key
/etc/cloud-router/pki/{name}.pfx PKCS#12 bundle for client distribution
"""
import argparse
import subprocess
import sys
from pathlib import Path
CA_CERT = Path('/etc/swanctl/x509ca/ca.pem')
CA_KEY = Path('/etc/swanctl/private/ca.key')
SERVER_CERT = Path('/etc/swanctl/x509/server.pem')
SERVER_KEY = Path('/etc/swanctl/private/server.key')
PKI_DIR = Path('/etc/cloud-router/pki')
def _run(*args, stdin=None):
result = subprocess.run(list(args), input=stdin, capture_output=True)
if result.returncode != 0:
sys.stderr.buffer.write(result.stderr)
sys.exit(1)
return result.stdout
def _san_prefix(s):
if '@' in s:
return f'email:{s}'
parts = s.split('.')
if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
return f'IP:{s}'
return f'DNS:{s}'
def cmd_make_ca(args):
if CA_CERT.exists() and CA_KEY.exists():
print('Root CA already exists, skipping.')
return
print('Generating root CA certificate and key...')
_run(
'openssl', 'req',
'-x509',
'-newkey', 'rsa:4096',
'-keyout', str(CA_KEY),
'-out', str(CA_CERT),
'-days', str(args.days),
'-noenc',
'-subj', f'/CN={args.name}',
'-text',
'-addext', 'basicConstraints=critical,CA:TRUE,pathlen:1',
'-addext', 'keyUsage=critical,keyCertSign,cRLSign',
)
CA_KEY.chmod(0o600)
print(f'Root CA created.')
print(f' Certificate : {CA_CERT}')
print(f' Private key : {CA_KEY}')
def cmd_make_cert(args):
name = args.subject.split('.')[0]
if args.type == 'server':
cert_path = SERVER_CERT
key_path = SERVER_KEY
else:
cert_path = PKI_DIR / f'{name}_cert.pem'
key_path = PKI_DIR / f'{name}_key.pem'
if cert_path.exists() and key_path.exists():
print(f'Certificate already exists at {cert_path}, skipping.')
return
if not CA_CERT.exists() or not CA_KEY.exists():
print('ERROR: Root CA not found. Run: simple-ca make-ca <name>', file=sys.stderr)
sys.exit(1)
req_args = [
'openssl', 'req',
'-newkey', 'rsa:4096',
'-keyout', str(key_path),
'-noenc',
'-subj', f'/CN={args.subject}',
'-addext', 'basicConstraints=critical,CA:FALSE',
]
if args.type == 'server':
sans = [f'DNS:{args.subject}'] + [_san_prefix(s) for s in args.san]
req_args += [
'-addext', 'keyUsage=critical,digitalSignature,keyEncipherment',
'-addext', 'extendedKeyUsage=serverAuth,clientAuth',
'-addext', f'subjectAltName={",".join(sans)}',
]
else:
sans = [_san_prefix(s) for s in args.san]
req_args += [
'-addext', 'keyUsage=critical,digitalSignature,nonRepudiation',
'-addext', 'extendedKeyUsage=clientAuth,emailProtection',
]
if sans:
req_args += ['-addext', f'subjectAltName={",".join(sans)}']
x509_args = [
'openssl', 'x509',
'-req',
'-CA', str(CA_CERT),
'-CAkey', str(CA_KEY),
'-copy_extensions', 'copyall',
'-days', str(args.days),
'-text',
'-out', str(cert_path),
]
csr = _run(*req_args)
_run(*x509_args, stdin=csr)
key_path.chmod(0o600)
print('Certificate created.')
print(f' Certificate : {cert_path}')
print(f' Private key : {key_path}')
def cmd_make_pfx(args):
name = args.name
cert_path = PKI_DIR / f'{name}_cert.pem'
key_path = PKI_DIR / f'{name}_key.pem'
pfx_path = PKI_DIR / f'{name}.pfx'
if not cert_path.exists() or not key_path.exists():
print(f'ERROR: Certificate or key not found for {name!r} in {PKI_DIR}.', file=sys.stderr)
sys.exit(1)
if pfx_path.exists():
print(f'ERROR: {pfx_path} already exists.', file=sys.stderr)
sys.exit(1)
password = args.password or 'changeit'
_run(
'openssl', 'pkcs12',
'-export',
'-out', str(pfx_path),
'-inkey', str(key_path),
'-in', str(cert_path),
'-certfile', str(CA_CERT),
'-password', f'pass:{password}',
)
print(f'PKCS#12 created: {pfx_path}')
def main():
ap = argparse.ArgumentParser(
prog='simple-ca',
description='Minimal CA for cloud-router IKEv2 PKI.',
)
sub = ap.add_subparsers(dest='command', required=True)
p_ca = sub.add_parser('make-ca', help='Create root CA')
p_ca.add_argument('name', help='CA common name')
p_ca.add_argument('--days', type=int, default=3650, metavar='N')
p_ca.set_defaults(func=cmd_make_ca)
p_cert = sub.add_parser('make-cert', help='Issue a certificate')
p_cert.add_argument('subject', help='Subject CN (FQDN for server, username for user)')
p_cert.add_argument('san', nargs='*', help='Additional SANs: IP, DNS, or email')
p_cert.add_argument('--type', choices=['server', 'user'], default='server',
help='Certificate type (default: server)')
p_cert.add_argument('--days', type=int, default=365, metavar='N')
p_cert.set_defaults(func=cmd_make_cert)
p_pfx = sub.add_parser('make-pfx', help='Export PKCS#12 bundle for a user certificate')
p_pfx.add_argument('name', help='Certificate name (without extension)')
p_pfx.add_argument('--password', metavar='PASS',
help='Export password (default: changeit)')
p_pfx.set_defaults(func=cmd_make_pfx)
args = ap.parse_args()
args.func(args)
if __name__ == '__main__':
main()
-257
View File
@@ -1,257 +0,0 @@
#!/usr/bin/python3
"""Render cloud-router configuration files and configure the system."""
import os
import sys
import socket
import pathlib
import subprocess
import jinja2
TEMPLATE_DIR = pathlib.Path('/usr/share/cloud-router/templates')
def _require(name):
val = os.environ.get(name)
if val is None:
print(f'ERROR: environment variable {name} is not set', file=sys.stderr)
sys.exit(1)
return val
def build_context():
local_addrs = _require('CLOUD_ROUTER_LOCAL_ADDRS')
local_fqdn = _require('CLOUD_ROUTER_LOCAL_FQDN')
local_id_mode = _require('CLOUD_ROUTER_LOCAL_ID_MODE')
local_cidrs = _require('CLOUD_ROUTER_LOCAL_CIDRS')
remote_addrs = _require('CLOUD_ROUTER_REMOTE_ADDRS')
remote_id = _require('CLOUD_ROUTER_REMOTE_ID')
psk = _require('CLOUD_ROUTER_PSK')
remote_cidrs = _require('CLOUD_ROUTER_REMOTE_CIDRS')
router_int_gateway_ip = _require('CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP')
p2s_address_pool = _require('CLOUD_ROUTER_P2S_ADDRESS_POOL')
wg_enabled = _require('CLOUD_ROUTER_WG_ENABLED')
wg_address = _require('CLOUD_ROUTER_WG_ADDRESS')
wg_listen_port = _require('CLOUD_ROUTER_WG_LISTEN_PORT')
local_subnet = local_cidrs.split(',')[0].strip()
p2s_server_name = local_fqdn.split('.')[0]
if local_id_mode == 'fqdn':
local_id = f'@{local_fqdn}'
elif local_id_mode == 'public_ip':
try:
local_id = socket.getaddrinfo(local_fqdn, None, socket.AF_INET)[0][4][0]
except socket.gaierror as exc:
print(f'ERROR: cannot resolve {local_fqdn}: {exc}', file=sys.stderr)
sys.exit(1)
elif local_id_mode == 'internal_ip':
local_id = local_addrs
else:
local_id = f'@{local_fqdn}'
return {
'local_addrs': local_addrs,
'local_fqdn': local_fqdn,
'local_id_mode': local_id_mode,
'local_cidrs': local_cidrs,
'local_subnet': local_subnet,
'remote_addrs': remote_addrs,
'remote_id': remote_id,
'psk': psk,
'remote_cidrs': remote_cidrs,
'router_int_gateway_ip': router_int_gateway_ip,
'p2s_address_pool': p2s_address_pool,
'p2s_server_name': p2s_server_name,
'wg_enabled': wg_enabled,
'wg_address': wg_address,
'wg_listen_port': wg_listen_port,
'local_id': local_id,
}
def render(jinja_env, ctx, template_name, dest, mode):
content = jinja_env.get_template(template_name).render(ctx)
dest = pathlib.Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding='utf-8')
os.chmod(dest, mode)
os.chown(dest, 0, 0)
def detect_wan_iface():
result = subprocess.run(
['ip', 'route', 'get', '1.1.1.1'],
capture_output=True, text=True,
)
if result.returncode != 0:
print('ERROR: ip route get 1.1.1.1 failed', file=sys.stderr)
sys.exit(1)
tokens = result.stdout.split()
for i, tok in enumerate(tokens):
if tok == 'dev' and i + 1 < len(tokens):
return tokens[i + 1]
print('ERROR: unable to detect WAN interface', file=sys.stderr)
sys.exit(1)
def setup_wireguard(ctx):
if ctx['wg_enabled'] != 'true':
return
wg_dir = pathlib.Path('/etc/wireguard')
wg_dir.mkdir(mode=0o700, exist_ok=True)
key_file = wg_dir / 'wg0.key'
if not key_file.exists() or key_file.stat().st_size == 0:
result = subprocess.run(['wg', 'genkey'], capture_output=True, check=True)
key_file.write_bytes(result.stdout)
os.chmod(key_file, 0o600)
pub_result = subprocess.run(
['wg', 'pubkey'],
input=key_file.read_bytes(),
capture_output=True, check=True,
)
pub_file = wg_dir / 'wg0.pub'
pub_file.write_bytes(pub_result.stdout)
os.chmod(pub_file, 0o644)
def _insert_after(content, marker, block):
"""Insert block after the first line that exactly matches marker."""
lines = content.splitlines(keepends=True)
result = []
for line in lines:
result.append(line)
if line.rstrip('\n') == marker:
result.append(block)
return ''.join(result)
def _insert_after_first_commit(content, block):
"""Insert block after the first COMMIT line (end of *filter table)."""
lines = content.splitlines(keepends=True)
result = []
inserted = False
for line in lines:
result.append(line)
if not inserted and line.rstrip('\n') == 'COMMIT':
result.append(block)
inserted = True
return ''.join(result)
def _wg_block(ctx):
return (
'\n'
'# WIREGUARD RULES START\n'
f'-A ufw-before-input -p udp --dport {ctx["wg_listen_port"]} -j ACCEPT\n'
'# WIREGUARD RULES END\n'
)
def setup_ufw(ctx, wan_iface):
# ── DEFAULT_FORWARD_POLICY ────────────────────────────────────────────────
ufw_defaults = pathlib.Path('/etc/default/ufw')
if ufw_defaults.exists():
lines = ufw_defaults.read_text().splitlines(keepends=True)
lines = [
'DEFAULT_FORWARD_POLICY="ACCEPT"\n'
if ln.startswith('DEFAULT_FORWARD_POLICY=') else ln
for ln in lines
]
ufw_defaults.write_text(''.join(lines))
# ── before.rules ─────────────────────────────────────────────────────────
before_rules = pathlib.Path('/etc/ufw/before.rules')
if not before_rules.exists():
print(f'ERROR: {before_rules} does not exist', file=sys.stderr)
sys.exit(1)
content = before_rules.read_text()
# Idempotency: handle dpkg-reconfigure re-runs
if '# IPSEC RULES START' in content:
if ctx['wg_enabled'] == 'true' and '# WIREGUARD RULES START' not in content:
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
before_rules.write_text(content)
return
# Filter table additions (IPSEC, P2S DNS, FORWARD)
filter_block = (
'\n'
'# IPSEC RULES START\n'
'-A ufw-before-input -p udp --dport 500 -j ACCEPT\n'
'-A ufw-before-input -p udp --dport 4500 -j ACCEPT\n'
'-A ufw-before-input -p esp -j ACCEPT\n'
'-A ufw-before-input -m policy --dir in --pol ipsec -j ACCEPT\n'
'-A ufw-before-output -m policy --dir out --pol ipsec -j ACCEPT\n'
'-A ufw-before-forward -m policy --dir in --pol ipsec -j ACCEPT\n'
'-A ufw-before-forward -m policy --dir out --pol ipsec -j ACCEPT\n'
'# IPSEC RULES END\n'
'\n'
'# P2S DNS RULES START\n'
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p udp --dport 53 -j ACCEPT\n'
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p tcp --dport 53 -j ACCEPT\n'
'# P2S DNS RULES END\n'
'\n'
'# ROUTER FORWARD RULES START\n'
f'-A ufw-before-forward -s {ctx["local_subnet"]} -o {wan_iface} -j ACCEPT\n'
f'-A ufw-before-forward -d {ctx["local_subnet"]} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n'
'# ROUTER FORWARD RULES END\n'
)
content = _insert_after(content, '# End required lines', filter_block)
# NAT table (inserted after the filter table's COMMIT)
nat_lines = [
'\n# ROUTER NAT RULES START\n',
'*nat\n',
':POSTROUTING ACCEPT [0:0]\n',
'-F POSTROUTING\n',
]
for cidr in ctx['remote_cidrs'].split(','):
nat_lines.append(
f'-A POSTROUTING -s {ctx["local_subnet"]} -d {cidr.strip()} -j RETURN\n'
)
nat_lines.append(
f'-A POSTROUTING -s {ctx["local_subnet"]} -o {wan_iface} -j MASQUERADE\n'
)
nat_lines.append('COMMIT\n# ROUTER NAT RULES END\n')
content = _insert_after_first_commit(content, ''.join(nat_lines))
if ctx['wg_enabled'] == 'true':
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
before_rules.write_text(content)
def main():
ctx = build_context()
loader = jinja2.FileSystemLoader(str(TEMPLATE_DIR))
jinja_env = jinja2.Environment(
loader=loader,
keep_trailing_newline=True,
undefined=jinja2.StrictUndefined,
autoescape=False,
)
render(jinja_env, ctx, 'cloud-router.default.j2',
'/etc/default/cloud-router', 0o644)
render(jinja_env, ctx, 'remote-site.conf.j2',
'/etc/swanctl/conf.d/remote-site.conf', 0o600)
render(jinja_env, ctx, 'road-warrior.conf.j2',
'/etc/swanctl/conf.d/road-warrior.conf', 0o600)
render(jinja_env, ctx, 'p2s-forwarder.conf.j2',
'/etc/systemd/resolved.conf.d/p2s-forwarder.conf', 0o644)
render(jinja_env, ctx, '90-cloud-router.yaml.j2',
'/etc/netplan/90-cloud-router.yaml', 0o600)
if ctx['wg_enabled'] == 'true':
render(jinja_env, ctx, 'wg0.conf.j2',
'/etc/wireguard/wg0.conf', 0o600)
wan_iface = detect_wan_iface()
setup_wireguard(ctx)
setup_ufw(ctx, wan_iface)
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/python3
"""Render configuration files and provision PKI from vpn-router.conf.
Run directly to regenerate the configuration files without touching the
running system. 'setup' calls the same code before applying the rest.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter
def main():
vpnrouter.configure()
return 0
if __name__ == '__main__':
sys.exit(main())
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/python3
"""Create /etc/vpn-router/vpn-router.conf from the installer's answers.
Runs once, from postinst, and only when the file does not exist. The
configuration file belongs to the operator from that point on and is never
rewritten by the package.
Answers arrive as VPN_ROUTER_* environment variables. The pre-shared key is
stored base64-encoded so that arbitrary characters cannot collide with INI
syntax.
"""
import base64
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter
# environment variable -> (section, option)
ANSWERS = {
'VPN_ROUTER_PLATFORM': ('general', 'platform'),
'VPN_ROUTER_EXTERNAL_INTERFACE': ('interfaces', 'external'),
'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'),
'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'),
'VPN_ROUTER_LOCAL_ID_MODE': ('wan', 'local_id_mode'),
'VPN_ROUTER_LOCAL_CIDRS': ('local', 'cidrs'),
'VPN_ROUTER_INT_GATEWAY_IP': ('local', 'int_gateway_ip'),
'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'),
'VPN_ROUTER_REMOTE_ID': ('remote', 'id'),
'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'),
'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'),
'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'),
'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'),
'VPN_ROUTER_WG_ENABLED': ('wireguard', 'enabled'),
'VPN_ROUTER_WG_ADDRESS': ('wireguard', 'address'),
'VPN_ROUTER_WG_LISTEN_PORT': ('wireguard', 'listen_port'),
}
def main():
path = vpnrouter.CONFIG_FILE
if path.exists():
return 0
parser = vpnrouter.default_parser()
for variable, (section, option) in ANSWERS.items():
value = os.environ.get(variable)
if value is not None and value != '':
parser[section][option] = value
psk = os.environ.get('VPN_ROUTER_PSK', '')
if psk:
parser['remote']['psk_b64'] = base64.b64encode(
psk.encode('utf-8')).decode('ascii')
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as handle:
parser.write(handle)
os.chmod(path, 0o600)
os.chown(path, 0, 0)
print(f'vpn-router: wrote {path}')
print('vpn-router: edit it and run "systemctl restart vpn-router-setup" '
'to apply changes')
return 0
if __name__ == '__main__':
sys.exit(main())
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/python3
"""Apply the configuration to the running system.
Regenerates the configuration files first, then does everything that needs
the live system: routes, DNS-derived identity, WireGuard keys, firewall rules
and service reloads. Idempotent - a second run changes nothing.
Interface names come from the configuration and are never guessed. The
firewall and NAT rules are written against them, so choosing the wrong one
would be a silent and security-relevant mistake.
"""
import os
import pathlib
import socket
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter
from vpnrouter import fail
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard')
FILTER_ANCHOR = '# End required lines'
BLOCKS = (
('# IPSEC RULES START', '# IPSEC RULES END'),
('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
('# P2S DNS RULES START', '# P2S DNS RULES END'),
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
)
def run(args, check=True):
result = subprocess.run(args, capture_output=True, text=True)
if check and result.returncode != 0:
fail(f'{" ".join(args)} failed: {result.stderr.strip()}')
return result
def apply_routes(ctx):
"""Route the protected subnets via the configured internal interface.
validate() has already confirmed the interface exists.
"""
gateway = ctx['int_gateway_ip']
iface = ctx['int_iface']
for cidr in ctx['local_cidr_list']:
run(['ip', 'route', 'replace', cidr, 'via', gateway, 'dev', iface])
def resolve_public_ip(fqdn):
try:
return socket.getaddrinfo(fqdn, None, socket.AF_INET)[0][4][0]
except socket.gaierror as exc:
fail(f'cannot resolve {fqdn} for local_id_mode=public_ip: {exc}')
def setup_wireguard(ctx):
"""Generate the WireGuard key pair when it does not exist."""
if not ctx['wg_enabled']:
return
WG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
key_file = WG_DIR / 'wg0.key'
pub_file = WG_DIR / 'wg0.pub'
if not key_file.exists() or key_file.stat().st_size == 0:
result = run(['wg', 'genkey'])
key_file.write_text(result.stdout)
os.chmod(key_file, 0o600)
result = subprocess.run(['wg', 'pubkey'], input=key_file.read_text(),
capture_output=True, text=True)
if result.returncode != 0:
fail(f'wg pubkey failed: {result.stderr.strip()}')
pub_file.write_text(result.stdout)
os.chmod(pub_file, 0o644)
def strip_blocks(text):
"""Remove every block this package owns, and the blank line before it."""
lines = text.splitlines(keepends=True)
starts = {start for start, _ in BLOCKS}
ends = {end for _, end in BLOCKS}
out = []
skipping = False
for line in lines:
stripped = line.strip()
if not skipping and stripped in starts:
while out and out[-1].strip() == '':
out.pop()
skipping = True
continue
if skipping:
if stripped in ends:
skipping = False
continue
out.append(line)
return ''.join(out)
def is_configured(ctx):
"""True once any feature is actually configured.
While nothing is configured the package leaves the firewall alone: an
install that has not been filled in yet must not start blocking traffic on
a host that was working before.
"""
return any((vpnrouter.s2s_ready(ctx), vpnrouter.routing_ready(ctx),
vpnrouter.p2s_ready(ctx), vpnrouter.wg_ready(ctx)))
def filter_block(ctx):
lines = [
'',
'# IPSEC RULES START',
'-A ufw-before-input -p udp --dport 500 -j ACCEPT',
'-A ufw-before-input -p udp --dport 4500 -j ACCEPT',
'-A ufw-before-input -p esp -j ACCEPT',
'-A ufw-before-input -m policy --dir in --pol ipsec -j ACCEPT',
'-A ufw-before-output -m policy --dir out --pol ipsec -j ACCEPT',
'-A ufw-before-forward -m policy --dir in --pol ipsec -j ACCEPT',
'-A ufw-before-forward -m policy --dir out --pol ipsec -j ACCEPT',
'# IPSEC RULES END',
]
if vpnrouter.wg_ready(ctx):
lines += [
'',
'# WIREGUARD RULES START',
f'-A ufw-before-input -p udp --dport {ctx["wg_listen_port"]} -j ACCEPT',
'# WIREGUARD RULES END',
]
if vpnrouter.p2s_ready(ctx) and ctx['local_addrs']:
pool = ctx['p2s_address_pool']
lines += ['', '# P2S DNS RULES START']
for address in [a.strip() for a in ctx['local_addrs'].split(',') if a.strip()]:
lines += [
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
]
lines += ['# P2S DNS RULES END']
if vpnrouter.routing_ready(ctx):
subnet = ctx['local_subnet']
lines += [
'',
'# ROUTER FORWARD RULES START',
f'-A ufw-before-forward -s {subnet} -o {ctx["wan_iface"]} -j ACCEPT',
f'-A ufw-before-forward -d {subnet} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT',
'# ROUTER FORWARD RULES END',
]
return ''.join(line + '\n' for line in lines)
def nat_and_mangle_block(ctx):
lines = []
if vpnrouter.routing_ready(ctx):
subnet = ctx['local_subnet']
lines += [
'',
'# ROUTER NAT RULES START',
'*nat',
':POSTROUTING ACCEPT [0:0]',
'-F POSTROUTING',
]
for cidr in [c.strip() for c in ctx['remote_cidrs'].split(',') if c.strip()]:
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
lines += [
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE',
'COMMIT',
'# ROUTER NAT RULES END',
]
lines += [
'',
'# ROUTER MSS RULES START',
'*mangle',
':FORWARD ACCEPT [0:0]',
'-A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu',
'COMMIT',
'# ROUTER MSS RULES END',
]
return ''.join(line + '\n' for line in lines)
def _insert_after_line(text, marker, block):
lines = text.splitlines(keepends=True)
for index, line in enumerate(lines):
if line.strip() == marker:
lines.insert(index + 1, block)
return ''.join(lines)
return None
def setup_ufw(ctx):
"""Rewrite the package's blocks in before.rules. Returns True on change."""
if not BEFORE_RULES.is_file():
fail(f'{BEFORE_RULES} does not exist')
# Nothing configured: remove anything left from an earlier configuration
# and touch nothing else.
if not is_configured(ctx):
original = BEFORE_RULES.read_text(encoding='utf-8')
text = strip_blocks(original)
if text == original:
return False
BEFORE_RULES.write_text(text, encoding='utf-8')
return True
if UFW_DEFAULTS.is_file():
text = UFW_DEFAULTS.read_text(encoding='utf-8')
updated = ''.join(
'DEFAULT_FORWARD_POLICY="ACCEPT"\n'
if line.startswith('DEFAULT_FORWARD_POLICY=') else line
for line in text.splitlines(keepends=True)
)
if updated != text:
UFW_DEFAULTS.write_text(updated, encoding='utf-8')
original = BEFORE_RULES.read_text(encoding='utf-8')
text = strip_blocks(original)
result = _insert_after_line(text, FILTER_ANCHOR, filter_block(ctx))
if result is None:
fail(f'{BEFORE_RULES}: anchor "{FILTER_ANCHOR}" not found')
text = result
result = _insert_after_line(text, 'COMMIT', nat_and_mangle_block(ctx))
if result is None:
fail(f'{BEFORE_RULES}: no COMMIT line found')
text = result
if text == original:
return False
BEFORE_RULES.write_text(text, encoding='utf-8')
return True
def unit_is_active(unit):
return run(['systemctl', 'is-active', '--quiet', unit],
check=False).returncode == 0
def manage_strongswan(ctx, changed):
"""Make sure the IPsec daemon is running and holding the current config.
strongswan.service loads swanctl configuration itself on start
(ExecStartPost) and re-reads it on reload, so starting it is enough on a
cold boot and a reload is the right response to a configuration change.
"""
wanted = vpnrouter.s2s_ready(ctx) or vpnrouter.p2s_ready(ctx)
if not wanted:
return
if not unit_is_active('strongswan'):
run(['systemctl', 'enable', '--now', 'strongswan'], check=False)
elif 'swanctl' in changed:
run(['systemctl', 'reload', 'strongswan'], check=False)
def manage_wireguard(ctx, changed):
"""Bring wg0 up when WireGuard is configured, and down when it is not."""
unit = 'wg-quick@wg0'
if not vpnrouter.wg_ready(ctx):
if unit_is_active(unit):
run(['systemctl', 'disable', '--now', unit], check=False)
return
if not unit_is_active(unit):
run(['systemctl', 'enable', '--now', unit], check=False)
elif 'wireguard' in changed:
run(['systemctl', 'restart', unit], check=False)
def run_platform_setup(cfg, ctx):
"""Let the platform module apply its own state, if it defines any."""
module = vpnrouter.platform_module(cfg)
apply = getattr(module, 'apply', None)
if apply is None:
return
try:
apply(ctx)
except Exception as exc:
fail(f'platform module {module.__name__} failed: {exc}')
def main():
cfg = vpnrouter.load_config()
ctx, changed = vpnrouter.configure(cfg)
if vpnrouter.routing_ready(ctx):
apply_routes(ctx)
if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']:
address = resolve_public_ip(ctx['local_fqdn'])
ctx, more = vpnrouter.configure(cfg, local_id_override=address)
changed |= more
setup_wireguard(ctx)
rules_changed = setup_ufw(ctx)
if is_configured(ctx):
status = run(['ufw', 'status'], check=False)
if not status.stdout.startswith('Status: active'):
# Only ever enable the firewall after making sure SSH survives it.
run(['ufw', 'allow', '22/tcp'], check=False)
run(['ufw', '--force', 'enable'])
elif rules_changed:
run(['ufw', 'reload'])
elif rules_changed:
run(['ufw', 'reload'], check=False)
manage_strongswan(ctx, changed)
manage_wireguard(ctx, changed)
if 'resolved' in changed:
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
run_platform_setup(cfg, ctx)
return 0
if __name__ == '__main__':
sys.exit(main())
+611
View File
@@ -0,0 +1,611 @@
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Sławomir Koszewski
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# This module requires Python 3.8+ and OpenSSL to be installed on the system.
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
import tempfile
OPENSSL = "openssl"
class Config:
FILE = "simple-ca.json"
def __init__(self, ca_dir: str):
self._path = os.path.join(ca_dir, self.FILE)
self._data: dict = {}
if not os.path.isfile(self._path):
return
try:
with open(self._path, "r") as f:
self._data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
print(f"WARNING: could not read {self._path}: {e}", file=sys.stderr)
def get(self, key, default=None):
return self._data.get(key, default)
def update(self, patch: dict):
file_missing = not os.path.isfile(self._path)
changed = any(self._data.get(k) != v for k, v in patch.items())
if file_missing or changed:
self._data.update(patch)
self._save()
def append_history(self, ca_key: str, entry: dict):
history = self._data.get("history", {})
history.setdefault(ca_key, []).append(entry)
self._data["history"] = history
self._save()
def revoke_in_history(self, ca_key: str, serial: str, revoked_at: str):
"""Mark a certificate as revoked. Returns True if newly revoked,
False if already revoked, None if serial not found."""
history = self._data.get("history", {})
for entry in history.get(ca_key, []):
if entry.get("serial") == serial:
if "revoked" in entry:
return False
entry["revoked"] = revoked_at
self._data["history"] = history
self._save()
return True
return None
def _save(self):
with open(self._path, "w") as f:
json.dump(self._data, f, indent=2)
f.write("\n")
_config: Config
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _err(msg):
print(f"ERROR: {msg}", file=sys.stderr)
def _rebuild_ca_bundle(ca_dir):
bundle_path = os.path.join(ca_dir, "ca_bundle.pem")
parts = []
root = os.path.join(ca_dir, "ca_cert.pem")
if os.path.isfile(root):
with open(root, "rb") as f:
parts.append(f.read())
for name in sorted(_config.get("subordinates", [])):
sub_cert = os.path.join(ca_dir, name, "ca_cert.pem")
if os.path.isfile(sub_cert):
with open(sub_cert, "rb") as f:
parts.append(f.read())
with open(bundle_path, "wb") as f:
f.write(b"".join(parts))
def _read_serial(cert_path) -> str:
result = subprocess.run(
[OPENSSL, "x509", "-in", cert_path, "-noout", "-serial"],
capture_output=True, text=True,
)
return result.stdout.strip().split("=", 1)[-1]
def _read_expiry(cert_path) -> str:
"""Return the certificate notAfter date as an ISO 8601 UTC string."""
result = subprocess.run(
[OPENSSL, "x509", "-in", cert_path, "-noout", "-enddate"],
capture_output=True, text=True,
)
raw = result.stdout.strip().split("=", 1)[-1]
raw = " ".join(raw.split()) # normalize whitespace ("May 4" -> "May 4")
dt = datetime.datetime.strptime(raw, "%b %d %H:%M:%S %Y GMT")
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def _iso_to_asn1(iso: str) -> str:
"""Convert "2026-05-24T14:28:10Z" -> "260524142810Z" for OpenSSL index.txt."""
dt = datetime.datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ")
return dt.strftime("%y%m%d%H%M%SZ")
_IP_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$")
_DNS_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*$")
def _is_ip(value):
return bool(_IP_RE.match(value))
def _is_dns(value):
return bool(_DNS_RE.match(value))
def _pipe(cmd1, cmd2):
p1 = subprocess.Popen(cmd1, stdout=subprocess.PIPE)
p2 = subprocess.Popen(cmd2, stdin=p1.stdout)
p1.stdout.close()
p2.communicate()
p1.wait()
return p1.returncode == 0 and p2.returncode == 0
def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, ca_publish_base_url=None):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"Certificate directory {ca_dir} does not exist.")
return False
if not ca_name:
_err("CA name is required.")
return False
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
if issuing_ca:
_err(
f"Cannot create issuing CA '{ca_name}' without existing root CA "
"certificate and key. Please create the root CA first."
)
return False
print(f"Generating CA certificate '{ca_name}' and key...")
# Path length constraint of 1: allows one level of issuing CAs.
cmd = [
OPENSSL, "req",
"-x509",
"-newkey", "rsa:4096",
"-keyout", root_ca_key_path,
"-out", root_ca_cert_path,
"-days", str(days),
"-noenc",
"-subj", f"/CN={ca_name}",
"-text",
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:1",
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
]
if subprocess.run(cmd).returncode != 0:
_err("Failed to generate CA certificate and key.")
return False
_rebuild_ca_bundle(ca_dir)
patch = {"name": ca_name, "created": _now()}
if ca_publish_base_url:
patch["ca_publish_base_url"] = ca_publish_base_url
_config.update(patch)
return True
issuing_ca_dir = os.path.join(ca_dir, issuing_ca)
issuing_ca_cert = os.path.join(issuing_ca_dir, "ca_cert.pem")
issuing_ca_key = os.path.join(issuing_ca_dir, "ca_key.pem")
if not os.path.isfile(issuing_ca_cert) or not os.path.isfile(issuing_ca_key):
print(f"Generating issuing CA certificate '{ca_name}' and key...")
os.makedirs(issuing_ca_dir, exist_ok=True)
req_cmd = [
OPENSSL, "req",
"-newkey", "rsa:4096",
"-keyout", issuing_ca_key,
"-noenc",
"-subj", f"/CN={ca_name}",
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:0",
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
]
if ca_publish_base_url:
req_cmd += [
"-addext", f"authorityInfoAccess=caIssuers;URI:{ca_publish_base_url}/ca_cert.crt",
"-addext", f"crlDistributionPoints=URI:{ca_publish_base_url}/crl.pem",
]
x509_cmd = [
OPENSSL, "x509",
"-req",
"-CA", root_ca_cert_path,
"-CAkey", root_ca_key_path,
"-copy_extensions", "copyall",
"-days", str(days),
"-text",
"-out", issuing_ca_cert,
]
if not _pipe(req_cmd, x509_cmd):
_err("Failed to generate issuing CA certificate and key.")
return False
patch = {"ca_publish_base_url": ca_publish_base_url} if ca_publish_base_url else {}
subs = _config.get("subordinates", [])
if issuing_ca not in subs:
patch["subordinates"] = subs + [issuing_ca]
_config.update(patch)
_rebuild_ca_bundle(ca_dir)
return True
def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
issuing_ca=None, days=365, ca_publish_base_url=None):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"CA directory {ca_dir} does not exist.")
return False
if not cert_subject_name:
_err("Subject name is required.")
return False
if not _is_dns(cert_subject_name):
_err(f"Invalid subject name '{cert_subject_name}'. Must be a valid DNS name.")
return False
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
cert_dir = cert_dir or signing_dir
if not os.path.isdir(cert_dir):
_err(f"Certificate directory {cert_dir} does not exist.")
return False
ca_cert_path = os.path.join(signing_dir, "ca_cert.pem")
ca_key_path = os.path.join(signing_dir, "ca_key.pem")
if not os.path.isfile(ca_cert_path) or not os.path.isfile(ca_key_path):
_err(
f"Signing CA certificate and key not found in {signing_dir}. "
"Please set up a signing CA first."
)
return False
aia_url = cdp_url = ""
if ca_publish_base_url:
if issuing_ca:
aia_url = f"{ca_publish_base_url}/{issuing_ca}/ca_cert.crt"
cdp_url = f"{ca_publish_base_url}/{issuing_ca}/crl.pem"
else:
aia_url = f"{ca_publish_base_url}/ca_cert.crt"
cdp_url = f"{ca_publish_base_url}/crl.pem"
cert_name = cert_subject_name.split(".", 1)[0]
san_entries = [f"DNS:{cert_subject_name}"]
for entry in sans or []:
if _is_ip(entry):
san_entries.append(f"IP:{entry}")
elif _is_dns(entry):
san_entries.append(f"DNS:{entry}")
else:
_err(f"Invalid SAN entry '{entry}'")
return False
sans_ext = "subjectAltName=" + ",".join(san_entries)
print(f"Generating server certificate for '{cert_subject_name}' with SANs:")
for san in san_entries:
print(f" - {san}")
cert_out = os.path.join(cert_dir, f"{cert_name}_cert.pem")
key_out = os.path.join(cert_dir, f"{cert_name}_key.pem")
if not os.path.isfile(cert_out) or not os.path.isfile(key_out):
print("Generating server certificate and key...")
req_cmd = [
OPENSSL, "req",
"-newkey", "rsa:4096",
"-keyout", key_out,
"-noenc",
"-subj", f"/CN={cert_subject_name}",
"-addext", "basicConstraints=critical,CA:FALSE",
"-addext", "keyUsage=critical,digitalSignature,keyEncipherment",
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext", sans_ext,
]
if aia_url:
req_cmd += ["-addext", f"authorityInfoAccess=caIssuers;URI:{aia_url}"]
if cdp_url:
req_cmd += ["-addext", f"crlDistributionPoints=URI:{cdp_url}"]
x509_cmd = [
OPENSSL, "x509",
"-req",
"-CA", ca_cert_path,
"-CAkey", ca_key_path,
"-copy_extensions", "copyall",
"-days", str(days),
"-text",
"-out", cert_out,
]
if not _pipe(req_cmd, x509_cmd):
_err("Failed to generate server certificate and key.")
return False
_config.append_history(issuing_ca or "ca", {
"name": cert_subject_name,
"serial": _read_serial(cert_out),
"created": _now(),
"expires": _read_expiry(cert_out),
})
return True
def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=False):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
cert_dir = os.path.dirname(cert_path)
cert_basename = os.path.basename(cert_path)
cert_name = cert_basename[:-len("_cert.pem")] if cert_basename.endswith("_cert.pem") else cert_basename
key_path = os.path.join(cert_dir, f"{cert_name}_key.pem")
if not cert_dir or not os.path.isdir(cert_dir):
_err(f"Certificate directory {cert_dir} does not exist.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"CA directory {ca_dir} does not exist.")
return False
if not os.path.isfile(cert_path) or not os.path.isfile(key_path):
_err("Server certificate or key not found.")
return False
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
_err(f"CA certificate or key not found in {ca_dir}.")
return False
if issuing_ca:
issuing_ca_cert_path = os.path.join(ca_dir, issuing_ca, "ca_cert.pem")
if not os.path.isfile(issuing_ca_cert_path):
_err(f"Issuing CA certificate not found: {issuing_ca_cert_path}.")
return False
if not password:
password = "changeit"
pfx_path = os.path.join(cert_dir, f"{cert_name}.pfx")
if os.path.isfile(pfx_path):
print("PKCS#12 (PFX) file already exists, aborting generation.")
return False
print("Generating PKCS#12 (PFX) file...", end="")
chain_bytes = b""
with open(root_ca_cert_path, "rb") as f:
chain_bytes += f.read()
if issuing_ca:
with open(issuing_ca_cert_path, "rb") as f:
chain_bytes += f.read()
chain_fd, chain_file = tempfile.mkstemp()
try:
with os.fdopen(chain_fd, "wb") as f:
f.write(chain_bytes)
cmd = [
"/usr/bin/openssl" if apple_openssl else OPENSSL, "pkcs12",
"-export", "-out", pfx_path,
"-inkey", key_path,
"-in", cert_path,
"-certfile", chain_file,
"-password", f"pass:{password}",
]
if subprocess.run(cmd).returncode != 0:
_err("Failed to generate PKCS#12 (PFX) file.")
return False
finally:
if os.path.exists(chain_file):
os.remove(chain_file)
print("done.")
return True
def make_crl(ca_dir, issuing_ca=None, days=30):
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
ca_cert = os.path.join(signing_dir, "ca_cert.pem")
ca_key = os.path.join(signing_dir, "ca_key.pem")
if not os.path.isfile(ca_cert) or not os.path.isfile(ca_key):
_err(f"CA certificate or key not found in {signing_dir}.")
return False
crl_path = os.path.join(signing_dir, "crl.pem")
history_key = issuing_ca or "ca"
revoked_entries = [
e for e in _config.get("history", {}).get(history_key, [])
if "revoked" in e and "expires" in e
]
with tempfile.TemporaryDirectory() as tmp:
index_txt = os.path.join(tmp, "index.txt")
crlnumber = os.path.join(tmp, "crlnumber")
cnf_path = os.path.join(tmp, "openssl.cnf")
with open(index_txt, "w") as f:
for e in revoked_entries:
expires_asn1 = _iso_to_asn1(e["expires"])
revoked_asn1 = _iso_to_asn1(e["revoked"])
f.write(f"R\t{expires_asn1}\t{revoked_asn1}\t{e['serial']}\tunknown\t/CN={e['name']}\n")
with open(crlnumber, "w") as f:
f.write("01\n")
with open(cnf_path, "w") as f:
f.write(
"[ ca ]\ndefault_ca = CA_default\n\n"
"[ CA_default ]\n"
f"database = {index_txt}\n"
f"crlnumber = {crlnumber}\n"
f"certificate = {ca_cert}\n"
f"private_key = {ca_key}\n"
f"default_crl_days = {days}\n"
"default_md = sha256\n\n"
"[ crl_ext ]\n"
"authorityKeyIdentifier = keyid:always\n"
)
if subprocess.run([OPENSSL, "ca", "-gencrl", "-config", cnf_path, "-out", crl_path]).returncode != 0:
_err("Failed to generate CRL.")
return False
print(f"CRL written to {crl_path}")
return True
def revoke_cert(cert_path, ca_dir, issuing_ca=None):
if not os.path.isfile(cert_path):
_err(f"Certificate not found: {cert_path}")
return False
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
if not os.path.isdir(signing_dir):
_err(f"CA directory not found: {signing_dir}")
return False
ca_key = issuing_ca or "ca"
serial = _read_serial(cert_path)
result = _config.revoke_in_history(ca_key, serial, _now())
if result is None:
_err(f"Certificate with serial {serial} not found in history for CA '{ca_key}'.")
return False
if result is False:
print(f"Certificate {cert_path} (serial {serial}) is already revoked.")
return True
print(f"Certificate {cert_path} (serial {serial}) marked as revoked.")
return True
def _build_parser():
parser = argparse.ArgumentParser(
description="Simple CA for creating and managing test certificates."
)
sub = parser.add_subparsers(dest="command", required=True)
p_ca = sub.add_parser("make-ca", help="Create a root or issuing CA.")
p_ca.add_argument("--days", type=int, default=None, help="Validity period in days (default: 3650)")
p_ca.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_ca.add_argument("--ca-publish-base-url", default=None,
help="Base URL for AIA and CRL distribution point extensions")
p_ca.add_argument("--ca-dir", help="Directory to store the CA files")
p_ca.add_argument("--openssl", default=None, metavar="PATH",
help=f"Path to the openssl binary (default: {OPENSSL})")
p_ca.add_argument("ca_name", help="Name of the CA")
p_cert = sub.add_parser("make-cert", help="Create a server/client certificate.")
p_cert.add_argument("--ca-dir", help="Directory of the CA")
p_cert.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_cert.add_argument("--days", type=int, default=None, help="Validity period in days (default: 365)")
p_cert.add_argument("--cert-dir", help="Directory to store the certificate files")
p_cert.add_argument("--openssl", default=None, metavar="PATH",
help=f"Path to the openssl binary (default: {OPENSSL})")
p_cert.add_argument("subject_name", help="Subject name for the certificate")
p_cert.add_argument("sans", nargs="*", help="Subject Alternative Names (SANs) for the certificate")
p_pfx = sub.add_parser("make-pfx", help="Create a PKCS#12 (PFX) bundle.")
p_pfx.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_pfx.add_argument("--ca-dir", help="Directory of the CA")
p_pfx.add_argument("--password", help="Password for the PFX file")
p_pfx.add_argument("--apple-openssl", action="store_true", default=False,
help="Use Apple's bundled /usr/bin/openssl for PKCS12 generation")
p_pfx.add_argument("path", help="Path to the certificate file")
p_crl = sub.add_parser("make-crl", help="Generate a CRL for a CA.")
p_crl.add_argument("--ca-dir", help="Directory of the CA")
p_crl.add_argument("--issuing-ca", default=None, help="Generate CRL for this issuing CA")
p_crl.add_argument("--days", type=int, default=None, help="CRL validity in days (default: 30)")
p_rev = sub.add_parser("revoke-cert", help="Revoke a certificate.")
p_rev.add_argument("--ca-dir", help="Directory of the CA")
p_rev.add_argument("--issuing-ca", default=None, help="Issuing CA that signed the certificate")
p_rev.add_argument("cert_path", help="Path to the certificate file to revoke")
return parser
def main(argv=None):
global OPENSSL, _config
parser = _build_parser()
args = parser.parse_args(argv)
ca_dir = args.ca_dir or os.environ.get("SIMPLE_CA_DIR") or os.getcwd()
_config = Config(ca_dir)
OPENSSL = getattr(args, "openssl", None) or _config.get("openssl", OPENSSL)
issuing_ca = args.issuing_ca or _config.get("issuing_ca")
ca_publish_base_url = getattr(args, "ca_publish_base_url", None) or _config.get("ca_publish_base_url")
days_cfg = _config.get("days", {})
if args.command == "make-ca":
days = args.days or days_cfg.get("ca", 3650)
ok = make_ca(
ca_dir, args.ca_name,
days=days,
issuing_ca=issuing_ca,
ca_publish_base_url=ca_publish_base_url,
)
elif args.command == "make-cert":
days = args.days or days_cfg.get("cert", 365)
ok = make_cert(
args.subject_name,
sans=args.sans,
ca_dir=ca_dir,
cert_dir=getattr(args, "cert_dir", None),
issuing_ca=issuing_ca,
days=days,
ca_publish_base_url=ca_publish_base_url,
)
elif args.command == "make-pfx":
ok = make_pfx(
args.path, ca_dir,
issuing_ca=issuing_ca,
password=args.password,
apple_openssl=args.apple_openssl,
)
elif args.command == "make-crl":
days = args.days or days_cfg.get("crl", 30)
ok = make_crl(ca_dir, issuing_ca=issuing_ca, days=days)
elif args.command == "revoke-cert":
ok = revoke_cert(args.cert_path, ca_dir, issuing_ca=issuing_ca)
else:
parser.error(f"Unknown command: {args.command}")
ok = False
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,503 @@
"""Shared library for the vpn-router package.
Everything derivable from the configuration file alone, with no running
network required. Used by both 'configure' and 'setup'.
"""
import base64
import binascii
import configparser
import ipaddress
import os
import pathlib
import shutil
import subprocess
import sys
import jinja2
LIB_DIR = pathlib.Path(__file__).resolve().parent
if str(LIB_DIR) not in sys.path:
sys.path.insert(0, str(LIB_DIR))
import vpnrouter_platforms
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.conf')
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
SIMPLE_CA = LIB_DIR / 'simple-ca'
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf'
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
CA_CERT_DEST = pathlib.Path('/etc/swanctl/x509ca/ca.pem')
SERVER_CERT = pathlib.Path('/etc/swanctl/x509/server.pem')
SERVER_KEY = pathlib.Path('/etc/swanctl/private/server.key')
CRL_DEST = pathlib.Path('/etc/swanctl/x509crl/crl.pem')
TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.crt')
# section -> option -> default
SCHEMA = {
'general': {'platform': 'generic'},
'interfaces': {'external': '', 'internal': ''},
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'},
'local': {'cidrs': '', 'int_gateway_ip': ''},
'remote': {'addrs': '', 'id': '', 'cidrs': '', 'psk_b64': '', 'psk_file': ''},
'p2s': {'enabled': 'false', 'address_pool': '', 'ca_name': 'VPN Router CA'},
'wireguard': {'enabled': 'false', 'address': '', 'listen_port': '51820'},
}
ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
class ConfigError(Exception):
"""A setting is present but malformed."""
def warn(message):
print(f'vpn-router: warning: {message}', file=sys.stderr)
def fail(message):
print(f'vpn-router: error: {message}', file=sys.stderr)
sys.exit(1)
def new_parser():
"""A parser that tolerates operator comments and never interpolates.
interpolation=None matters: the default BasicInterpolation treats '%' as
syntax, which would break any value containing it.
Inline comments are enabled, which configparser leaves off by default
because it makes '#' and ';' unusable inside values. That is an acceptable
trade here precisely because the one setting that can hold arbitrary
characters, the pre-shared key, is base64-encoded, and base64 uses none of
those characters.
"""
return configparser.ConfigParser(
interpolation=None,
comment_prefixes=('#', ';'),
inline_comment_prefixes=('#', ';'),
)
def default_parser():
parser = new_parser()
for section, options in SCHEMA.items():
parser[section] = dict(options)
return parser
def load_config(path=CONFIG_FILE):
"""Read the configuration, filling in defaults for anything absent."""
parser = default_parser()
if path.exists():
try:
parser.read(path, encoding='utf-8')
except configparser.Error as exc:
fail(f'cannot parse {path}: {exc}')
return parser
def _split_list(value):
return [item.strip() for item in value.split(',') if item.strip()]
def _get(cfg, section, option):
return cfg.get(section, option, fallback=SCHEMA[section][option]).strip()
def _get_bool(cfg, section, option):
try:
return cfg.getboolean(section, option,
fallback=SCHEMA[section][option] == 'true')
except ValueError:
raise ConfigError(f'{section}.{option} is not a boolean')
def _check_networks(value, name):
for item in _split_list(value):
try:
ipaddress.ip_network(item, strict=False)
except ValueError:
raise ConfigError(f'{name}: {item!r} is not a valid CIDR')
def _check_address(value, name):
if not value:
return
try:
ipaddress.ip_address(value)
except ValueError:
raise ConfigError(f'{name}: {value!r} is not a valid IP address')
def validate(cfg):
"""Reject values that are present but malformed. Absent is not an error."""
platform = _get(cfg, 'general', 'platform')
installed = vpnrouter_platforms.available()
if platform and platform not in installed:
raise ConfigError(
f'general.platform: {platform!r} is not installed, expected one of '
f'{", ".join(installed)}')
id_mode = _get(cfg, 'wan', 'local_id_mode')
if id_mode and id_mode not in ID_MODES:
raise ConfigError(
f'wan.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
_check_networks(_get(cfg, 'local', 'cidrs'), 'local.cidrs')
_check_networks(_get(cfg, 'remote', 'cidrs'), 'remote.cidrs')
_check_networks(_get(cfg, 'p2s', 'address_pool'), 'p2s.address_pool')
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
for option in ('external', 'internal'):
name = _get(cfg, 'interfaces', option)
if name and not (pathlib.Path('/sys/class/net') / name).exists():
raise ConfigError(
f'interfaces.{option}: interface {name!r} does not exist')
wg_address = _get(cfg, 'wireguard', 'address')
if wg_address:
try:
ipaddress.ip_interface(wg_address)
except ValueError:
raise ConfigError(
f'wireguard.address: {wg_address!r} is not an address with prefix')
port = _get(cfg, 'wireguard', 'listen_port')
if port:
if not port.isdigit() or not 1 <= int(port) <= 65535:
raise ConfigError(f'wireguard.listen_port: {port!r} is not a valid port')
for section in ('p2s', 'wireguard'):
_get_bool(cfg, section, 'enabled')
def resolve_psk(cfg):
"""Return the pre-shared key, or '' when none is configured."""
psk_file = _get(cfg, 'remote', 'psk_file')
if psk_file:
path = pathlib.Path(psk_file)
if not path.is_file():
raise ConfigError(f'remote.psk_file: {psk_file} does not exist')
return path.read_text(encoding='utf-8').strip()
psk_b64 = _get(cfg, 'remote', 'psk_b64')
if not psk_b64:
return ''
try:
return base64.b64decode(psk_b64, validate=True).decode('utf-8')
except (binascii.Error, UnicodeDecodeError) as exc:
raise ConfigError(f'remote.psk_b64 is not valid base64 text: {exc}')
def platform_module(cfg):
"""Import the configured platform module, falling back to the default."""
name = _get(cfg, 'general', 'platform') or vpnrouter_platforms.DEFAULT
try:
return vpnrouter_platforms.load(name)
except ModuleNotFoundError:
warn(f'platform {name!r} is not installed, using '
f'{vpnrouter_platforms.DEFAULT}')
return vpnrouter_platforms.load(vpnrouter_platforms.DEFAULT)
def _join(*groups):
items = []
for group in groups:
for item in _split_list(group):
if item not in items:
items.append(item)
return ', '.join(items)
def interface_addresses(name, setting):
"""IPv4 addresses configured on an interface.
The interface name is configuration; its addresses are read from the
system. Looking up the addresses of a named interface is deterministic -
unlike working out which interface to use, which is a guess the package
deliberately does not make.
"""
if not name:
return []
if not (pathlib.Path('/sys/class/net') / name).exists():
raise ConfigError(f'{setting}: interface {name!r} does not exist')
result = subprocess.run(
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
capture_output=True, text=True,
)
if result.returncode != 0:
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
addresses = []
for line in result.stdout.splitlines():
fields = line.split()
if len(fields) >= 4:
addresses.append(fields[3].split('/')[0])
return addresses
def build_context(cfg, local_id_override=None):
"""Assemble the template context from the configuration."""
extras = vpnrouter_platforms.context(platform_module(cfg))
ext_iface = _get(cfg, 'interfaces', 'external')
int_iface = _get(cfg, 'interfaces', 'internal')
ext_addrs = interface_addresses(ext_iface, 'interfaces.external')
int_addrs = interface_addresses(int_iface, 'interfaces.internal')
if ext_iface and not ext_addrs:
warn(f'{ext_iface} has no IPv4 address yet')
local_addrs = ', '.join(ext_addrs)
int_addr = int_addrs[0] if int_addrs else ''
local_fqdn = _get(cfg, 'wan', 'local_fqdn')
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
local_cidrs = _get(cfg, 'local', 'cidrs')
if local_id_override:
local_id = local_id_override
elif id_mode == 'fqdn':
local_id = f'@{local_fqdn}' if local_fqdn else ''
elif id_mode == 'internal_ip':
local_id = int_addr
else:
# public_ip is resolved once the network is up, and the caller then
# re-renders with local_id_override set.
local_id = ''
cidr_list = _split_list(local_cidrs)
return {
'platform': _get(cfg, 'general', 'platform') or 'generic',
'wan_iface': ext_iface,
'int_iface': int_iface,
'local_addrs': local_addrs,
'local_addr_list': _split_list(local_addrs),
'local_fqdn': local_fqdn,
'local_id_mode': id_mode,
'local_id': local_id,
'local_cidrs': local_cidrs,
'local_cidr_list': cidr_list,
'local_subnet': cidr_list[0] if cidr_list else '',
'local_ts': _join(local_cidrs, extras['EXTRA_LOCAL_TS']),
'int_addr': int_addr,
'int_gateway_ip': _get(cfg, 'local', 'int_gateway_ip'),
'remote_addrs': _get(cfg, 'remote', 'addrs'),
'remote_id': _get(cfg, 'remote', 'id'),
'remote_cidrs': _get(cfg, 'remote', 'cidrs'),
'psk': resolve_psk(cfg),
'p2s_enabled': _get_bool(cfg, 'p2s', 'enabled'),
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
'p2s_dns': _join(local_addrs, extras['EXTRA_P2S_DNS']),
'wg_enabled': _get_bool(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'),
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
}
def s2s_ready(ctx):
return all([
ctx['local_addrs'], ctx['local_fqdn'], ctx['local_cidrs'],
ctx['remote_addrs'], ctx['remote_id'], ctx['remote_cidrs'],
ctx['psk'], ctx['local_id'],
])
def routing_ready(ctx):
"""Routing and NAT need both interfaces named, plus the subnets involved."""
return all([ctx['local_cidrs'], ctx['int_gateway_ip'],
ctx['int_iface'], ctx['wan_iface']])
def p2s_ready(ctx):
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'])
def wg_ready(ctx):
return bool(ctx['wg_enabled'] and ctx['wg_address'])
def _jinja_env():
return jinja2.Environment(
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
keep_trailing_newline=True,
undefined=jinja2.StrictUndefined,
autoescape=False,
)
def render(env, ctx, template_name, dest, mode):
"""Render a template, writing only when the content changes."""
content = env.get_template(template_name).render(ctx)
dest = pathlib.Path(dest)
if dest.exists() and dest.read_text(encoding='utf-8') == content:
os.chmod(dest, mode)
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding='utf-8')
os.chmod(dest, mode)
return True
def existing_peers(path):
"""Return the [Peer] sections of an existing WireGuard config.
Peers are added by the operator by hand, so re-rendering the [Interface]
section must not discard them.
"""
path = pathlib.Path(path)
if not path.is_file():
return ''
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
for index, line in enumerate(lines):
if line.strip().lower() == '[peer]':
return ''.join(lines[index:])
return ''
def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600):
"""Render wg0.conf, carrying any operator-defined peers across."""
content = env.get_template('wg0.conf.j2').render(ctx)
peers = existing_peers(dest)
if peers:
content = content.rstrip('\n') + '\n\n' + peers
dest = pathlib.Path(dest)
if dest.exists() and dest.read_text(encoding='utf-8') == content:
os.chmod(dest, mode)
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding='utf-8')
os.chmod(dest, mode)
return True
def remove(path):
"""Remove a generated file. Returns True when something was removed."""
path = pathlib.Path(path)
if path.exists():
path.unlink()
return True
return False
def install_file(src, dest, mode):
"""Copy src to dest when the content differs. Returns True on change."""
src, dest = pathlib.Path(src), pathlib.Path(dest)
if not src.is_file():
return False
if dest.exists() and dest.read_bytes() == src.read_bytes():
os.chmod(dest, mode)
return False
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dest)
os.chmod(dest, mode)
return True
def _simple_ca(*args):
result = subprocess.run(
[sys.executable, str(SIMPLE_CA), *args],
capture_output=True, text=True,
)
if result.returncode != 0:
fail(f'simple-ca {" ".join(args)} failed: {result.stderr.strip()}')
return result.stdout
def provision_pki(ctx):
"""Create the CA and server certificate when the PKI directory is empty."""
PKI_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PKI_DIR, 0o700)
if any(PKI_DIR.iterdir()):
return
if not ctx['p2s_ca_name']:
warn('p2s.ca_name is empty, not creating a local CA')
return
if not SIMPLE_CA.is_file():
fail(f'{SIMPLE_CA} is missing')
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR),
ctx['local_fqdn'], ctx['local_fqdn'])
def distribute_pki(ctx):
"""Copy PKI material to each consumer. Returns True when anything changed."""
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
bundle = PKI_DIR / 'ca_bundle.pem'
ca_src = bundle if bundle.is_file() else PKI_DIR / 'ca_cert.pem'
changed = False
changed |= install_file(ca_src, CA_CERT_DEST, 0o644)
if label:
changed |= install_file(PKI_DIR / f'{label}_cert.pem', SERVER_CERT, 0o644)
changed |= install_file(PKI_DIR / f'{label}_key.pem', SERVER_KEY, 0o600)
changed |= install_file(PKI_DIR / 'crl.pem', CRL_DEST, 0o644)
if install_file(PKI_DIR / 'ca_cert.pem', TRUST_ANCHOR, 0o644):
changed = True
if shutil.which('update-ca-certificates'):
subprocess.run(['update-ca-certificates'],
capture_output=True, text=True)
return changed
def configure(cfg=None, local_id_override=None):
"""Render configuration files and provision PKI.
Returns (ctx, changed) where changed is a set naming what needs reloading:
'swanctl', 'resolved', 'wireguard'.
"""
if cfg is None:
cfg = load_config()
try:
validate(cfg)
ctx = build_context(cfg, local_id_override=local_id_override)
except ConfigError as exc:
fail(str(exc))
changed = set()
if p2s_ready(ctx):
provision_pki(ctx)
if distribute_pki(ctx):
changed.add('swanctl')
env = _jinja_env()
if s2s_ready(ctx):
if render(env, ctx, 'remote-site.conf.j2', REMOTE_SITE, 0o600):
changed.add('swanctl')
elif remove(REMOTE_SITE):
changed.add('swanctl')
if p2s_ready(ctx):
if render(env, ctx, 'road-warrior.conf.j2', ROAD_WARRIOR, 0o600):
changed.add('swanctl')
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
changed.add('resolved')
else:
if remove(ROAD_WARRIOR):
changed.add('swanctl')
if remove(P2S_FORWARDER):
changed.add('resolved')
if wg_ready(ctx):
if render_wg_conf(env, ctx):
changed.add('wireguard')
elif remove(WG_CONF):
changed.add('wireguard')
return ctx, changed
@@ -0,0 +1,53 @@
"""Platform modules for vpn-router.
A platform module supplies the values and behaviour that only make sense on one
kind of host. The core is complete without any of them; a module only adds
what the core deliberately refuses to know about, such as a provider's DNS
resolver address.
Modules are discovered by listing this package, so adding a platform means
adding one file here. Nothing in the core has to be edited.
A module may define any of:
EXTRA_P2S_DNS str, added to the road-warrior pool 'dns' list
EXTRA_LOCAL_TS str, added to 'local_ts' on both connections
apply(ctx) called once the system has been configured, to apply
platform-specific state. Must be idempotent. ctx is the
template context, carrying wan_iface, int_iface,
local_cidrs and everything else derived from the
configuration.
Anything a module does not define takes its default, so a module that only
needs one constant is one line long.
"""
import importlib
import pkgutil
DEFAULT = 'generic'
#: Context keys a module may contribute, with the value used when it does not.
CONTEXT_DEFAULTS = {
'EXTRA_P2S_DNS': '',
'EXTRA_LOCAL_TS': '',
}
def available():
"""Names of the installed platform modules."""
return sorted(module.name for module in pkgutil.iter_modules(__path__))
def load(name):
"""Import a platform module by name.
Raises ModuleNotFoundError when no such module is installed.
"""
return importlib.import_module(f'{__name__}.{name}')
def context(module):
"""Read a module's context contributions, applying defaults."""
return {key: getattr(module, key, default)
for key, default in CONTEXT_DEFAULTS.items()}
@@ -0,0 +1,11 @@
"""Microsoft Azure.
No platform-specific configuration is currently required.
The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to
road-warrior clients and *not* added to the traffic selectors. Clients are
given the router itself as their DNS server, and the router forwards through
its own systemd-resolved via the p2s-forwarder drop-in. That resolves
Azure-internal names without routing the wireserver address through the tunnel
and without a second resolver in the pool.
"""
@@ -0,0 +1,8 @@
"""Google Cloud Platform.
No platform-specific configuration is currently required.
As on Azure, road-warrior clients are given the router itself as their DNS
server and the router forwards through its own systemd-resolved, so the
metadata resolver does not belong in the pool or in the traffic selectors.
"""
@@ -0,0 +1,6 @@
"""Generic platform: no additions.
The default, and the correct choice on any host that needs nothing
platform-specific. It defines nothing, which is the point: the core is
complete on its own.
"""
@@ -1,7 +0,0 @@
network:
version: 2
ethernets:
eth1:
routes:
- to: {{ local_subnet }}
via: {{ router_int_gateway_ip }}
@@ -1,14 +0,0 @@
LOCAL_ADDRS="{{ local_addrs }}"
LOCAL_FQDN="{{ local_fqdn }}"
LOCAL_ID_MODE="{{ local_id_mode }}"
LOCAL_CIDRS="{{ local_cidrs }}"
LOCAL_SUBNET="{{ local_subnet }}"
REMOTE_ADDRS="{{ remote_addrs }}"
REMOTE_ID="{{ remote_id }}"
REMOTE_CIDRS="{{ remote_cidrs }}"
ROUTER_INT_GATEWAY_IP="{{ router_int_gateway_ip }}"
P2S_ADDRESS_POOL="{{ p2s_address_pool }}"
P2S_SERVER_NAME="{{ p2s_server_name }}"
WG_ENABLED="{{ wg_enabled }}"
WG_ADDRESS="{{ wg_address }}"
WG_LISTEN_PORT="{{ wg_listen_port }}"
@@ -1,2 +0,0 @@
[Resolve]
DNSStubListenerExtra={{ local_addrs }}
@@ -0,0 +1,69 @@
# Example vpn-router configuration.
#
# This file is documentation. The live configuration is
# /etc/vpn-router/vpn-router.conf, which is generated on first install and
# owned by the operator afterwards. Copy settings from here as needed.
#
# Format: INI. Lists are comma-separated, booleans are true/false, an empty
# value means unset, and a _b64 suffix means the value is base64-encoded.
#
# Comments start with # or ; and may follow a value on the same line. That
# means # and ; cannot appear inside a value - which is why the pre-shared
# key, the one setting that can hold arbitrary text, is base64-encoded.
#
# Apply any change with: systemctl restart vpn-router-setup
#
# Every feature is optional. A file with nothing filled in configures nothing
# and leaves the host reachable and unchanged.
[general]
# Platform module to load: generic, azure or gcp.
platform = generic
[interfaces]
# The two inputs everything else is built on. Names only: the addresses on
# these interfaces are read from the system, never configured here.
# External faces the untrusted network; internal faces the protected one.
external =
internal =
[wan]
# This router's fully-qualified domain name.
local_fqdn =
# Source of the local IKE identity: fqdn | public_ip | internal_ip
# fqdn - use local_fqdn (must match what the peer expects)
# public_ip - resolve local_fqdn at boot and use the address
# internal_ip - use the address on the internal interface
local_id_mode = fqdn
[local]
# Local subnets advertised into the tunnel, comma-separated.
cidrs =
# Next hop on the internal side for the subnets above.
int_gateway_ip =
[remote]
# Remote gateway address(es) or FQDN, comma-separated.
addrs =
# Remote IKE identity, without a leading '@'.
id =
# Remote subnets reachable through the tunnel, comma-separated.
cidrs =
# Pre-shared key, base64-encoded: printf %s "$PSK" | base64 -w0
psk_b64 =
# Path to a file containing the raw PSK. Takes precedence over psk_b64.
psk_file =
[p2s]
# Road-warrior access.
enabled = false
# Address pool handed to road-warrior clients.
address_pool =
# CN for the CA created when /etc/vpn-router/pki is empty.
ca_name = VPN Router CA
[wireguard]
enabled = false
# Address and prefix length for wg0, for example 192.168.200.1/24.
address =
listen_port = 51820
@@ -0,0 +1,4 @@
[Resolve]
{% for address in local_addr_list -%}
DNSStubListenerExtra={{ address }}
{% endfor -%}
@@ -21,7 +21,7 @@ connections {
children {
site2site {
mode = tunnel
local_ts = {{ local_cidrs }}
local_ts = {{ local_ts }}
remote_ts = {{ remote_cidrs }}
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
@@ -21,7 +21,7 @@ connections {
children {
road-warrior {
mode = tunnel
local_ts = {{ local_cidrs }}
local_ts = {{ local_ts }}
remote_ts = dynamic
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
@@ -38,6 +38,6 @@ connections {
pools {
rw-pool {
addrs = {{ p2s_address_pool }}
dns = {{ local_addrs }}
dns = {{ p2s_dns }}
}
}