Reengineed the code. Generalized the package. Cloud configurators are modules.
This commit is contained in:
@@ -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/ \;
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
cloud-router (1.0.0-1) unstable; urgency=medium
|
||||
vpn-router (1.0.0-1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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: *
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+49
@@ -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#
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#!/usr/bin/make -f
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_installsystemd:
|
||||
dh_installsystemd --name=vpn-router-setup
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
Executable
+337
@@ -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
@@ -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 -%}
|
||||
+1
-1
@@ -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
|
||||
+2
-2
@@ -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 }}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user