feature: the router has a two-level CA with an optional offline root, issuing CAs for P2S, site-to-site and general use, templates, published certificates and CRLs and an ACME server, site-to-site connections authenticate with certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
Release / release (push) Successful in 13s

This commit is contained in:
slawek committed 2026-10-25 22:05:39 +01:00
1 parent fe45272f7c
commit 8d3469d10a
37 files changed
+2499 -921

No files matched your search

+39
View File
@@ -1,3 +1,42 @@
vpn-router (0.21.0-1) unstable; urgency=medium
feature: the router has a two-level CA with an optional offline root, issuing CAs for P2S, site-to-site and general use, templates, published certificates and CRLs and an ACME server, site-to-site connections authenticate with certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
* The root CA signs the issuing CAs p2s, which issues the router's VPN
server certificate and the users' certificates, s2s, which issues
the site-to-site certificates, and the general-purpose CAs of
pki.issuing_cas; road-warrior and OpenVPN accept certificates of p2s
only.
* The pki section of vpn-router.json sets the names, lifetimes and
key type of the CAs, the validity of the CRLs, where certificates
and CRLs are published, the general-purpose CAs and the certificate
templates; p2s.ca_name and the vpn-router/p2s_ca_name question are
replaced by pki.root_name and vpn-router/pki_root_name.
* Without the root key on the router, new issuing CAs wait with a CSR
for an offline signature; vpn-router-ca exports, removes and imports
the root key, imports the signed certificates and a root CRL, issues
certificates from a CSR or into a PFX file, lists and revokes them.
* Certificates name their issuer's certificate and CRL at the
endpoints of pki.publish: the status port over HTTP, or Caddy on port
80, on the HTTPS status port or on port 443; the CRLs are signed
again at every revocation and by vpn-router-pki.timer.
* A general-purpose CA with acme backs an ACME server of Caddy.
* The router renews its own certificates before they expire.
* A site-to-site connection with auth certificate authenticates with
the router's site-to-site certificate and trusts the s2s CA or the
connection's remote_ca.
* /ca of the status web service shows and changes the PKI; /pki/
serves the published certificates and CRLs; /config shows the
installed Caddy and its source; request bodies may be 64 KiB.
* vpn-router no longer depends on caddy: vpn-router-setup installs it
from https.caddy_source, the Ubuntu archive or Caddy's repository,
and vpn-router-caddy use-upstream switches an installed router to
Caddy's repository for good.
* The package depends on python3-cryptography instead of openssl, and
simple-ca is gone.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 10:00:00 +0100
vpn-router (0.20.0-1) unstable; urgency=medium
feature: the router makes scheduled backups to a local folder, copied to the cloud with rclone, or to a Proxmox Backup Server, and vpn-router-backup restores them.
+1 -1
View File
@@ -51,6 +51,7 @@ 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/pki_root_name || true
db_input high vpn-router/p2s_enabled || true
db_input high vpn-router/wg_enabled || true
db_go || true
@@ -58,7 +59,6 @@ db_go || true
db_get vpn-router/p2s_enabled
if [ "$RET" = "true" ]; then
db_input high vpn-router/p2s_address_pool || true
db_input high vpn-router/p2s_ca_name || true
db_go || true
fi
+15 -11
View File
@@ -17,8 +17,8 @@ Depends: ${misc:Depends},
ufw,
debconf,
ucf,
openssl,
python3,
python3-cryptography,
python3-jinja2,
python3-ldap,
python3-pyotp,
@@ -31,20 +31,24 @@ Depends: ${misc:Depends},
openvpn-auth-ldap,
iproute2,
systemd-resolved,
caddy,
zstd
Suggests: rclone,
Suggests: caddy,
rclone,
proxmox-backup-client-static
Description: Linux VPN router with IPSec, road-warrior, OpenVPN 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 OpenVPN with a certificate and a password per user, kept in a local
LDAP directory, optional WireGuard, and the routing, NAT, MSS clamping and
firewall rules a router needs. It keeps metrics of the router in PostgreSQL
and serves them for an OpenTelemetry Collector, and makes scheduled backups
to a local folder, copied to the cloud with rclone, or to a Proxmox Backup
Server. Nothing in the package is
specific to a cloud provider.
IKEv2 IPSec (strongSwan swanctl) with pre-shared keys or certificates,
optional road-warrior access using EAP-TLS, optional OpenVPN with a
certificate and a password per user, kept in a local LDAP directory, optional
WireGuard, and the routing, NAT, MSS clamping and firewall rules a router
needs. Its two-level CA, whose root may be kept offline, has issuing CAs for
the VPN clients, for site-to-site and for general use, and publishes their
certificates and CRLs. Caddy, installed by the package from the Ubuntu
archive or from Caddy's repository, serves HTTPS. It keeps metrics of the
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
makes scheduled backups to a local folder, copied to the cloud with rclone,
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
provider.
.
Settings live in /etc/vpn-router/vpn-router.json, which is created on first
install and owned by the administrator afterwards. Editing it and restarting
+4 -1
View File
@@ -8,13 +8,16 @@ src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_metrics.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_status.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_pki.py usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-issuer usr/lib/vpn-router/
src/usr/sbin/vpn-router-user usr/sbin/
src/usr/sbin/vpn-router-group usr/sbin/
src/usr/sbin/vpn-router-token usr/sbin/
src/usr/sbin/vpn-config-edit usr/sbin/
src/usr/sbin/vpn-router-backup usr/sbin/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
src/usr/sbin/vpn-router-ca usr/sbin/
src/usr/sbin/vpn-router-caddy usr/sbin/
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
+2 -2
View File
@@ -31,7 +31,7 @@ case "$1" in
answer vpn-router/psk; VPN_ROUTER_PSK="$ANSWER"
answer vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$ANSWER"
answer vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$ANSWER"
answer vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$ANSWER"
answer vpn-router/pki_root_name; VPN_ROUTER_PKI_ROOT_NAME="$ANSWER"
answer vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$ANSWER"
answer vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$ANSWER"
answer vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$ANSWER"
@@ -44,7 +44,7 @@ case "$1" in
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_PKI_ROOT_NAME VPN_ROUTER_WG_ENABLED \
VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT
# --- Hand a candidate configuration to ucf ---
+6 -2
View File
@@ -57,7 +57,9 @@ case "$1" in
/var/lib/vpn-router/setup.done \
/var/lib/vpn-router/backup.json
rmdir --ignore-fail-on-non-empty /etc/systemd/system/vpn-router-backup.timer.d 2>/dev/null || true
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore \
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki \
/etc/openvpn/server/vpn-router-ca.pem /etc/openvpn/server/vpn-router-crl.pem
rmdir --ignore-fail-on-non-empty /var/lib/vpn-router/backup /var/lib/vpn-router 2>/dev/null || true
# The refresh tokens' database and role, when the package created them.
@@ -70,7 +72,9 @@ case "$1" in
# Key material and users are not package state: /etc/vpn-router/pki,
# /etc/vpn-router/ldap, /etc/vpn-router/rclone.conf, the user directory
# in slapd, the WireGuard key pair, everything under /etc/swanctl, the
# backups and what a restore replaced are left in place.
# backups and what a restore replaced are left in place. Caddy stays
# installed, so its repository, which vpn-router-caddy configures, stays
# too.
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
;;
esac
+4 -4
View File
@@ -17,11 +17,11 @@ case "$1" in
fi
# vpn-router-setup enabled wg-quick@wg0, openvpn-server@vpn-router,
# caddy, vpn-router-metrics and the backup timer, so the package takes
# them down again. Use the debhelper wrappers rather than systemctl, so
# the script behaves on a machine without systemd.
# caddy, vpn-router-metrics, the backup timer and the PKI timer, so the
# package takes them down again. Use the debhelper wrappers rather than
# systemctl, so the script behaves on a machine without systemd.
for unit in wg-quick@wg0.service openvpn-server@vpn-router.service caddy.service vpn-router-metrics.service \
vpn-router-backup.timer; do
vpn-router-backup.timer vpn-router-pki.timer; do
if command -v deb-systemd-invoke >/dev/null 2>&1; then
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
fi
+2
View File
@@ -9,3 +9,5 @@ override_dh_installsystemd:
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
dh_installsystemd --name=vpn-router-bootstrap --no-enable --no-start
dh_installsystemd --name=vpn-router-backup --no-enable --no-start
dh_installsystemd --name=vpn-router-pki --no-enable --no-start
dh_installsystemd --name=vpn-router-caddy --no-enable --no-start
+6 -7
View File
@@ -124,8 +124,7 @@ Type: boolean
Default: false
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.
clients, with certificates of the router's P2S issuing CA.
Template: vpn-router/p2s_address_pool
Type: string
@@ -133,12 +132,12 @@ Default:
Description: Road-warrior address pool:
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
Template: vpn-router/p2s_ca_name
Template: vpn-router/pki_root_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.
Default: VPN Router Root CA
Description: Root CA name:
Common name of the root certificate authority the router creates in
/etc/vpn-router/pki/root. Ignored when a root certificate is placed there.
Template: vpn-router/wg_enabled
Type: boolean
@@ -0,0 +1,10 @@
[Unit]
Description=VPN Router Caddy installation
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/sbin/vpn-router-caddy apply
TimeoutStartSec=20min
@@ -0,0 +1,7 @@
[Unit]
Description=VPN Router PKI maintenance
Documentation=file:/usr/share/doc/vpn-router/README.md
[Service]
Type=oneshot
ExecStart=/usr/sbin/vpn-router-ca maintain
@@ -0,0 +1,10 @@
[Unit]
Description=VPN Router PKI maintenance schedule
Documentation=file:/usr/share/doc/vpn-router/README.md
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
ProtectSystem=strict
RuntimeDirectory=vpn-router
RuntimeDirectoryPreserve=yes
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish
ProtectHome=yes
PrivateTmp=yes
@@ -50,7 +50,7 @@ ANSWERS = {
'VPN_ROUTER_PSK': ((*FIRST, 'psk'),),
'VPN_ROUTER_P2S_ENABLED': (('p2s', 'enabled'),),
'VPN_ROUTER_P2S_ADDRESS_POOL': (('p2s', 'address_pool'),),
'VPN_ROUTER_P2S_CA_NAME': (('p2s', 'ca_name'),),
'VPN_ROUTER_PKI_ROOT_NAME': (('pki', 'root_name'),),
'VPN_ROUTER_WG_ENABLED': (('wireguard', 'enabled'),),
'VPN_ROUTER_WG_ADDRESS': (('wireguard', 'address'),),
'VPN_ROUTER_WG_LISTEN_PORT': (('wireguard', 'listen_port'),),
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/python3
"""Accept an OpenVPN client certificate only when the P2S issuing CA signed it.
Run by the OpenVPN server as tls-verify for each certificate of the client's chain, with its depth and subject as the
arguments. At depth 1, the CA that signed the client's certificate, the SHA-256 fingerprint OpenVPN gives in
tls_digest_sha256_1 must be that of P2S_CA; the other depths are accepted.
"""
import hashlib
import os
import ssl
import sys
P2S_CA = '/etc/swanctl/x509ca/p2s.pem'
if len(sys.argv) < 2 or sys.argv[1] != '1':
sys.exit(0)
try:
with open(P2S_CA, encoding='ascii') as handle:
expected = hashlib.sha256(ssl.PEM_cert_to_DER_cert(handle.read())).hexdigest()
except (OSError, ValueError):
sys.exit(1)
given = os.environ.get('tls_digest_sha256_1', '').replace(':', '').lower()
sys.exit(0 if given == expected else 1)
+61 -10
View File
@@ -22,6 +22,7 @@ import os
import pathlib
import re
import secrets
import shutil
import socket
import subprocess
import sys
@@ -33,8 +34,9 @@ import ldap
import vpnrouter
import vpnrouter_ldap
import vpnrouter_pki
import vpnrouter_users
from vpnrouter import fail
from vpnrouter import fail, warn
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
@@ -72,6 +74,15 @@ CADDYFILE = pathlib.Path('/etc/caddy/vpn-router.Caddyfile')
CADDY_UNIT = 'caddy.service'
CADDY_GROUP = 'caddy'
#: The unit that installs Caddy from https.caddy_source with vpn-router-caddy, outside setup.
CADDY_INSTALL_UNIT = 'vpn-router-caddy.service'
#: The certificates and keys of the issuing CAs behind Caddy's ACME server, <name>/cert.pem and key.pem.
CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki')
#: The timer of vpn-router-ca maintain, which keeps the CRLs fresh.
PKI_TIMER = 'vpn-router-pki.timer'
FILTER_ANCHOR = '# End required lines'
#: Configuration options left out wherever the configuration is shown.
@@ -266,16 +277,21 @@ def filter_block(ctx):
'# METRICS RULES END',
]
# Let's Encrypt reaches the HTTP challenge on the external interface; the HTTPS ports are open on both.
# Let's Encrypt reaches the HTTP challenge on the external interface, and the CA files published on port 80 are
# read on both; the HTTPS ports are open on both.
if vpnrouter.https_ready(ctx) and interfaces:
ports = [port for wanted, port in ((ctx['web_enabled'], ctx['https_web_port']),
publish = ctx['pki_publish']
ports = [port for wanted, port in ((ctx['web_enabled'] or 'https' in publish or ctx['pki_acme'],
ctx['https_web_port']),
(MANAGER.exists(), ctx['https_manager_port']),
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port'])) if wanted]
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port']),
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
('https-default' in publish, vpnrouter.HTTPS_DEFAULT_PORT)) if wanted]
lines += [
'',
'# HTTPS RULES START',
*([f'-A ufw-before-input -i {ctx["wan_iface"]} -p tcp --dport {vpnrouter.ACME_HTTP_PORT} -j ACCEPT']
if ctx['wan_iface'] else []),
if ctx['wan_iface'] and 'http-80' not in publish else []),
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name in interfaces for port in ports),
'# HTTPS RULES END',
]
@@ -528,13 +544,20 @@ def manage_openvpn(ctx, changed):
run(['systemctl', 'restart', unit], check=False)
def manage_caddy(ctx):
def manage_caddy(cfg, ctx):
"""Run Caddy, the HTTPS proxy of the web service, the Router Manager and the metrics service, while HTTPS is ready, and stop it when not.
CADDYFILE serves the web service on https.web_port, while the manager is installed the
manager on https.manager_port, and while metrics are enabled the metrics service on
metrics.https_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
calling the service on its loopback address.
calling the service on its loopback address. It also serves the CA files of
vpnrouter_pki.PUBLISH_DIR at /pki/ on the endpoints of pki.publish that are Caddy's, and an
ACME server at /acme/<name>/ on https.web_port for each issuing CA with acme and a
certificate, whose certificate and key are copied to CADDY_PKI_DIR.
Caddy is installed by CADDY_INSTALL_UNIT, outside setup, when it is missing or when
https.caddy_source is upstream and Caddy's repository is not configured; the unit restarts
setup, whose next run serves with it.
"""
if not vpnrouter.https_ready(ctx):
vpnrouter.remove(CADDYFILE)
@@ -542,16 +565,42 @@ def manage_caddy(ctx):
run(['systemctl', 'disable', '--now', CADDY_UNIT], check=False)
return
source = cfg['https']['caddy_source']
if shutil.which('caddy') is None or (source == 'upstream' and not vpnrouter.CADDY_APT_SOURCE.is_file()):
run(['systemctl', 'start', '--no-block', CADDY_INSTALL_UNIT], check=False)
warn(f'Caddy is installed from the {source} source by {CADDY_INSTALL_UNIT}, which runs setup again')
return
if source == 'ubuntu' and vpnrouter.CADDY_APT_SOURCE.is_file():
warn("https.caddy_source ubuntu is ignored: Caddy's repository is configured, and Caddy stays upstream's")
try:
group = grp.getgrnam(CADDY_GROUP).gr_gid
except KeyError:
fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?')
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0)
acme = [name for name in ctx['pki_acme'] if vpnrouter_pki.ready(name)]
copied = False
for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []):
if folder.name not in acme:
shutil.rmtree(folder)
for name in acme:
folder = CADDY_PKI_DIR / name
for path in (CADDY_PKI_DIR, folder):
path.mkdir(mode=0o750, exist_ok=True)
os.chown(path, 0, group)
for file in ('cert.pem', 'key.pem'):
copied |= vpnrouter.install_file(vpnrouter_pki.CA_DIR / name / file, folder / file, 0o640)
os.chown(folder / file, 0, group)
authorities = vpnrouter.issuing_cas(cfg)
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0,
pki_publish_dir=str(vpnrouter_pki.PUBLISH_DIR),
acme_cas=[{'id': name, 'name': authorities[name]['name'].strip(),
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
'key': str(CADDY_PKI_DIR / name / 'key.pem')} for name in acme])
changed = vpnrouter.render(vpnrouter._jinja_env(), caddy_ctx, 'Caddyfile.j2', CADDYFILE, 0o640)
os.chown(CADDYFILE, 0, group)
if not unit_is_active(CADDY_UNIT):
run(['systemctl', 'enable', '--now', CADDY_UNIT], check=False)
elif changed:
elif changed or copied:
run(['systemctl', 'reload', CADDY_UNIT], check=False)
@@ -744,7 +793,9 @@ def main():
progress('running', 'OpenVPN')
manage_openvpn(ctx, changed)
progress('running', 'HTTPS proxy')
manage_caddy(ctx)
manage_caddy(cfg, ctx)
progress('running', 'PKI timer')
run(['systemctl', 'enable', '--now', PKI_TIMER], check=False)
if 'resolved' in changed:
progress('running', 'resolver')
@@ -1,611 +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.
# 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())
@@ -12,7 +12,6 @@ import os
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
@@ -24,14 +23,13 @@ if str(LIB_DIR) not in sys.path:
sys.path.insert(0, str(LIB_DIR))
import vpnrouter_ldap
import vpnrouter_pki
import vpnrouter_platforms
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock')
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')
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
@@ -40,13 +38,6 @@ P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf')
OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
USER_CERT_DIR = PKI_DIR / 'users'
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; the type of the default is the required type.
SCHEMA = {
@@ -55,13 +46,16 @@ SCHEMA = {
'wan': {'local_fqdn': ''},
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
'site_to_site': {},
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
'p2s': {'enabled': False, 'address_pool': ''},
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
'status_content': 'basic'},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {}},
'metrics': {'enabled': True, 'listen': 'internal', 'port': 9464, 'https_port': 9465, 'service_account': 'metrics',
'otlp_endpoint': '', 'otlp_username': '', 'otlp_password': '',
'keep_details': '24 hours', 'keep_hourly': '1 month', 'keep_daily': '3 months',
@@ -74,7 +68,11 @@ SCHEMA = {
}
#: site_to_site maps a connection name to its settings: option -> default, the type of the default being the required type.
CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'psk': '', 'psk_file': ''}
CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'auth': 'psk', 'psk': '',
'psk_file': '', 'remote_ca': ''}
#: How a site_to_site connection authenticates: a pre-shared key, or certificates.
CONNECTION_AUTH = ('psk', 'certificate')
#: The name of a site_to_site connection, which also names its swanctl connection, child and secret.
CONNECTION_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
@@ -115,6 +113,68 @@ PBS_NAMESPACE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-
#: The port of Let's Encrypt's HTTP challenge, which Caddy answers while HTTPS is on.
ACME_HTTP_PORT = 80
#: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default.
HTTPS_DEFAULT_PORT = 443
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
CADDY_SOURCES = ('ubuntu', 'upstream')
#: Caddy's own repository, which https.caddy_source upstream configures: the source, its signing key and their URLs.
CADDY_APT_SOURCE = pathlib.Path('/etc/apt/sources.list.d/caddy-stable.sources')
CADDY_KEYRING = pathlib.Path('/etc/apt/keyrings/caddy-stable.asc')
CADDY_KEY_URL = 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'
CADDY_REPOSITORY = 'https://dl.cloudsmith.io/public/caddy/stable/deb/debian'
#: The key types of the CAs, pki.key, and of the templates.
PKI_KEYS = ('rsa-2048', 'rsa-3072', 'rsa-4096', 'ec-p256', 'ec-p384')
#: Where the CA certificates and CRLs are published, pki.publish, as the URLs in the certificates list them.
PKI_PUBLISH = ('http', 'http-80', 'https', 'https-default')
#: The key usages a template may give, as the arguments of cryptography's KeyUsage.
KEY_USAGES = ('digital_signature', 'content_commitment', 'key_encipherment', 'data_encipherment', 'key_agreement')
#: The extended key usages a template may give.
EXTENDED_KEY_USAGES = ('server_auth', 'client_auth', 'code_signing', 'email_protection', 'time_stamping',
'ocsp_signing', 'ipsec_ike', 'ike_intermediate')
#: What the common name of a template's certificates is: a DNS name, any name, or an e-mail address.
TEMPLATE_SUBJECTS = ('dns', 'name', 'email')
#: The subject alternative names a template's certificates may have.
SAN_TYPES = ('dns', 'ip', 'email')
#: A certificate template: option -> default, the type of the default being the required type.
TEMPLATE = {'key': 'rsa-3072', 'days': 365, 'key_usage': ['digital_signature'], 'extended_key_usage': [],
'subject': 'name', 'sans': []}
#: The built-in templates; pki.templates changes their options and adds templates of its own.
PKI_TEMPLATES = {
'vpn-server': {'key_usage': ['digital_signature', 'key_encipherment'],
'extended_key_usage': ['server_auth', 'ike_intermediate'], 'subject': 'dns', 'sans': ['dns', 'ip']},
'vpn-user': {'extended_key_usage': ['client_auth'], 'subject': 'name', 'sans': ['dns', 'email']},
'site-to-site': {'key_usage': ['digital_signature', 'key_encipherment'],
'extended_key_usage': ['server_auth', 'client_auth'], 'subject': 'dns', 'sans': ['dns', 'ip']},
'server': {'key_usage': ['digital_signature', 'key_encipherment'], 'extended_key_usage': ['server_auth'],
'subject': 'dns', 'sans': ['dns', 'ip']},
'client': {'extended_key_usage': ['client_auth'], 'subject': 'name', 'sans': ['dns', 'email']},
}
#: The templates only the router issues: its VPN server certificate and the users' certificates.
ROUTER_TEMPLATES = ('vpn-server', 'vpn-user')
#: The built-in issuing CAs: name -> the pki option of the common name and the templates the CA issues.
BUILTIN_CAS = {'p2s': ('p2s_name', ['vpn-server', 'vpn-user']), 's2s': ('s2s_name', ['site-to-site'])}
#: A general-purpose issuing CA of pki.issuing_cas: option -> default, the type of the default being the required type.
ISSUING_CA = {'name': '', 'templates': [], 'acme': False}
#: The name of an issuing CA or a template; an issuing CA's name also names its files.
PKI_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
#: The longest common name of a CA, in characters.
CA_NAME_MAX = 64
#: What / and /status of the web service show, web.status_content, from the least to the most.
STATUS_CONTENTS = ('basic', 'standard', 'full')
@@ -125,9 +185,6 @@ OPENVPN_PROTOCOLS = ('udp', 'tcp')
#: The tun device of the OpenVPN server.
OPENVPN_DEVICE = 'ovpn0'
#: Days a certificate revocation list is valid; the router only reads its own lists.
CRL_DAYS = 3650
#: A port or a port range as iptables multiport takes it: 443 or 8000:8100.
DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$')
@@ -337,6 +394,10 @@ def validate(cfg):
f'{prefix}.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
_check_networks(_clean(connection['cidrs']), f'{prefix}.cidrs')
_check_networks(_clean(connection['local_cidrs']), f'{prefix}.local_cidrs')
if connection['auth'] not in CONNECTION_AUTH:
raise ConfigError(f'{prefix}.auth: {connection["auth"]!r} is not one of {", ".join(CONNECTION_AUTH)}')
if connection['remote_ca'].strip() and not vpnrouter_pki.is_ca_certificate(connection['remote_ca']):
raise ConfigError(f'{prefix}.remote_ca is not the PEM certificate of a CA')
for option, value in identities:
if value.startswith('@'):
@@ -414,8 +475,89 @@ def validate(cfg):
for item in _get_list(cfg, 'web', 'trusted_proxies'):
_check_address(item, 'web.trusted_proxies')
caddy_source = _get(cfg, 'https', 'caddy_source')
if caddy_source not in CADDY_SOURCES:
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
for option in ('root_name', 'p2s_name', 's2s_name'):
value = _get(cfg, 'pki', option)
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
raise ConfigError(f'pki.{option} must have 1 to {CA_NAME_MAX} printable characters')
for option in ('root_days', 'ca_days', 'crl_days', 'root_crl_days'):
if not 1 <= _get(cfg, 'pki', option) <= 36500:
raise ConfigError(f'pki.{option} must be 1 to 36500 days')
if _get(cfg, 'pki', 'key') not in PKI_KEYS:
raise ConfigError(f'pki.key: {_get(cfg, "pki", "key")!r} is not one of {", ".join(PKI_KEYS)}')
publish = _get_list(cfg, 'pki', 'publish')
for item in publish:
if item not in PKI_PUBLISH:
raise ConfigError(f'pki.publish: {item!r} is not one of {", ".join(PKI_PUBLISH)}')
if len(set(publish)) != len(publish):
raise ConfigError('pki.publish names an endpoint twice')
if publish and not _get(cfg, 'wan', 'local_fqdn'):
raise ConfigError('pki.publish needs wan.local_fqdn, the name in the published URLs')
if 'http' in publish and not _get(cfg, 'web', 'enabled'):
raise ConfigError('pki.publish: http is served by the web service, which needs web.enabled')
if set(publish) - {'http'} and not _get(cfg, 'https', 'enabled'):
raise ConfigError('pki.publish: http-80, https and https-default are served by Caddy, which needs https.enabled')
for name, template in cfg['pki']['templates'].items():
prefix = f'pki.templates.{name}'
if not PKI_NAME.match(name):
raise ConfigError(f'{prefix}: a template name starts with a lowercase letter and has at most 31 lowercase '
'letters, digits and hyphens')
if not isinstance(template, dict):
raise ConfigError(f'{prefix} must be {TYPE_NAMES[dict]}')
_check_options(template, TEMPLATE, prefix)
templates = pki_templates(cfg)
for name, template in templates.items():
prefix = f'pki.templates.{name}'
if template['key'] not in PKI_KEYS:
raise ConfigError(f'{prefix}.key: {template["key"]!r} is not one of {", ".join(PKI_KEYS)}')
if not 1 <= template['days'] <= 36500:
raise ConfigError(f'{prefix}.days must be 1 to 36500 days')
for option, allowed in (('key_usage', KEY_USAGES), ('extended_key_usage', EXTENDED_KEY_USAGES),
('sans', SAN_TYPES)):
for item in template[option]:
if item not in allowed:
raise ConfigError(f'{prefix}.{option}: {item!r} is not one of {", ".join(allowed)}')
if not template['key_usage']:
raise ConfigError(f'{prefix}.key_usage needs at least one usage')
if template['subject'] not in TEMPLATE_SUBJECTS:
raise ConfigError(f'{prefix}.subject: {template["subject"]!r} is not one of {", ".join(TEMPLATE_SUBJECTS)}')
for name, authority in cfg['pki']['issuing_cas'].items():
prefix = f'pki.issuing_cas.{name}'
if not PKI_NAME.match(name) or name in BUILTIN_CAS or name == 'root':
raise ConfigError(f'{prefix}: an issuing CA name starts with a lowercase letter and has at most 31 '
f'lowercase letters, digits and hyphens, and is not root, {" or ".join(BUILTIN_CAS)}')
if not isinstance(authority, dict):
raise ConfigError(f'{prefix} must be {TYPE_NAMES[dict]}')
_check_options(authority, ISSUING_CA, prefix)
for name, authority in issuing_cas(cfg).items():
prefix = f'pki.issuing_cas.{name}'
if authority['builtin']:
continue
common_name = authority['name'].strip()
if not common_name or len(common_name) > CA_NAME_MAX or not common_name.isprintable():
raise ConfigError(f'{prefix}.name must have 1 to {CA_NAME_MAX} printable characters')
if not authority['templates']:
raise ConfigError(f'{prefix}.templates needs at least one template')
for template in authority['templates']:
if template not in templates:
raise ConfigError(f'{prefix}.templates: {template!r} is not a template')
if template in ROUTER_TEMPLATES:
raise ConfigError(f'{prefix}.templates: {template} is issued by the P2S CA only')
if authority['acme'] and not _get(cfg, 'https', 'enabled'):
raise ConfigError(f'{prefix}.acme: the ACME server is Caddy\'s, which needs https.enabled')
taken = {web_port: 'web.port', ACME_HTTP_PORT: 'the HTTP challenge',
https_ports[0]: 'https.web_port', https_ports[1]: 'https.manager_port'}
if 'https-default' in publish:
if HTTPS_DEFAULT_PORT in taken:
raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of '
f'{taken[HTTPS_DEFAULT_PORT]}')
if _get(cfg, 'openvpn', 'protocol') == 'tcp' and _get(cfg, 'openvpn', 'port') == HTTPS_DEFAULT_PORT:
raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of openvpn.port')
taken[HTTPS_DEFAULT_PORT] = 'pki.publish https-default'
for option in ('port', 'https_port'):
port = _get(cfg, 'metrics', option)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
@@ -496,7 +638,8 @@ def validate(cfg):
weight += 2 if ':' in item else 1
first, _, last = item.partition(':')
if _get(cfg, 'https', 'enabled'):
for port in (ACME_HTTP_PORT, *https_ports):
caddy_ports = (ACME_HTTP_PORT, *https_ports, *([HTTPS_DEFAULT_PORT] if 'https-default' in publish else []))
for port in caddy_ports:
if int(first) <= port <= int(last or first):
raise ConfigError(
f'docker.exposed_ports: {item!r} takes port {port}, which Caddy serves while '
@@ -512,6 +655,37 @@ def backup_keeps(schedule):
return BACKUP_KEEP[BACKUP_SCHEDULES.index(schedule):]
def pki_templates(cfg):
"""The certificate templates: the built-in ones (PKI_TEMPLATES) and those of pki.templates, each filled from TEMPLATE."""
custom = cfg['pki']['templates']
return {name: {**TEMPLATE, **PKI_TEMPLATES.get(name, {}),
**(custom.get(name) if isinstance(custom.get(name), dict) else {})}
for name in [*PKI_TEMPLATES, *(name for name in custom if name not in PKI_TEMPLATES)]}
def issuing_cas(cfg):
"""The issuing CAs, name -> {name, templates, acme, builtin}: p2s and s2s (BUILTIN_CAS) with their common names from
the pki section, then those of pki.issuing_cas, each filled from ISSUING_CA."""
found = {name: {'name': _get(cfg, 'pki', option), 'templates': list(templates), 'acme': False, 'builtin': True}
for name, (option, templates) in BUILTIN_CAS.items()}
for name, options in cfg['pki']['issuing_cas'].items():
if isinstance(options, dict):
found[name] = {**ISSUING_CA, **options, 'builtin': False}
return found
def pki_base_urls(cfg):
"""The base URLs, ending in /, under which the CA certificates and CRLs are published, in the order of pki.publish."""
fqdn = _get(cfg, 'wan', 'local_fqdn')
bases = {
'http': f'http://{fqdn}:{_get(cfg, "web", "port")}/pki/',
'http-80': f'http://{fqdn}/pki/',
'https': f'https://{fqdn}:{_get(cfg, "https", "web_port")}/pki/',
'https-default': f'https://{fqdn}/pki/',
}
return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else []
def resolve_psk(connection, name):
"""Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
psk_file = connection['psk_file'].strip()
@@ -626,6 +800,7 @@ def build_context(cfg, public_ip=None):
id_mode = connection['local_id_mode'].strip() or 'fqdn'
remote_cidrs = _clean(connection['cidrs'])
connection_cidrs = _clean(connection['local_cidrs'])
auth = connection['auth']
entry = {
'name': name,
'remote_addrs': ', '.join(_clean(connection['addrs'])),
@@ -636,10 +811,13 @@ def build_context(cfg, public_ip=None):
'local_cidr_list': connection_cidrs,
'local_id_mode': id_mode,
'local_id': identities.get(id_mode, ''),
'psk': resolve_psk(connection, name),
'auth': auth,
'psk': resolve_psk(connection, name) if auth == 'psk' else '',
'remote_cacert': vpnrouter_pki.remote_ca_file(name) if connection['remote_ca'].strip()
else vpnrouter_pki.SWANCTL_CA_FILES['s2s'],
}
entry['ready'] = bool(local_addrs and connection_cidrs and all(
entry[key] for key in ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', 'psk')))
required = ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', *(('psk',) if auth == 'psk' else ()))
entry['ready'] = bool(local_addrs and connection_cidrs and all(entry[key] for key in required))
connections.append(entry)
return {
@@ -658,7 +836,7 @@ def build_context(cfg, public_ip=None):
'connections': connections,
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
'public_ip': public_ip or '',
'p2s_dns': _join(local_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'),
@@ -674,10 +852,11 @@ def build_context(cfg, public_ip=None):
'openvpn_device': OPENVPN_DEVICE,
'openvpn_ldap_conf': str(OPENVPN_LDAP),
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
'ca_cert': str(CA_CERT_DEST),
'server_cert': str(SERVER_CERT),
'server_key': str(SERVER_KEY),
'crl': str(CRL_DEST),
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
'server_cert': str(vpnrouter_pki.VPN_SERVER_CERT),
'server_key': str(vpnrouter_pki.VPN_SERVER_KEY),
'server_certificate': False,
'ldap_uri': vpnrouter_ldap.URI,
'ldap_users': vpnrouter_ldap.USERS,
'web_enabled': _get(cfg, 'web', 'enabled'),
@@ -686,6 +865,8 @@ def build_context(cfg, public_ip=None):
'https_email': _get(cfg, 'https', 'email'),
'https_web_port': _get(cfg, 'https', 'web_port'),
'https_manager_port': _get(cfg, 'https', 'manager_port'),
'pki_publish': _get_list(cfg, 'pki', 'publish'),
'pki_acme': [name for name, authority in issuing_cas(cfg).items() if authority['acme']],
'metrics_enabled': _get(cfg, 'metrics', 'enabled'),
'metrics_port': _get(cfg, 'metrics', 'port'),
'metrics_https_port': _get(cfg, 'metrics', 'https_port'),
@@ -706,7 +887,8 @@ def routing_ready(ctx):
def p2s_ready(ctx):
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'])
"""Road-warrior access needs its settings and the router's VPN server certificate, which the P2S CA issues."""
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'] and ctx['server_certificate'])
def wg_ready(ctx):
@@ -714,7 +896,8 @@ def wg_ready(ctx):
def openvpn_ready(ctx):
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'])
"""OpenVPN needs its settings and the router's VPN server certificate, which the P2S CA issues."""
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'] and ctx['server_certificate'])
def https_ready(ctx):
@@ -805,62 +988,6 @@ def install_file(src, 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 CRL when the PKI directory is empty, and the server certificate once wan.local_fqdn is set.
A CA of the router's own (ca_key.pem present) without a CRL gets one, and without a
certificate for wan.local_fqdn gets one too; nothing present is overwritten.
"""
PKI_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PKI_DIR, 0o700)
if not SIMPLE_CA.is_file():
fail(f'{SIMPLE_CA} is missing')
if not any(PKI_DIR.iterdir()):
if not ctx['p2s_ca_name']:
warn('p2s.ca_name is empty, not creating a local CA')
return
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
if not (PKI_DIR / 'ca_key.pem').is_file():
return
if not (PKI_DIR / 'crl.pem').is_file():
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
if label and not (PKI_DIR / f'{label}_cert.pem').is_file():
_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, public_ip=None):
"""Render configuration files and provision PKI.
@@ -883,9 +1010,20 @@ def configure(cfg=None, public_ip=None):
changed = set()
provision_pki(ctx)
if distribute_pki(ctx):
changed.add('swanctl')
with vpnrouter_pki.locked():
try:
notices = vpnrouter_pki.provision(cfg, ctx)
if vpnrouter_pki.distribute(cfg):
changed.add('swanctl')
except vpnrouter_pki.Failure as failure:
fail(str(failure))
for notice in notices:
warn(notice)
# What needs the router's own certificates.
ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file()
for connection in ctx['connections']:
if connection['auth'] == 'certificate':
connection['ready'] = connection['ready'] and vpnrouter_pki.S2S_CERT.is_file()
env = _jinja_env()
@@ -0,0 +1,990 @@
"""The router's PKI, for vpn-router-setup, the web service, vpn-router-ca and the user module.
The root CA (ROOT_DIR) signs the issuing CAs, and only them: p2s, which signs the router's VPN server certificate and
the users' certificates, s2s, which signs the router's site-to-site certificate and those of its peers, and the
general-purpose CAs of pki.issuing_cas. Each CA is a directory with cert.pem, key.pem and crl.pem. While the root key is
not on the router, a new issuing CA has key.pem and csr.pem and is pending until import_ca takes its certificate, signed
offline. The router's own certificates are in ROUTER_DIR and the users' in USER_DIR, with their keys, which the OpenVPN
profile embeds; a certificate issued for anything else is kept without its key. STATE records every certificate issued,
its revocation, the CRL numbers, where the root CRL came from and the retired CAs.
The certificates carry the URLs of pki.publish for their issuer's certificate (AIA) and CRL (CRL distribution point);
PUBLISH_DIR holds the DER copies served there. distribute copies what strongSwan and OpenVPN read.
Callers hold locked() around every operation that writes, which is the lock of the user module's sessions too. A request
that cannot be done raises Failure, whose status says why: 2 the request is invalid, 3 something it names does not
exist, 4 the state does not allow it, 1 anything else.
"""
import base64
import contextlib
import datetime
import fcntl
import ipaddress
import json
import os
import pathlib
import re
import shutil
import subprocess
import tempfile
from cryptography import x509
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.x509.oid import AuthorityInformationAccessOID, ExtendedKeyUsageOID, NameOID
import vpnrouter
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
ROOT_DIR = PKI_DIR / 'root'
CA_DIR = PKI_DIR / 'ca'
ROUTER_DIR = PKI_DIR / 'router'
USER_DIR = PKI_DIR / 'users'
RETIRED_DIR = PKI_DIR / 'retired'
STATE = PKI_DIR / 'state.json'
#: The DER copies of the CA certificates and CRLs, <name>.crt and <name>.crl, which the web service and Caddy serve.
PUBLISH_DIR = pathlib.Path('/var/lib/vpn-router/pki-publish')
#: The lock of the PKI and of the user module's sessions.
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
#: The copies strongSwan reads: one CA certificate per file, as strongSwan reads only the first of a file.
X509CA_DIR = pathlib.Path('/etc/swanctl/x509ca')
X509_DIR = pathlib.Path('/etc/swanctl/x509')
PRIVATE_DIR = pathlib.Path('/etc/swanctl/private')
X509CRL_DIR = pathlib.Path('/etc/swanctl/x509crl')
SWANCTL_CA_FILES = {'root': 'root.pem', 'p2s': 'p2s.pem', 's2s': 's2s.pem'}
VPN_SERVER_CERT = X509_DIR / 'vpn-server.pem'
VPN_SERVER_KEY = PRIVATE_DIR / 'vpn-server.key'
S2S_CERT = X509_DIR / 'site-to-site.pem'
S2S_KEY = PRIVATE_DIR / 'site-to-site.key'
#: What OpenVPN reads: the root and the P2S CA, and their CRLs, which it needs for every CA of a client's chain.
OPENVPN_CA = pathlib.Path('/etc/openvpn/server/vpn-router-ca.pem')
OPENVPN_CRL = pathlib.Path('/etc/openvpn/server/vpn-router-crl.pem')
#: The root CA in the system's trust store.
TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.crt')
#: The router's own certificates: name -> the issuing CA and the template.
ROUTER_CERTS = {'vpn-server': ('p2s', 'vpn-server'), 'site-to-site': ('s2s', 'site-to-site')}
#: The extended key usages of vpnrouter.EXTENDED_KEY_USAGES.
EKU_OIDS = {
'server_auth': ExtendedKeyUsageOID.SERVER_AUTH,
'client_auth': ExtendedKeyUsageOID.CLIENT_AUTH,
'code_signing': ExtendedKeyUsageOID.CODE_SIGNING,
'email_protection': ExtendedKeyUsageOID.EMAIL_PROTECTION,
'time_stamping': ExtendedKeyUsageOID.TIME_STAMPING,
'ocsp_signing': ExtendedKeyUsageOID.OCSP_SIGNING,
'ipsec_ike': ExtendedKeyUsageOID.IPSEC_IKE,
'ike_intermediate': x509.ObjectIdentifier('1.3.6.1.5.5.8.2.2'),
}
#: The revocation reasons revoke takes.
REASONS = ('unspecified', 'key_compromise', 'affiliation_changed', 'superseded', 'cessation_of_operation',
'privilege_withdrawn')
#: Days before an expiry or a CRL's next update that status warns.
WARN_DAYS = 30
#: The longest PFX password, in characters.
PASSWORD_MAX = 128
DNS_NAME = re.compile(r'^(\*\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$')
EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
SERIAL = re.compile(r'^[0-9A-Fa-f]{1,40}$')
CA_USAGE = x509.KeyUsage(digital_signature=False, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=True, crl_sign=True,
encipher_only=False, decipher_only=False)
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 not found, 4 not allowed in this state, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
@contextlib.contextmanager
def locked():
"""Hold the lock of the PKI, which the user module's sessions share."""
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK_FILE, 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
yield
def now():
return datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
def iso(moment):
return moment.astimezone(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
def parse_iso(text):
return datetime.datetime.strptime(text, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=datetime.timezone.utc)
def utc(item, name):
"""A time of a certificate or CRL in UTC: the *_utc attribute of newer cryptography, or the naive one of older."""
if hasattr(item, f'{name}_utc'):
return getattr(item, f'{name}_utc')
value = getattr(item, name)
return value.replace(tzinfo=datetime.timezone.utc) if value is not None else None
def directory(name):
"""The directory of the root (root) or of an issuing CA."""
return ROOT_DIR if name == 'root' else CA_DIR / name
def read_cert(path):
return x509.load_pem_x509_certificate(path.read_bytes()) if path.is_file() else None
def read_key(path):
return serialization.load_pem_private_key(path.read_bytes(), None) if path.is_file() else None
def read_crl(path):
return x509.load_pem_x509_crl(path.read_bytes()) if path.is_file() else None
def cert_pem(cert):
return cert.public_bytes(serialization.Encoding.PEM)
def key_pem(key, password=None):
encryption = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
return key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, encryption)
def write(path, data, mode):
"""Write the bytes to the file with the mode, replacing it in one step; True when its content changed."""
if path.is_file() and path.read_bytes() == data:
os.chmod(path, mode)
return False
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f'.{path.name}.')
try:
with os.fdopen(descriptor, 'wb') as handle:
handle.write(data)
os.chmod(name, mode)
os.replace(name, path)
except OSError:
os.unlink(name)
raise
return True
def remove(path):
"""Remove the file; True when it existed."""
if path.is_file():
path.unlink()
return True
return False
def read_state():
try:
state = json.loads(STATE.read_text(encoding='utf-8'))
except FileNotFoundError:
state = {}
state.setdefault('certificates', [])
state.setdefault('crl_numbers', {})
state.setdefault('retired', [])
state.setdefault('root_crl', None)
return state
def write_state(state):
write(STATE, (json.dumps(state, indent=2) + '\n').encode(), 0o600)
def new_key(kind):
"""A new private key of one of vpnrouter.PKI_KEYS."""
if kind.startswith('rsa-'):
return rsa.generate_private_key(public_exponent=65537, key_size=int(kind[4:]))
return ec.generate_private_key(ec.SECP384R1() if kind == 'ec-p384' else ec.SECP256R1())
def digest(key):
"""The hash a key signs with: SHA-384 for a P-384 key, SHA-256 otherwise."""
return hashes.SHA384() if isinstance(key, ec.EllipticCurvePrivateKey) and key.curve.name == 'secp384r1' \
else hashes.SHA256()
def common_name(item):
"""The common name of a certificate's or a CSR's subject, or the whole subject without one."""
names = item.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
return names[0].value if names else item.subject.rfc4514_string()
def san_values(item):
"""The subject alternative names of a certificate or a CSR as strings: DNS:, IP: and email: values."""
try:
names = item.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
except x509.ExtensionNotFound:
return []
return ([f'DNS:{value}' for value in names.get_values_for_type(x509.DNSName)] +
[f'IP:{value}' for value in names.get_values_for_type(x509.IPAddress)] +
[f'email:{value}' for value in names.get_values_for_type(x509.RFC822Name)])
def same_key(first, second):
der = serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
return first.public_bytes(*der) == second.public_bytes(*der)
def issued_by(cert, issuer):
"""Whether the issuer's key signed the certificate."""
try:
cert.verify_directly_issued_by(issuer)
except (ValueError, TypeError, InvalidSignature):
return False
return True
def is_ca(cert):
try:
return cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca
except x509.ExtensionNotFound:
return False
def is_ca_certificate(text):
"""Whether the text is the PEM certificate of a CA."""
try:
return is_ca(x509.load_pem_x509_certificate(text.strip().encode()))
except ValueError:
return False
def authority_key_id(issuer):
try:
return x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
issuer.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value)
except x509.ExtensionNotFound:
return x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer.public_key())
def sign(subject, public_key, issuer, issuer_key, days, extensions, urls=(), issuer_name=None):
"""A certificate with the common name subject for public_key, signed by issuer_key, valid for days but not past the
issuer, with the extensions [(extension, critical)].
issuer is the issuer's certificate, None for a self-signed one. The certificate points at the issuer's certificate
and CRL, issuer_name.crt and issuer_name.crl, under each of the base urls.
"""
moment = now()
end = moment + datetime.timedelta(days=days)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)])
builder = (x509.CertificateBuilder()
.subject_name(name)
.issuer_name(issuer.subject if issuer is not None else name)
.public_key(public_key)
.serial_number(x509.random_serial_number())
.not_valid_before(moment - datetime.timedelta(minutes=5))
.not_valid_after(min(end, utc(issuer, 'not_valid_after')) if issuer is not None else end)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(public_key), False))
if issuer is not None:
builder = builder.add_extension(authority_key_id(issuer), False)
if issuer is not None and urls:
builder = builder.add_extension(x509.AuthorityInformationAccess([
x509.AccessDescription(AuthorityInformationAccessOID.CA_ISSUERS,
x509.UniformResourceIdentifier(f'{url}{issuer_name}.crt')) for url in urls]), False)
builder = builder.add_extension(x509.CRLDistributionPoints([x509.DistributionPoint(
[x509.UniformResourceIdentifier(f'{url}{issuer_name}.crl') for url in urls], None, None, None)]), False)
for extension, critical in extensions:
builder = builder.add_extension(extension, critical)
return builder.sign(issuer_key, digest(issuer_key))
def ca_extensions(path_length):
return [(x509.BasicConstraints(ca=True, path_length=path_length), True), (CA_USAGE, True)]
def general_names(values, allowed):
"""The subject alternative names of values, each a DNS name, an IP address or an e-mail address of the allowed
types (vpnrouter.SAN_TYPES)."""
names = []
for value in values:
if not isinstance(value, str) or not value.strip():
raise Failure('a subject alternative name must be a non-empty string', 2)
value = value.strip()
try:
kind, name = 'ip', x509.IPAddress(ipaddress.ip_address(value))
except ValueError:
if '@' in value:
if not EMAIL.match(value):
raise Failure(f'{value!r} is not an e-mail address', 2)
kind, name = 'email', x509.RFC822Name(value)
else:
if not DNS_NAME.match(value.lower()):
raise Failure(f'{value!r} is not a DNS name, an IP address or an e-mail address', 2)
kind, name = 'dns', x509.DNSName(value.lower())
if kind not in allowed:
raise Failure(f'{value}: the template allows no {kind} names', 2)
if name not in names:
names.append(name)
return names
def leaf_extensions(template, names):
"""The extensions of a certificate of the template with the subject alternative names."""
usage = x509.KeyUsage(**{option: option in template['key_usage'] for option in vpnrouter.KEY_USAGES},
key_cert_sign=False, crl_sign=False, encipher_only=False, decipher_only=False)
extensions = [(x509.BasicConstraints(ca=False, path_length=None), True), (usage, True)]
if template['extended_key_usage']:
extensions.append((x509.ExtendedKeyUsage([EKU_OIDS[item] for item in template['extended_key_usage']]), False))
if names:
extensions.append((x509.SubjectAlternativeName(names), False))
return extensions
def check_subject(kind, subject):
"""Check a common name against the subject of a template (vpnrouter.TEMPLATE_SUBJECTS)."""
if not isinstance(subject, str) or not subject.strip():
raise Failure('the subject is required', 2)
subject = subject.strip()
if kind == 'dns' and not DNS_NAME.match(subject.lower()):
raise Failure(f'{subject!r} is not a DNS name, which the template needs as the subject', 2)
if kind == 'email' and not EMAIL.match(subject):
raise Failure(f'{subject!r} is not an e-mail address, which the template needs as the subject', 2)
if len(subject) > 64 or not subject.isprintable():
raise Failure('the subject must have 1 to 64 printable characters', 2)
return subject.lower() if kind == 'dns' else subject
def record(state, cert, ca, kind, template=None):
"""Add a certificate to the state: kind is ca, router, user or external."""
state['certificates'].append({
'serial': f'{cert.serial_number:X}', 'ca': ca, 'kind': kind, 'template': template,
'subject': common_name(cert), 'sans': san_values(cert), 'created': iso(now()),
'expires': iso(utc(cert, 'not_valid_after')), 'revoked': None, 'reason': None,
})
def sign_crl(cfg, name, state):
"""Sign a new CRL of the CA with the certificates it revoked that have not expired, valid for pki.root_crl_days for
the root and pki.crl_days for an issuing CA."""
folder = directory(name)
cert, key = read_cert(folder / 'cert.pem'), read_key(folder / 'key.pem')
number = state['crl_numbers'].get(name, 0) + 1
moment = now()
days = cfg['pki']['root_crl_days'] if name == 'root' else cfg['pki']['crl_days']
builder = (x509.CertificateRevocationListBuilder()
.issuer_name(cert.subject)
.last_update(moment)
.next_update(moment + datetime.timedelta(days=days))
.add_extension(x509.CRLNumber(number), False)
.add_extension(authority_key_id(cert), False))
for entry in state['certificates']:
if entry['ca'] == name and entry['revoked'] and entry['expires'] > iso(moment):
revoked = (x509.RevokedCertificateBuilder()
.serial_number(int(entry['serial'], 16))
.revocation_date(parse_iso(entry['revoked'])))
if entry['reason'] and entry['reason'] != 'unspecified':
revoked = revoked.add_extension(x509.CRLReason(x509.ReasonFlags[entry['reason']]), False)
builder = builder.add_revoked_certificate(revoked.build())
crl = builder.sign(key, digest(key))
state['crl_numbers'][name] = number
if name == 'root':
state['root_crl'] = 'signed'
write(folder / 'crl.pem', crl.public_bytes(serialization.Encoding.PEM), 0o644)
def crl_stale(path):
"""Whether the CRL is missing or past half of its validity."""
crl = read_crl(path)
if crl is None:
return True
last, following = utc(crl, 'last_update'), utc(crl, 'next_update')
return following is None or now() >= last + (following - last) / 2
def active():
"""The names of the issuing CAs with a certificate, sorted."""
return sorted(folder.name for folder in CA_DIR.iterdir() if (folder / 'cert.pem').is_file()) \
if CA_DIR.is_dir() else []
def ready(name):
"""Whether the issuing CA has its certificate and key, and so issues certificates."""
return (CA_DIR / name / 'cert.pem').is_file() and (CA_DIR / name / 'key.pem').is_file()
def refresh_crls(cfg, state, force=()):
"""Sign a new CRL for each CA with its key on the router whose CRL is stale, or that force names."""
for name in ['root', *active()]:
folder = directory(name)
if (folder / 'key.pem').is_file() and (folder / 'cert.pem').is_file() and \
(name in force or crl_stale(folder / 'crl.pem')):
sign_crl(cfg, name, state)
def renewal_due(cert):
"""Whether less than a third of the certificate's lifetime is left."""
start, end = utc(cert, 'not_valid_before'), utc(cert, 'not_valid_after')
return now() >= end - (end - start) / 3
def router_identities(name, ctx):
"""The common name and the subject alternative names of one of the router's certificates (ROUTER_CERTS)."""
fqdn = ctx['local_fqdn']
if name == 'vpn-server':
return fqdn, [fqdn]
return fqdn, [value for value in (fqdn, ctx['public_ip'], ctx['int_addr']) if value]
def ensure_router_cert(cfg, ctx, state, name, urls):
"""Issue one of the router's certificates when it is missing, due for renewal, not issued by its CA's current key,
named differently or without one of the names it needs; True when it was issued."""
ca, template_name = ROUTER_CERTS[name]
issuer, issuer_key = read_cert(CA_DIR / ca / 'cert.pem'), read_key(CA_DIR / ca / 'key.pem')
subject, sans = router_identities(name, ctx)
if issuer is None or issuer_key is None or not subject:
return False
cert_path, key_path = ROUTER_DIR / f'{name}_cert.pem', ROUTER_DIR / f'{name}_key.pem'
cert = read_cert(cert_path)
wanted = {f'IP:{value}' if is_address(value) else f'DNS:{value.lower()}' for value in sans}
if cert is not None and key_path.is_file() and not renewal_due(cert) and issued_by(cert, issuer) and \
common_name(cert) == subject and wanted <= set(san_values(cert)):
return False
template = vpnrouter.pki_templates(cfg)[template_name]
key = new_key(template['key'])
cert = sign(subject, key.public_key(), issuer, issuer_key, template['days'],
leaf_extensions(template, general_names(sans, vpnrouter.SAN_TYPES)), urls, ca)
write(key_path, key_pem(key), 0o600)
write(cert_path, cert_pem(cert), 0o644)
record(state, cert, ca, 'router', template_name)
return True
def is_address(value):
try:
ipaddress.ip_address(value)
except ValueError:
return False
return True
def provision(cfg, ctx):
"""Create what the configuration needs and is missing, and keep it current; the notices to report.
The root is created when it has neither certificate nor key. Each issuing CA without a certificate is signed by
the root key, or, while the root key is not on the router, gets its key and a CSR and is pending. An issuing CA
that pki.issuing_cas no longer names is retired: its directory moves to RETIRED_DIR, and its certificate is revoked
on the root CRL. Stale CRLs are signed again, the router's certificates issued or renewed (ensure_router_cert), and
the copies of PUBLISH_DIR written.
"""
for folder in (PKI_DIR, CA_DIR, ROUTER_DIR, USER_DIR):
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(folder, 0o700)
pki = cfg['pki']
state = read_state()
urls = vpnrouter.pki_base_urls(cfg)
notices = []
root_cert, root_key = read_cert(ROOT_DIR / 'cert.pem'), read_key(ROOT_DIR / 'key.pem')
if root_cert is None:
if root_key is not None:
raise Failure(f'{ROOT_DIR / "key.pem"} has no certificate; place root/cert.pem beside it')
root_key = new_key(pki['key'])
root_cert = sign(pki['root_name'], root_key.public_key(), None, root_key, pki['root_days'], ca_extensions(1))
write(ROOT_DIR / 'key.pem', key_pem(root_key), 0o600)
write(ROOT_DIR / 'cert.pem', cert_pem(root_cert), 0o644)
authorities = vpnrouter.issuing_cas(cfg)
force = set()
for name, authority in authorities.items():
folder = CA_DIR / name
if (folder / 'cert.pem').is_file():
continue
key = read_key(folder / 'key.pem')
if key is None:
key = new_key(pki['key'])
write(folder / 'key.pem', key_pem(key), 0o600)
if root_key is not None:
cert = sign(authority['name'].strip(), key.public_key(), root_cert, root_key, pki['ca_days'],
ca_extensions(0), urls, 'root')
write(folder / 'cert.pem', cert_pem(cert), 0o644)
remove(folder / 'csr.pem')
record(state, cert, 'root', 'ca')
force.add(name)
continue
if not (folder / 'csr.pem').is_file():
request = (x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, authority['name'].strip())]))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), True)
.add_extension(CA_USAGE, True)
.sign(key, digest(key)))
write(folder / 'csr.pem', request.public_bytes(serialization.Encoding.PEM), 0o644)
notices.append(f'issuing CA {name} waits for its certificate: sign {folder / "csr.pem"} with the root CA and '
f'import it with vpn-router-ca ca import {name}')
for folder in sorted(CA_DIR.iterdir()):
if not folder.is_dir() or folder.name in authorities:
continue
cert = read_cert(folder / 'cert.pem')
RETIRED_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
shutil.move(str(folder), str(RETIRED_DIR / f'{folder.name}-{now().strftime("%Y%m%d%H%M%S")}'))
state['retired'].append({'name': folder.name, 'retired': iso(now()),
'serial': f'{cert.serial_number:X}' if cert is not None else None})
for entry in state['certificates']:
if cert is not None and entry['ca'] == 'root' and entry['serial'] == f'{cert.serial_number:X}' and \
not entry['revoked']:
entry['revoked'], entry['reason'] = iso(now()), 'cessation_of_operation'
force.add('root')
if root_key is None and cert is not None:
notices.append(f'issuing CA {folder.name} was retired: revoke its certificate on the root CRL offline and '
'upload the CRL with vpn-router-ca root import-crl')
refresh_crls(cfg, state, force)
for name in ROUTER_CERTS:
ensure_router_cert(cfg, ctx, state, name, urls)
write_state(state)
publish()
return notices
def publish():
"""Write the DER copies of the certificates and CRLs of the root and the issuing CAs with a certificate to
PUBLISH_DIR, and remove any other file there; True when anything changed."""
PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PUBLISH_DIR, 0o755)
wanted = {}
for name in ['root', *active()]:
cert, crl = read_cert(directory(name) / 'cert.pem'), read_crl(directory(name) / 'crl.pem')
if cert is not None:
wanted[f'{name}.crt'] = cert.public_bytes(serialization.Encoding.DER)
if crl is not None:
wanted[f'{name}.crl'] = crl.public_bytes(serialization.Encoding.DER)
changed = False
for path in PUBLISH_DIR.iterdir():
if path.name not in wanted:
path.unlink()
changed = True
for name, data in wanted.items():
changed |= write(PUBLISH_DIR / name, data, 0o644)
return changed
def remote_ca_file(connection):
"""The name in X509CA_DIR of the remote CA certificate of a site_to_site connection."""
return f's2s-{connection}.pem'
def install_crls():
"""Copy the CRLs of the root, p2s and s2s to strongSwan, and those of the root and p2s to OpenVPN; True on change."""
changed = False
for name, file in SWANCTL_CA_FILES.items():
source = directory(name) / 'crl.pem'
changed |= vpnrouter.install_file(source, X509CRL_DIR / file, 0o644) if source.is_file() \
else remove(X509CRL_DIR / file)
crls = [directory(name) / 'crl.pem' for name in ('root', 'p2s')]
if all(path.is_file() for path in crls):
changed |= write(OPENVPN_CRL, b''.join(path.read_bytes() for path in crls), 0o644)
return changed
def reload_crls():
"""Install the CRLs, and have strongSwan load them when they changed; OpenVPN reads its file when it changes."""
if install_crls():
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
def distribute(cfg):
"""Copy the certificates, keys and CRLs strongSwan, OpenVPN and the system's trust store read; True on change.
strongSwan gets the root, p2s and s2s, the remote CA of each site_to_site connection that has one, the router's
certificates and keys, and the CRLs; OpenVPN the root and p2s in one file, and their CRLs (install_crls).
"""
changed = False
for name, file in SWANCTL_CA_FILES.items():
source = directory(name) / 'cert.pem'
changed |= vpnrouter.install_file(source, X509CA_DIR / file, 0o644) if source.is_file() \
else remove(X509CA_DIR / file)
remote = {remote_ca_file(name): connection['remote_ca'].strip().encode() + b'\n'
for name, connection in cfg['site_to_site'].items() if connection['remote_ca'].strip()}
for path in X509CA_DIR.glob(remote_ca_file('*')):
if path.name not in remote:
changed |= remove(path)
for file, data in remote.items():
changed |= write(X509CA_DIR / file, data, 0o644)
for name, cert, key in (('vpn-server', VPN_SERVER_CERT, VPN_SERVER_KEY), ('site-to-site', S2S_CERT, S2S_KEY)):
if (ROUTER_DIR / f'{name}_cert.pem').is_file():
changed |= vpnrouter.install_file(ROUTER_DIR / f'{name}_cert.pem', cert, 0o644)
changed |= vpnrouter.install_file(ROUTER_DIR / f'{name}_key.pem', key, 0o600)
else:
changed |= remove(cert) | remove(key)
chain = [directory(name) / 'cert.pem' for name in ('root', 'p2s')]
if all(path.is_file() for path in chain):
changed |= write(OPENVPN_CA, b''.join(path.read_bytes() for path in chain), 0o644)
changed |= install_crls()
if vpnrouter.install_file(ROOT_DIR / '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 chain_of(ca):
"""The PEM certificates of the issuing CA and the root."""
return (cert_pem(read_cert(CA_DIR / ca / 'cert.pem')) + cert_pem(read_cert(ROOT_DIR / 'cert.pem'))).decode()
def issuer_of(cfg, ca, template):
"""The certificate and key of the issuing CA, and the template, checked to be one the CA issues."""
authorities = vpnrouter.issuing_cas(cfg)
if ca not in authorities:
raise Failure(f'no issuing CA {ca}', 3)
if template not in authorities[ca]['templates']:
raise Failure(f'issuing CA {ca} does not issue {template} certificates', 2)
if not ready(ca):
raise Failure(f'issuing CA {ca} has no certificate yet', 4)
return read_cert(CA_DIR / ca / 'cert.pem'), read_key(CA_DIR / ca / 'key.pem'), \
vpnrouter.pki_templates(cfg)[template]
def issue(cfg, ca, template, csr=None, subject=None, sans=None, password=None):
"""Issue a certificate of the template from the issuing CA for something other than the router.
With a CSR, the certificate is for the CSR's key, common name and subject alternative names, and the answer is
{serial, certificate, chain} in PEM. Without one, the router makes a key of the template for the subject and sans,
and the answer is {serial, pfx}: a PKCS#12 file in base64 with the key, the certificate and the chain, protected
by the password; the key is not kept. The templates of vpnrouter.ROUTER_TEMPLATES are not issued here.
"""
if template in vpnrouter.ROUTER_TEMPLATES:
raise Failure(f'{template} certificates are issued by the router only', 2)
issuer, issuer_key, values = issuer_of(cfg, ca, template)
key = None
if csr is not None:
try:
request = x509.load_pem_x509_csr(csr.strip().encode() if isinstance(csr, str) else b'')
except ValueError:
raise Failure('the CSR is not a PEM certificate signing request', 2)
if not request.is_signature_valid:
raise Failure('the signature of the CSR is not valid', 2)
subject = common_name(request)
sans = [value.split(':', 1)[1] for value in san_values(request)]
public_key = request.public_key()
else:
if not isinstance(password, str) or not 1 <= len(password) <= PASSWORD_MAX:
raise Failure(f'the password of the PFX file must have 1 to {PASSWORD_MAX} characters', 2)
if sans is None:
sans = []
if not isinstance(sans, list):
raise Failure('sans must be a list of names', 2)
key = new_key(values['key'])
public_key = key.public_key()
subject = check_subject(values['subject'], subject)
names = general_names(([subject] if values['subject'] == 'dns' else []) + list(sans),
set(values['sans']) | ({'dns'} if values['subject'] == 'dns' else set()))
cert = sign(subject, public_key, issuer, issuer_key, values['days'], leaf_extensions(values, names),
vpnrouter.pki_base_urls(cfg), ca)
state = read_state()
record(state, cert, ca, 'external', template)
write_state(state)
serial = f'{cert.serial_number:X}'
if key is None:
return {'serial': serial, 'certificate': cert_pem(cert).decode(), 'chain': chain_of(ca)}
encryption = (serialization.PrivateFormat.PKCS12.encryption_builder()
.kdf_rounds(50000)
.key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC)
.hmac_hash(hashes.SHA1())
.build(password.encode()))
pfx = pkcs12.serialize_key_and_certificates(subject.encode(), key, cert,
[issuer, read_cert(ROOT_DIR / 'cert.pem')], encryption)
return {'serial': serial, 'pfx': base64.b64encode(pfx).decode()}
def user_files(name):
"""The certificate and the key of a user."""
return USER_DIR / f'{name}_cert.pem', USER_DIR / f'{name}_key.pem'
def issue_user(cfg, name, email):
"""Issue the user's certificate from the P2S CA with the template vpn-user: the user name as the common name and a
DNS name, and the e-mail address; the certificate and the key are kept in USER_DIR."""
issuer, issuer_key, values = issuer_of(cfg, 'p2s', 'vpn-user')
key = new_key(values['key'])
cert = sign(name, key.public_key(), issuer, issuer_key, values['days'],
leaf_extensions(values, general_names([name, email], vpnrouter.SAN_TYPES)),
vpnrouter.pki_base_urls(cfg), 'p2s')
cert_path, key_path = user_files(name)
write(key_path, key_pem(key), 0o600)
write(cert_path, cert_pem(cert), 0o644)
state = read_state()
record(state, cert, 'p2s', 'user', 'vpn-user')
write_state(state)
def revoke_entries(cfg, state, entries, reason):
"""Mark the certificates revoked, sign new CRLs of their CAs, and publish and install them."""
for entry in entries:
entry['revoked'], entry['reason'] = iso(now()), reason
names = {entry['ca'] for entry in entries}
for name in names:
if not (directory(name) / 'key.pem').is_file():
raise Failure(f'the key of {name} is not on the router, so it cannot sign its CRL', 4)
for name in names:
sign_crl(cfg, name, state)
write_state(state)
publish()
reload_crls()
def revoke_user(cfg, name):
"""Revoke the user's certificates and remove the certificate and the key."""
state = read_state()
entries = [entry for entry in state['certificates']
if entry['kind'] == 'user' and entry['subject'] == name and not entry['revoked']]
if entries:
revoke_entries(cfg, state, entries, 'cessation_of_operation')
for path in user_files(name):
path.unlink(missing_ok=True)
def revoke(cfg, serial, reason=None):
"""Revoke a certificate the router issued, by its serial number, for one of REASONS.
A user's certificate is revoked by removing the user, and an issuing CA by removing it from pki.issuing_cas. A
revoked router certificate is removed, so that vpn-router-setup issues a new one; the answer says so with
reissue.
"""
reason = reason or 'unspecified'
if reason not in REASONS:
raise Failure(f'the reason must be one of {", ".join(REASONS)}', 2)
if not isinstance(serial, str) or not SERIAL.match(serial):
raise Failure('the serial number must be hexadecimal', 2)
state = read_state()
entry = next((entry for entry in state['certificates'] if entry['serial'] == serial.upper().lstrip('0')), None)
if entry is None:
raise Failure(f'no certificate with the serial number {serial}', 3)
if entry['revoked']:
raise Failure(f'certificate {serial} is revoked', 4)
if entry['kind'] == 'user':
raise Failure(f'certificate {serial} is the certificate of user {entry["subject"]}; remove the user', 2)
if entry['kind'] == 'ca':
raise Failure(f'certificate {serial} is an issuing CA; remove it from pki.issuing_cas', 2)
revoke_entries(cfg, state, [entry], reason)
reissue = False
if entry['kind'] == 'router':
for name, (ca, _) in ROUTER_CERTS.items():
cert = read_cert(ROUTER_DIR / f'{name}_cert.pem')
if cert is not None and f'{cert.serial_number:X}' == entry['serial']:
remove(ROUTER_DIR / f'{name}_cert.pem')
reissue = True
return {**entry, 'reissue': reissue}
def certificates():
"""The certificates the router issued, newest first, each with state: valid, expired or revoked."""
moment = iso(now())
return [{**entry, 'state': 'revoked' if entry['revoked'] else 'expired' if entry['expires'] <= moment else 'valid'}
for entry in reversed(read_state()['certificates'])]
def csr(cfg, name):
"""The CSR of a pending issuing CA and the openssl extension section to sign it with: {name, csr, extensions}."""
if name not in vpnrouter.issuing_cas(cfg):
raise Failure(f'no issuing CA {name}', 3)
path = CA_DIR / name / 'csr.pem'
if not path.is_file():
raise Failure(f'issuing CA {name} is not waiting for a certificate', 4)
urls = vpnrouter.pki_base_urls(cfg)
lines = ['[ issuing_ca ]', 'basicConstraints = critical, CA:TRUE, pathlen:0', 'keyUsage = critical, keyCertSign, cRLSign',
'subjectKeyIdentifier = hash', 'authorityKeyIdentifier = keyid:always']
if urls:
lines.append('authorityInfoAccess = ' + ', '.join(f'caIssuers;URI:{url}root.crt' for url in urls))
lines.append('crlDistributionPoints = ' + ', '.join(f'URI:{url}root.crl' for url in urls))
return {'name': name, 'csr': path.read_text(encoding='utf-8'), 'extensions': '\n'.join(lines) + '\n'}
def import_ca(cfg, name, text):
"""Take the certificate of a pending issuing CA, signed by the root offline: it must be a CA certificate the root
signed for the key of the CA's CSR."""
if name not in vpnrouter.issuing_cas(cfg):
raise Failure(f'no issuing CA {name}', 3)
folder = CA_DIR / name
if (folder / 'cert.pem').is_file():
raise Failure(f'issuing CA {name} has its certificate', 4)
key = read_key(folder / 'key.pem')
if key is None:
raise Failure(f'issuing CA {name} has no key; vpn-router-setup makes it with the CSR', 3)
try:
cert = x509.load_pem_x509_certificate(text.strip().encode() if isinstance(text, str) else b'')
except ValueError:
raise Failure('the certificate is not a PEM certificate', 2)
if not issued_by(cert, read_cert(ROOT_DIR / 'cert.pem')):
raise Failure('the certificate is not signed by the root CA', 2)
if not same_key(cert.public_key(), key.public_key()):
raise Failure(f'the certificate is not for the key of the CSR of {name}', 2)
if not is_ca(cert):
raise Failure('the certificate is not a CA certificate', 2)
write(folder / 'cert.pem', cert_pem(cert), 0o644)
remove(folder / 'csr.pem')
state = read_state()
record(state, cert, 'root', 'ca')
sign_crl(cfg, name, state)
write_state(state)
publish()
return {'name': name, 'subject': common_name(cert), 'expires': iso(utc(cert, 'not_valid_after'))}
def import_root_crl(data):
"""Take a CRL of the root signed offline, in PEM or DER; it must be signed by the root and not older than the
current one."""
if isinstance(data, str):
data = data.strip().encode()
try:
crl = x509.load_pem_x509_crl(data) if data.startswith(b'-----') else x509.load_der_x509_crl(data)
except ValueError:
raise Failure('the CRL is not a PEM or DER certificate revocation list', 2)
root = read_cert(ROOT_DIR / 'cert.pem')
if root is None:
raise Failure('there is no root CA', 3)
if crl.issuer != root.subject or not crl.is_signature_valid(root.public_key()):
raise Failure('the CRL is not signed by the root CA', 2)
current = read_crl(ROOT_DIR / 'crl.pem')
if current is not None and utc(crl, 'last_update') < utc(current, 'last_update'):
raise Failure('the CRL is older than the current one', 2)
write(ROOT_DIR / 'crl.pem', crl.public_bytes(serialization.Encoding.PEM), 0o644)
state = read_state()
state['root_crl'] = 'uploaded'
write_state(state)
publish()
reload_crls()
following = utc(crl, 'next_update')
return {'next_update': iso(following) if following else None}
def import_root_cert(data):
"""Place an existing root CA's certificate, PEM, before the root exists: without its key the root is offline."""
if read_cert(ROOT_DIR / 'cert.pem') is not None or (ROOT_DIR / 'key.pem').exists():
raise Failure('the root CA exists', 4)
try:
cert = x509.load_pem_x509_certificate(data.strip() if isinstance(data, bytes) else data.strip().encode())
except ValueError:
raise Failure('the certificate is not a PEM certificate', 2)
if not is_ca(cert):
raise Failure('the certificate is not a CA certificate', 2)
write(ROOT_DIR / 'cert.pem', cert_pem(cert), 0o644)
return {'subject': common_name(cert), 'expires': iso(utc(cert, 'not_valid_after'))}
def export_root_key(passphrase):
"""The root key in PEM, encrypted with the passphrase."""
key = read_key(ROOT_DIR / 'key.pem')
if key is None:
raise Failure('the root key is not on the router', 3)
if not passphrase:
raise Failure('the passphrase is required', 2)
return key_pem(key, passphrase.encode())
def remove_root_key():
if not remove(ROOT_DIR / 'key.pem'):
raise Failure('the root key is not on the router', 3)
def import_root_key(data, passphrase=None):
"""Put the root key back, PEM, encrypted with the passphrase or not; it must be the key of the root certificate."""
root = read_cert(ROOT_DIR / 'cert.pem')
if root is None:
raise Failure('there is no root CA', 3)
try:
key = serialization.load_pem_private_key(data, passphrase.encode() if passphrase else None)
except (ValueError, TypeError):
raise Failure('the file is not a PEM private key, or the passphrase is wrong', 2)
if not same_key(key.public_key(), root.public_key()):
raise Failure('the key is not the key of the root CA', 2)
write(ROOT_DIR / 'key.pem', key_pem(key), 0o600)
def maintain(cfg):
"""Sign the stale CRLs again, publish and install them; True when one of the router's certificates is due for
renewal, which vpn-router-setup does."""
state = read_state()
refresh_crls(cfg, state)
write_state(state)
publish()
reload_crls()
for name in ROUTER_CERTS:
cert = read_cert(ROUTER_DIR / f'{name}_cert.pem')
if cert is not None and renewal_due(cert):
return True
return False
def acme_url(cfg, name):
"""The ACME directory of an issuing CA served by Caddy's ACME server."""
return f'https://{cfg["wan"]["local_fqdn"].strip()}:{cfg["https"]["web_port"]}/acme/{name}/directory'
def status(cfg):
"""The root, the issuing CAs, the retired CAs and the warnings, as /ca and vpn-router-ca status show them."""
moment = now()
soon = moment + datetime.timedelta(days=WARN_DAYS)
state = read_state()
warnings = []
def crl_of(name):
crl = read_crl(directory(name) / 'crl.pem')
following = utc(crl, 'next_update') if crl is not None else None
if crl is None:
warnings.append(f'{name} has no CRL')
elif following is not None and following <= soon:
warnings.append(f'the CRL of {name} {"expired" if following <= moment else "expires"} on {iso(following)}')
return iso(following) if following else None
root_cert = read_cert(ROOT_DIR / 'cert.pem')
root = None
if root_cert is not None:
key = (ROOT_DIR / 'key.pem').is_file()
root = {'subject': common_name(root_cert), 'expires': iso(utc(root_cert, 'not_valid_after')), 'key': key,
'crl_next_update': crl_of('root') if key or (ROOT_DIR / 'crl.pem').is_file() else None,
'crl_source': state['root_crl']}
if key:
warnings.append('the root key is on the router; export it with vpn-router-ca root export-key and remove it '
'with vpn-router-ca root remove-key to keep the root offline')
elif not (ROOT_DIR / 'crl.pem').is_file():
warnings.append('the root has no CRL; sign one offline and upload it with vpn-router-ca root import-crl')
if utc(root_cert, 'not_valid_after') <= soon:
warnings.append(f'the root certificate expires on {root["expires"]}')
authorities = []
for name, authority in vpnrouter.issuing_cas(cfg).items():
folder = CA_DIR / name
cert = read_cert(folder / 'cert.pem')
kind = 'active' if cert is not None else 'pending' if (folder / 'csr.pem').is_file() else 'missing'
entry = {'name': name, 'common_name': authority['name'], 'state': kind, 'builtin': authority['builtin'],
'templates': authority['templates'], 'acme': authority['acme'],
'subject': common_name(cert) if cert is not None else None,
'expires': iso(utc(cert, 'not_valid_after')) if cert is not None else None,
'key': (folder / 'key.pem').is_file(), 'crl_next_update': crl_of(name) if cert is not None else None,
'acme_url': acme_url(cfg, name) if authority['acme'] and cert is not None else None}
if kind == 'pending':
warnings.append(f'issuing CA {name} waits for its certificate signed by the root CA')
if cert is not None and utc(cert, 'not_valid_after') <= soon:
warnings.append(f'the certificate of issuing CA {name} expires on {entry["expires"]}')
authorities.append(entry)
root_crl = read_crl(ROOT_DIR / 'crl.pem')
for retired in state['retired']:
if retired['serial'] and root_crl is not None and \
root_crl.get_revoked_certificate_by_serial_number(int(retired['serial'], 16)) is None:
warnings.append(f'the root CRL does not revoke retired issuing CA {retired["name"]}')
for name in ROUTER_CERTS:
cert = read_cert(ROUTER_DIR / f'{name}_cert.pem')
if cert is not None and utc(cert, 'not_valid_after') <= soon:
warnings.append(f'the router certificate {name} expires on {iso(utc(cert, "not_valid_after"))}')
return {'root': root, 'authorities': authorities, 'retired': state['retired'], 'warnings': warnings}
@@ -2,8 +2,8 @@
A user is an entry in ou=users of the router's directory, of one of two kinds, fixed when it is
added. A VPN user is an inetOrgPerson with a first name, a last name and an e-mail address, whose
password OpenVPN checks, and a client certificate of the router CA with the user name as its
common name, which OpenVPN and IKEv2 accept. A Manager-only account is an account,
password OpenVPN checks, and a client certificate of the P2S issuing CA (vpnrouter_pki) with the
user name as its common name, which OpenVPN and IKEv2 accept. A Manager-only account is an account,
simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and
optionally an e-mail address, no names, no certificate and so no VPN access; it is added when
neither the first nor the last name is given, and can sign in to the web service. A service
@@ -31,8 +31,8 @@ login() checks a user's password and code with a bind. Members of ADMIN_GROUP ma
router through the web service. The module knows only the directory; the callers revoke a
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
Every operation runs in a session(), which holds the lock that serializes changes and the
connection of the user manager account. A request that cannot be done raises Failure, whose
Every operation runs in a session(), which holds the lock that serializes changes, the PKI's
lock too, and the connection of the user manager account. A request that cannot be done raises Failure, whose
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 5 a
login failed, 1 anything else. Answers are dicts with the keys of the JSON answers.
"""
@@ -41,10 +41,7 @@ import base64
import calendar
import contextlib
import fcntl
import pathlib
import re
import subprocess
import sys
import time
import ldap
@@ -54,6 +51,7 @@ import pyotp
import vpnrouter
import vpnrouter_ldap
import vpnrouter_pki
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
@@ -91,7 +89,7 @@ LOCKED = b'000001010000Z'
ADMIN_GROUP = 'admins'
#: The digits of a TOTP code given to login().
CODE = re.compile(r'^\d{6}$')
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
LOCK_FILE = vpnrouter_pki.LOCK_FILE
class Failure(Exception):
@@ -176,7 +174,7 @@ def dn_of(name):
def certificate(name):
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem'
return vpnrouter_pki.user_files(name)
def entry(connection, name):
@@ -188,18 +186,6 @@ def entry(connection, name):
raise Failure(f'user {name} does not exist', 3)
def simple_ca(*args):
result = subprocess.run([sys.executable, str(vpnrouter.SIMPLE_CA), *args], capture_output=True, text=True)
if result.returncode != 0:
raise Failure(f'simple-ca {args[0]} failed: {result.stderr.strip() or result.stdout.strip()}')
def publish_crl():
"""Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection."""
if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644):
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
def locked_since(value):
"""The time a pwdAccountLockedTime value names, in seconds since the epoch, or 0 when it names none."""
try:
@@ -283,8 +269,9 @@ def add(connection, name, password, values, service=False):
if vpn_user and len(details) != len(PERSON):
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
check_password(password)
if vpn_user and not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen')
if vpn_user and not vpnrouter_pki.ready('p2s'):
raise Failure('the P2S issuing CA has no certificate yet; vpn-router-setup creates it once a platform is chosen, '
'or, while the root CA is offline, once its certificate is imported')
if vpn_user:
record = {'objectClass': VPN_USER_CLASSES, 'uid': [name.encode()], 'cn': [common_name(details, name).encode()]}
else:
@@ -299,10 +286,10 @@ def add(connection, name, password, values, service=False):
raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, password)
if vpn_user:
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
for path in certificate(name):
path.unlink(missing_ok=True)
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name)
try:
vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail'])
except vpnrouter_pki.Failure as failure:
raise Failure(f'the certificate cannot be issued: {failure}', failure.status)
return summary(entry(connection, name))
@@ -467,19 +454,16 @@ def remove(connection, name):
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
for group in groups:
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
cert, key = certificate(name)
if cert.is_file():
simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert))
simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS))
publish_crl()
cert.unlink(missing_ok=True)
key.unlink(missing_ok=True)
try:
vpnrouter_pki.revoke_user(vpnrouter.load_config(), name)
except vpnrouter_pki.Failure as failure:
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
connection.delete_s(dn_of(name))
return {'name': name, 'removed': True}
def profile(connection, name):
"""The OpenVPN client profile of the user, with the CA, the certificate and the key inline."""
"""The OpenVPN client profile of the user, with the root and the P2S CA, the certificate and the key inline."""
entry(connection, name)
cfg = vpnrouter.load_config()
if not cfg['openvpn']['enabled']:
@@ -501,7 +485,7 @@ def profile(connection, name):
'auth-user-pass',
'auth-nocache',
'verb 3',
'<ca>', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '</ca>',
'<ca>', extract_pem(vpnrouter_pki.OPENVPN_CA.read_text(encoding='utf-8')), '</ca>',
'<cert>', extract_pem(cert.read_text(encoding='utf-8')), '</cert>',
'<key>', key.read_text(encoding='utf-8').strip(), '</key>',
'',
+105 -10
View File
@@ -9,7 +9,7 @@ Pages:
(Tailwind CSS from a CDN) for a browser
/status the status as JSON
/config the configuration without its secrets, and what the router resolved and
runs, as JSON
runs, with the installed Caddy and where it comes from, as JSON
/users the users in the router's directory, whether or not a P2S system is enabled: list,
add, change, set the password, disable, enable, remove, the OpenVPN client
profile, and TOTP enrolment (see users()); service accounts are not among them
@@ -26,6 +26,12 @@ Pages:
applied by vpn-router-setup (see site_to_site())
/backup the backup settings, the last run and the local archives: show, change and run
now (see backup())
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
settings, issue and revoke certificates, and take the certificate of an issuing CA
and a root CRL signed offline (see ca())
/pki/<name>.crt, /pki/<name>.crl
the DER certificate and CRL of the root (root) and of each issuing CA, which the
certificates name in their AIA and CRL distribution points; no authentication
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/metrics/history
the metrics the metrics service keeps, for a view: hour, day, month or year (see
@@ -34,7 +40,7 @@ Pages:
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings
/config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup and /account need an HS256 JWT in an Authorization: Bearer header signed
/config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /ca and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
token of vpn-router-token has the admin claim and names no user; it may call every path but
@@ -90,6 +96,7 @@ import vpnrouter_auth
import vpnrouter_backup
import vpnrouter_metrics
import vpnrouter_net
import vpnrouter_pki
import vpnrouter_serve
import vpnrouter_users
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers
@@ -120,15 +127,21 @@ REFRESH_SECONDS = 60
POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers',
'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone')
#: The largest request body accepted, in bytes.
BODY_LIMIT = 4096
#: The largest request body accepted, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
BODY_LIMIT = 65536
#: The settings of a site_to_site connection that /site-to-site adds and changes.
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'psk')
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
#: The HTTP status for each status of a vpnrouter_pki.Failure; any other is 500.
PKI_STATUS = {2: 400, 3: 404, 4: 409}
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
_cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()
@@ -348,8 +361,10 @@ def postgresql_databases():
def details(state):
"""What /config shows: the configuration without its secrets, and what the router resolved and runs."""
"""What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
installed Caddy, {version, source}, null when it is not installed."""
configuration = state.get('configuration', {})
caddy = package_version('caddy')
return {
'configuration': configuration,
'network': dict(state.get('network', {}), routes=routes()),
@@ -363,6 +378,9 @@ def details(state):
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
},
'postgresql': postgresql_configuration(),
# Caddy is upstream's once its repository is configured (vpn-router-caddy).
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'}
if caddy else None,
}
@@ -622,7 +640,8 @@ def site_to_site(method, path, body):
psk_set, whether psk holds a key, and established, whether its tunnel is up,
null when swanctl cannot be asked
POST /site-to-site add a connection: {"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode",
"psk"}; psk is required, and an existing name is 409
"auth", "psk", "remote_ca"}; psk is required with auth psk, the default, and an
existing name is 409
PUT /site-to-site/<name> change any of the settings of POST but name; an empty or absent psk keeps the
current key, and a psk given replaces psk_file
DELETE /site-to-site/<name> remove the connection
@@ -652,8 +671,8 @@ def site_to_site(method, path, body):
return 400, {'error': 'name is required'}
if name in connections:
return 409, {'error': f'connection {name} exists'}
if not isinstance(settings.get('psk'), str) or not settings['psk']:
return 400, {'error': 'psk is required'}
if settings.get('auth', 'psk') == 'psk' and (not isinstance(settings.get('psk'), str) or not settings['psk']):
return 400, {'error': 'psk is required with auth psk'}
connections[name] = settings
elif name not in connections:
return 404, {'error': f'no connection {name}'}
@@ -757,6 +776,75 @@ def backup(method, path, body):
return 200, {'settings': backup_settings(cfg['backup'])}
def ca(method, path, body):
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
GET /ca {"settings", "templates", "status", "certificates"}: the pki section,
the templates with the built-in ones, the root, the issuing CAs, the
retired CAs and the warnings (vpnrouter_pki.status), and the
certificates issued, newest first
PUT /ca change any of the settings of the pki section
POST /ca/certificates issue a certificate: {"ca", "template", "csr"} answers {"serial",
"certificate", "chain"}; {"ca", "template", "subject", "sans",
"password"} answers {"serial", "pfx"}, a PKCS#12 file in base64
POST /ca/certificates/<serial>/revoke revoke a certificate: {"reason"?}
GET /ca/issuing/<name>/csr the CSR of an issuing CA that waits for the offline root, with the
openssl extensions to sign it with: {"name", "csr", "extensions"}
PUT /ca/issuing/<name>/certificate take its certificate signed by the root: {"certificate"}
PUT /ca/root/crl take a root CRL signed offline: {"crl"}
A change of the settings is made as /site-to-site makes one; it, a revoked router certificate and an imported
issuing CA certificate are applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
route = (method, *parts[1:])
try:
cfg = vpnrouter.load_config()
if route == ('GET',):
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg),
'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()}
if route == ('PUT',):
settings = {option: body[option] for option in vpnrouter.SCHEMA['pki'] if option in body}
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get('pki')
data['pki'] = {**(current if isinstance(current, dict) else {}), **settings}
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg)}
with vpnrouter_pki.locked():
if route == ('POST', 'certificates'):
return 201, vpnrouter_pki.issue(cfg, body.get('ca'), body.get('template'), csr=body.get('csr'),
subject=body.get('subject'), sans=body.get('sans'),
password=body.get('password'))
if len(route) == 4 and route[:2] == ('POST', 'certificates') and route[3] == 'revoke':
document = vpnrouter_pki.revoke(cfg, route[2], body.get('reason'))
elif len(route) == 4 and route[:2] == ('GET', 'issuing') and route[3] == 'csr':
return 200, vpnrouter_pki.csr(cfg, route[2])
elif len(route) == 4 and route[:2] == ('PUT', 'issuing') and route[3] == 'certificate':
document = vpnrouter_pki.import_ca(cfg, route[2], body.get('certificate'))
elif route == ('PUT', 'root', 'crl'):
return 200, vpnrouter_pki.import_root_crl(body.get('crl'))
else:
return 404, {'error': f'no route {method} {path}'}
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except vpnrouter_pki.Failure as failure:
return PKI_STATUS.get(failure.status, 500), {'error': str(failure)}
except OSError as exc:
return 500, {'error': f'the PKI cannot be changed: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
if route[1] == 'issuing' or document.get('reissue'):
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, document
def account(name, method, path, body):
"""Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.
@@ -945,7 +1033,8 @@ class Handler(BaseHTTPRequestHandler):
# A body that is not read must not be taken for the next request.
if self.command not in ('GET', 'HEAD'):
self.close_connection = True
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup')
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
'ca')
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
@@ -989,6 +1078,9 @@ class Handler(BaseHTTPRequestHandler):
elif directory == 'backup':
code, document = backup(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'ca':
code, document = ca(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'service-accounts':
code, document = service_accounts(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
@@ -997,6 +1089,9 @@ class Handler(BaseHTTPRequestHandler):
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif self.command not in ('GET', 'HEAD'):
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \
(vpnrouter_pki.PUBLISH_DIR / path[5:]).is_file():
body, kind, code = (vpnrouter_pki.PUBLISH_DIR / path[5:]).read_bytes(), PUBLISHED[path[-4:]], 200
elif path in ('/status', '/'):
document = shown(self.status(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
agent = self.headers.get('User-Agent', '').lower()
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/python3
"""Manage the router's PKI from the command line, with vpnrouter_pki.
status shows the root, the issuing CAs and the warnings; list the certificates the router issued. issue signs a CSR,
or makes a key and writes a PFX file whose password is read from the first line of standard input. ca csr prints the
CSR of an issuing CA that waits for the offline root, with the openssl extensions to sign it with, and ca import takes
the signed certificate. root export-key writes the root key encrypted with the passphrase read from standard input,
root remove-key removes it from the router, root import-key puts it back, root import-cert places an existing root's
certificate before the root exists, and root import-crl takes a root CRL signed offline. maintain signs the stale CRLs
again; vpn-router-pki.service runs it daily. A change that needs vpn-router-setup restarts it without waiting.
The answer is text for people on standard output, and a failure is a message on standard error. With --json the answer
is JSON on standard output, and a failure is {"error": ...} there too. The exit status says what failed: 2 the request
is invalid, 3 something it names does not exist, 4 the state does not allow it, 1 anything else. -h or --help shows the
commands and their options.
vpn-router-ca [--json] status
vpn-router-ca [--json] list [--ca NAME]
vpn-router-ca [--json] issue --ca NAME --template NAME --csr FILE
vpn-router-ca [--json] issue --ca NAME --template NAME --subject NAME [--san NAME ...] --pfx FILE (password on stdin)
vpn-router-ca [--json] revoke SERIAL [--reason REASON]
vpn-router-ca [--json] maintain
vpn-router-ca [--json] ca csr NAME
vpn-router-ca [--json] ca import NAME FILE
vpn-router-ca [--json] root import-cert FILE
vpn-router-ca [--json] root export-key --out FILE (passphrase on stdin)
vpn-router-ca [--json] root remove-key [--yes]
vpn-router-ca [--json] root import-key FILE (passphrase on stdin, an empty line for none)
vpn-router-ca [--json] root import-crl FILE
"""
import argparse
import base64
import json
import os
import pathlib
import subprocess
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter
import vpnrouter_pki
from vpnrouter_pki import Failure
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors end with status 2."""
def error(self, message):
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
sys.exit(2)
def secret():
"""The first line of standard input."""
return sys.stdin.readline().rstrip('\r\n')
def restart_setup():
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True)
def show_status(document):
root = document['root']
if root is None:
print('Root CA: none; vpn-router-setup creates it once a platform is chosen')
else:
print(f'Root CA: {root["subject"]}, expires {root["expires"]}, key {"on the router" if root["key"] else "offline"}'
f', CRL until {root["crl_next_update"] or "none"}')
for authority in document['authorities']:
line = f'Issuing CA {authority["name"]}: {authority["common_name"]}, {authority["state"]}'
if authority['expires']:
line += f', expires {authority["expires"]}, CRL until {authority["crl_next_update"] or "none"}'
line += f', templates {", ".join(authority["templates"])}'
if authority['acme_url']:
line += f', ACME {authority["acme_url"]}'
print(line)
for retired in document['retired']:
print(f'Retired CA {retired["name"]}: {retired["retired"]}')
for warning in document['warnings']:
print(f'Warning: {warning}')
def run(options):
"""Do the command of the options; the answer as a document, and a text for people."""
cfg = vpnrouter.load_config()
if options.command == 'status':
document = vpnrouter_pki.status(cfg)
return document, None
if options.command == 'list':
found = [entry for entry in vpnrouter_pki.certificates() if options.ca in (None, entry['ca'])]
text = '\n'.join(f'{entry["serial"]} {entry["ca"]} {entry["kind"]} {entry["subject"]} {entry["state"]} '
f'{entry["expires"]}' for entry in found)
return {'certificates': found}, text
with vpnrouter_pki.locked():
if options.command == 'issue':
if options.csr:
document = vpnrouter_pki.issue(cfg, options.ca, options.template,
csr=pathlib.Path(options.csr).read_text(encoding='ascii'))
return document, document['certificate'] + document['chain']
if not options.subject or not options.pfx:
raise Failure('give --csr, or --subject and --pfx', 2)
document = vpnrouter_pki.issue(cfg, options.ca, options.template, subject=options.subject,
sans=options.san or [], password=secret())
out = pathlib.Path(options.pfx)
with open(out, 'xb') as handle:
handle.write(base64.b64decode(document['pfx']))
return {'serial': document['serial'], 'pfx': str(out)}, f'Certificate {document["serial"]} written to {out}.'
if options.command == 'revoke':
document = vpnrouter_pki.revoke(cfg, options.serial, options.reason)
if document['reissue']:
restart_setup()
return document, f'Certificate {document["serial"]} revoked' + \
('; vpn-router-setup issues a new one.' if document['reissue'] else '.')
if options.command == 'maintain':
due = vpnrouter_pki.maintain(cfg)
if due:
restart_setup()
return {'renewal': due}, 'CRLs checked' + ('; vpn-router-setup renews the router certificates.' if due else '.')
if options.command == 'ca' and options.action == 'csr':
document = vpnrouter_pki.csr(cfg, options.name)
return document, document['csr'] + '\n' + document['extensions']
if options.command == 'ca':
document = vpnrouter_pki.import_ca(cfg, options.name, pathlib.Path(options.file).read_text(encoding='ascii'))
restart_setup()
return document, f'Issuing CA {options.name} has its certificate, which expires {document["expires"]}.'
if options.action == 'import-cert':
document = vpnrouter_pki.import_root_cert(pathlib.Path(options.file).read_bytes())
restart_setup()
return document, f'Root CA {document["subject"]} placed, without its key.'
if options.action == 'export-key':
data = vpnrouter_pki.export_root_key(secret())
out = pathlib.Path(options.out)
with open(out, 'xb') as handle:
handle.write(data)
return {'exported': str(out)}, f'Root key written to {out}, encrypted.'
if options.action == 'remove-key':
if not options.yes:
print('Remove the root key from the router? Keep an exported copy first. Type yes: ', end='',
file=sys.stderr, flush=True)
if secret().strip() != 'yes':
return {'removed': False}, 'Nothing removed.'
vpnrouter_pki.remove_root_key()
return {'removed': True}, 'Root key removed; new issuing CAs now wait for an offline signature.'
if options.action == 'import-key':
vpnrouter_pki.import_root_key(pathlib.Path(options.file).read_bytes(), secret() or None)
restart_setup()
return {'imported': True}, 'Root key back on the router.'
document = vpnrouter_pki.import_root_crl(pathlib.Path(options.file).read_bytes())
return document, f'Root CRL taken, valid until {document["next_update"]}.'
def main(argv):
parser = Parser(prog='vpn-router-ca', description="Manage the router's PKI.")
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
commands.add_parser('status', help='show the root, the issuing CAs and the warnings')
lister = commands.add_parser('list', help='list the certificates the router issued')
lister.add_argument('--ca', help='only those of this issuing CA')
issuer = commands.add_parser('issue', help='issue a certificate from a CSR, or with a new key into a PFX file')
issuer.add_argument('--ca', required=True, help='the issuing CA')
issuer.add_argument('--template', required=True, help='the certificate template')
issuer.add_argument('--csr', help='the PEM CSR to sign')
issuer.add_argument('--subject', help='the common name, without --csr')
issuer.add_argument('--san', action='append', help='a subject alternative name, without --csr; repeat for more')
issuer.add_argument('--pfx', help='the PFX file to write, without --csr; the password is read from standard input')
revoker = commands.add_parser('revoke', help='revoke a certificate the router issued')
revoker.add_argument('serial', help='the serial number, hexadecimal')
revoker.add_argument('--reason', choices=vpnrouter_pki.REASONS, help='the reason, unspecified by default')
commands.add_parser('maintain', help='sign the stale CRLs again')
authority = commands.add_parser('ca', help='an issuing CA that waits for the offline root')
actions = authority.add_subparsers(dest='action', required=True, parser_class=Parser)
actions.add_parser('csr', help='print its CSR and the openssl extensions').add_argument('name')
importer = actions.add_parser('import', help='take its certificate signed by the root')
importer.add_argument('name')
importer.add_argument('file')
root = commands.add_parser('root', help='the root CA')
root_actions = root.add_subparsers(dest='action', required=True, parser_class=Parser)
root_actions.add_parser('import-cert', help="place an existing root's certificate").add_argument('file')
exporter = root_actions.add_parser('export-key', help='write the root key encrypted with the passphrase from stdin')
exporter.add_argument('--out', required=True, help='the file to write')
remover = root_actions.add_parser('remove-key', help='remove the root key from the router')
remover.add_argument('--yes', action='store_true', help='do not ask')
root_actions.add_parser('import-key', help='put the root key back').add_argument('file')
root_actions.add_parser('import-crl', help='take a root CRL signed offline').add_argument('file')
options = parser.parse_args(argv)
os.umask(0o077)
try:
document, text = run(options)
except Failure as failure:
code, message = failure.status, str(failure)
except OSError as exc:
code, message = 1, str(exc)
except SystemExit as exc:
code, message = 1, str(exc.code)
else:
if options.json:
print(json.dumps(document, indent=2))
elif text is None:
show_status(document)
elif text:
print(text.rstrip('\n'))
return 0
if options.json:
print(json.dumps({'error': message}, indent=2))
else:
print(f'vpn-router-ca: {message}', file=sys.stderr)
return code
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/python3
"""Install Caddy, the router's HTTPS proxy, from the source https.caddy_source names.
status shows the configured source, the source in effect and the installed and candidate versions of Caddy. apply
installs Caddy when it is missing: from the Ubuntu archive, or with upstream from Caddy's own repository, which it
configures first (vpnrouter.CADDY_APT_SOURCE, with the key in vpnrouter.CADDY_KEYRING). When upstream is configured
and the repository is not, it configures the repository and replaces the installed Caddy with upstream's. It then
restarts vpn-router-setup without waiting, which serves with the new Caddy; vpn-router-caddy.service runs it for
vpn-router-setup. use-upstream sets https.caddy_source to upstream and applies it, printing each step.
Once the repository is configured Caddy stays upstream's: apt keeps the higher upstream version, and ubuntu has no
effect. A failure is a message on standard error and the exit status 1. -h or --help shows the commands.
vpn-router-caddy [--json] status
vpn-router-caddy apply
vpn-router-caddy use-upstream
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.request
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter
#: apt-get, waiting up to ten minutes for dpkg's lock, which a package installation that runs setup holds.
APT = ['apt-get', '-o', 'DPkg::Lock::Timeout=600']
#: The options of apt-get install: no questions, and the configuration files of the installed Caddy kept.
INSTALL = ['-y', '-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confold', 'install']
#: Seconds the key of Caddy's repository may take to download.
KEY_TIMEOUT = 60
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors end with status 2."""
def error(self, message):
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
sys.exit(2)
def step(message):
print(message, flush=True)
def installed():
"""The installed version of caddy, or None when it is not installed."""
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}\t${Version}', 'caddy'], capture_output=True, text=True)
status, _, version = result.stdout.partition('\t')
return version if result.returncode == 0 and status == 'install ok installed' else None
def candidate():
"""The version of caddy apt would install, or None when it knows none."""
result = subprocess.run(['apt-cache', 'policy', 'caddy'], capture_output=True, text=True)
for line in result.stdout.splitlines():
if line.strip().startswith('Candidate:'):
value = line.split(':', 1)[1].strip()
return None if value == '(none)' else value
return None
def effective():
"""The source Caddy comes from: upstream once its repository is configured, ubuntu otherwise."""
return 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'
def command(args):
"""Run a command with its output shown; SystemExit when it fails."""
step(f'$ {" ".join(args)}')
result = subprocess.run(args, env=dict(os.environ, DEBIAN_FRONTEND='noninteractive'))
if result.returncode != 0:
sys.exit(f'vpn-router-caddy: {args[0]} failed with status {result.returncode}')
def apply():
"""Install Caddy, or replace it with upstream's, as https.caddy_source asks (see the module)."""
source = vpnrouter.load_config()['https']['caddy_source']
repository = source == 'upstream' and not vpnrouter.CADDY_APT_SOURCE.is_file()
if installed() and not repository:
if source == 'ubuntu' and effective() == 'upstream':
step("https.caddy_source is ubuntu, but Caddy's repository is configured, so Caddy stays upstream's.")
else:
step(f'Caddy {installed()} is installed from the {source} source.')
return
if repository:
step(f"Downloading the key of Caddy's repository from {vpnrouter.CADDY_KEY_URL}")
with urllib.request.urlopen(vpnrouter.CADDY_KEY_URL, timeout=KEY_TIMEOUT) as response:
key = response.read()
if not key.lstrip().startswith(b'-----BEGIN PGP PUBLIC KEY BLOCK-----'):
sys.exit(f'vpn-router-caddy: {vpnrouter.CADDY_KEY_URL} did not answer with an OpenPGP public key')
vpnrouter.CADDY_KEYRING.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
vpnrouter.CADDY_KEYRING.write_bytes(key)
os.chmod(vpnrouter.CADDY_KEYRING, 0o644)
vpnrouter.CADDY_APT_SOURCE.write_text(
f'Types: deb\nURIs: {vpnrouter.CADDY_REPOSITORY}\nSuites: any-version\nComponents: main\n'
f'Signed-By: {vpnrouter.CADDY_KEYRING}\n', encoding='utf-8')
os.chmod(vpnrouter.CADDY_APT_SOURCE, 0o644)
step(f"Caddy's repository is configured in {vpnrouter.CADDY_APT_SOURCE}.")
command([*APT, 'update'])
command([*APT, *INSTALL, 'caddy'])
step(f'Caddy {installed()} is installed from the {effective()} source.')
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True)
step('vpn-router-setup restarted.')
def main(argv):
parser = Parser(prog='vpn-router-caddy', description='Install Caddy from the source https.caddy_source names.')
parser.add_argument('--json', action='store_true', help='answer status in JSON')
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
commands.add_parser('status', help='show the sources and the versions of Caddy')
commands.add_parser('apply', help='install Caddy, or replace it with upstream\'s, as https.caddy_source asks')
commands.add_parser('use-upstream', help="switch to Caddy's own repository and replace Caddy with upstream's")
options = parser.parse_args(argv)
try:
if options.command == 'status':
document = {'configured': vpnrouter.load_config()['https']['caddy_source'], 'source': effective(),
'installed': installed(), 'candidate': candidate()}
if options.json:
print(json.dumps(document, indent=2))
else:
print(f'Configured source: {document["configured"]}\nSource in effect: {document["source"]}\n'
f'Installed: {document["installed"] or "no"}\nCandidate: {document["candidate"] or "none"}')
return 0
if options.command == 'use-upstream':
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get('https')
data['https'] = {**(current if isinstance(current, dict) else {}), 'caddy_source': 'upstream'}
vpnrouter.validate(vpnrouter.with_defaults(data))
vpnrouter.save_config(data)
step('https.caddy_source is upstream.')
apply()
except vpnrouter.ConfigError as exc:
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
return 1
except OSError as exc:
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
@@ -22,14 +22,23 @@
"cidrs": ["192.168.0.0/24"],
"local_cidrs": ["10.0.0.0/24"],
"local_id_mode": "fqdn",
"auth": "psk",
"psk": "change-me",
"psk_file": ""
"psk_file": "",
"remote_ca": ""
},
"branch": {
"addrs": ["branch.example.org"],
"id": "branch.example.org",
"cidrs": ["192.168.10.0/24"],
"local_cidrs": ["10.0.0.0/24"],
"local_id_mode": "fqdn",
"auth": "certificate"
}
},
"p2s": {
"enabled": false,
"address_pool": "172.16.0.0/24",
"ca_name": "VPN Router CA"
"address_pool": "172.16.0.0/24"
},
"wireguard": {
"enabled": false,
@@ -58,7 +67,31 @@
"enabled": true,
"email": "",
"web_port": 7444,
"manager_port": 7443
"manager_port": 7443,
"caddy_source": "ubuntu"
},
"pki": {
"root_name": "VPN Router Root CA",
"root_days": 7300,
"ca_days": 3650,
"key": "rsa-4096",
"crl_days": 7,
"root_crl_days": 180,
"publish": ["http"],
"p2s_name": "VPN Router P2S CA",
"s2s_name": "VPN Router Site-to-Site CA",
"issuing_cas": {
"servers": {
"name": "VPN Router Server CA",
"templates": ["server"],
"acme": false
}
},
"templates": {
"server": {
"days": 397
}
}
},
"metrics": {
"enabled": true,
@@ -4,10 +4,69 @@
{%- if https_email %}
email {{ https_email }}
{%- endif %}
{%- if acme_cas %}
skip_install_trust
pki {
{%- for ca in acme_cas %}
ca {{ ca.id }} {
name "{{ ca.name }}"
root {
cert {{ ca.cert }}
key {{ ca.key }}
}
intermediate {
cert {{ ca.cert }}
key {{ ca.key }}
}
}
{%- endfor %}
}
{%- endif %}
}
{% if web_enabled %}
{% if web_enabled or 'https' in pki_publish or acme_cas %}
{{ local_fqdn }}:{{ https_web_port }} {
reverse_proxy {{ loopback }}:{{ web_port }}
{%- if 'https' in pki_publish %}
handle_path /pki/* {
root * {{ pki_publish_dir }}
file_server
}
{%- endif %}
{%- for ca in acme_cas %}
handle /acme/{{ ca.id }}/* {
acme_server {
ca {{ ca.id }}
}
}
{%- endfor %}
handle {
{%- if web_enabled %}
reverse_proxy {{ loopback }}:{{ web_port }}
{%- else %}
respond 404
{%- endif %}
}
}
{% endif %}
{%- if 'http-80' in pki_publish %}
http://{{ local_fqdn }} {
handle_path /pki/* {
root * {{ pki_publish_dir }}
file_server
}
handle {
respond 404
}
}
{% endif %}
{%- if 'https-default' in pki_publish %}
{{ local_fqdn }}:443 {
handle_path /pki/* {
root * {{ pki_publish_dir }}
file_server
}
handle {
respond 404
}
}
{% endif %}
{%- if manager_port %}
@@ -5,15 +5,16 @@ dev-type tun
topology subnet
server {{ openvpn_network }} {{ openvpn_netmask }}
ca {{ ca_cert }}
ca {{ openvpn_ca }}
cert {{ server_cert }}
key {{ server_key }}
crl-verify {{ crl }}
crl-verify {{ openvpn_crl }}
dh none
remote-cert-tls client
plugin /usr/lib/openvpn/openvpn-auth-ldap.so {{ openvpn_ldap_conf }}
script-security 2
tls-verify /usr/lib/vpn-router/openvpn-issuer
auth-user-pass-verify /usr/lib/vpn-router/openvpn-same-user via-env
{% for network, netmask in openvpn_routes -%}
@@ -8,13 +8,13 @@ connections {
local {
auth = pubkey
certs = server.pem
certs = vpn-server.pem
id = @{{ local_fqdn }}
}
remote {
auth = eap-tls
cacerts = ca.pem
cacerts = p2s.pem
eap_id = %any
}
@@ -11,12 +11,22 @@ connections {
local {
id = {{ connection.local_id }}
{%- if connection.auth == 'certificate' %}
auth = pubkey
certs = site-to-site.pem
{%- else %}
auth = psk
{%- endif %}
}
remote {
id = {{ connection.remote_id }}
{%- if connection.auth == 'certificate' %}
auth = pubkey
cacerts = {{ connection.remote_cacert }}
{%- else %}
auth = psk
{%- endif %}
}
children {
@@ -40,7 +50,7 @@ connections {
}
secrets {
{%- for connection in connections if connection.ready %}
{%- for connection in connections if connection.ready and connection.auth == 'psk' %}
ike-psk-{{ connection.name }} {
id-1 = {{ connection.local_id }}
id-2 = {{ connection.remote_id }}