This commit is contained in:
1 parent
609921348b
commit
cc6748a622
17 files changed
+1011
-63
No files matched your search
@@ -1,3 +1,28 @@
|
||||
vpn-router (0.5.0-1) unstable; urgency=medium
|
||||
|
||||
* OpenVPN P2S access, set in the openvpn section (enabled, address_pool,
|
||||
port, protocol). Clients need a certificate of the router CA and the
|
||||
password of the user named in it; the password is checked against a
|
||||
local LDAP directory by openvpn-auth-ldap.
|
||||
* The users live in the package's own slapd database, dc=vpn-router,
|
||||
dc=local, which vpn-router-setup provisions while OpenVPN or road-warrior
|
||||
access is configured. slapd listens on its local socket only. Passwords
|
||||
are stored as ARGON2 hashes, and a disabled user is locked with the
|
||||
password policy.
|
||||
* vpn-router-user lists, adds, disables, enables and removes users, sets
|
||||
passwords and gives OpenVPN client profiles. Adding a user also issues
|
||||
the user's client certificate; removing one revokes it.
|
||||
* The web service manages users under /users, with the same token as
|
||||
/config, and /status shows the OpenVPN clients.
|
||||
* The CA gets a certificate revocation list when it is created, and an
|
||||
existing CA without one gets one.
|
||||
* Purging the package removes the firewall rules of the web service, which
|
||||
it left behind before.
|
||||
* The status page follows the browser's light or dark colour scheme,
|
||||
uses a proportional font and reloads itself every minute.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 23:00:00 +0200
|
||||
|
||||
vpn-router (0.4.3-1) unstable; urgency=medium
|
||||
|
||||
* swanctl output with more closing than opening lines is read as not
|
||||
|
||||
@@ -20,13 +20,19 @@ Depends: ${misc:Depends},
|
||||
openssl,
|
||||
python3,
|
||||
python3-jinja2,
|
||||
python3-ldap,
|
||||
slapd,
|
||||
openvpn,
|
||||
openvpn-auth-ldap,
|
||||
iproute2,
|
||||
systemd-resolved
|
||||
Description: Linux VPN router with IPSec, road-warrior and WireGuard support
|
||||
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 WireGuard, and the routing, NAT, MSS clamping and firewall rules a
|
||||
router needs. Nothing in the package is specific to a cloud provider.
|
||||
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. 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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
|
||||
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
||||
src/usr/sbin/vpn-router-user usr/sbin/
|
||||
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||
|
||||
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk '
|
||||
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||
skip = 1; pending = 0; next
|
||||
}
|
||||
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||
skip = 0; next
|
||||
}
|
||||
skip { next }
|
||||
@@ -47,10 +47,13 @@ case "$1" in
|
||||
/etc/swanctl/conf.d/road-warrior.conf \
|
||||
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||
/etc/wireguard/wg0.conf \
|
||||
/etc/openvpn/server/vpn-router.conf \
|
||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||
/etc/vpn-router/vpn-router.json
|
||||
|
||||
# Key material is not package state: /etc/vpn-router/pki, the WireGuard
|
||||
# key pair and everything under /etc/swanctl are left in place.
|
||||
# Key material and users are not package state: /etc/vpn-router/pki,
|
||||
# /etc/vpn-router/ldap, the user directory in slapd, the WireGuard key
|
||||
# pair and everything under /etc/swanctl are left in place.
|
||||
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -11,15 +11,17 @@ case "$1" in
|
||||
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# This package enabled wg-quick@wg0, so it takes it down again. Use the
|
||||
# debhelper wrappers rather than systemctl, so the script behaves on a
|
||||
# machine without systemd.
|
||||
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||
deb-systemd-invoke stop wg-quick@wg0.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||
deb-systemd-helper disable wg-quick@wg0.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
# This package enabled wg-quick@wg0 and openvpn-server@vpn-router, so
|
||||
# it 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; do
|
||||
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||
deb-systemd-helper disable "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/run/vpn-router /etc/vpn-router/pki /etc/swanctl/x509crl
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/python3
|
||||
"""Accept an OpenVPN login only when the user name is the common name of the client certificate.
|
||||
|
||||
Run by the OpenVPN server as auth-user-pass-verify with via-env, next to the LDAP plugin that
|
||||
checks the password, so a user's certificate cannot be used with another user's password.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
username = os.environ.get('username', '')
|
||||
sys.exit(0 if username and username == os.environ.get('common_name') else 1)
|
||||
@@ -20,7 +20,10 @@ import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_ldap
|
||||
from vpnrouter import fail
|
||||
|
||||
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
||||
@@ -36,6 +39,7 @@ SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
|
||||
BLOCKS = (
|
||||
('# IPSEC RULES START', '# IPSEC RULES END'),
|
||||
('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
|
||||
('# OPENVPN RULES START', '# OPENVPN RULES END'),
|
||||
('# P2S DNS RULES START', '# P2S DNS RULES END'),
|
||||
('# WEB RULES START', '# WEB RULES END'),
|
||||
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||
@@ -121,7 +125,8 @@ def is_configured(ctx):
|
||||
a host that was working before.
|
||||
"""
|
||||
return any((vpnrouter.s2s_ready(ctx), vpnrouter.routing_ready(ctx),
|
||||
vpnrouter.p2s_ready(ctx), vpnrouter.wg_ready(ctx)))
|
||||
vpnrouter.p2s_ready(ctx), vpnrouter.wg_ready(ctx),
|
||||
vpnrouter.openvpn_ready(ctx)))
|
||||
|
||||
|
||||
def filter_block(ctx):
|
||||
@@ -146,14 +151,30 @@ def filter_block(ctx):
|
||||
'# WIREGUARD RULES END',
|
||||
]
|
||||
|
||||
if vpnrouter.p2s_ready(ctx) and ctx['local_addr_list']:
|
||||
pool = ctx['p2s_address_pool']
|
||||
lines += ['', '# P2S DNS RULES START']
|
||||
for address in ctx['local_addr_list']:
|
||||
if vpnrouter.openvpn_ready(ctx):
|
||||
device = ctx['openvpn_device']
|
||||
lines += [
|
||||
'',
|
||||
'# OPENVPN RULES START',
|
||||
f'-A ufw-before-input -i {ctx["wan_iface"]} -p {ctx["openvpn_protocol"]} --dport {ctx["openvpn_port"]} -j ACCEPT',
|
||||
]
|
||||
for subnet in ctx['local_cidr_list']:
|
||||
lines += [
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
|
||||
f'-A ufw-before-forward -i {device} -d {subnet} -j ACCEPT',
|
||||
f'-A ufw-before-forward -o {device} -s {subnet} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT',
|
||||
]
|
||||
lines += ['# OPENVPN RULES END']
|
||||
|
||||
pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']),
|
||||
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool'])) if ready]
|
||||
if pools and ctx['local_addr_list']:
|
||||
lines += ['', '# P2S DNS RULES START']
|
||||
for pool in pools:
|
||||
for address in ctx['local_addr_list']:
|
||||
lines += [
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
|
||||
]
|
||||
lines += ['# P2S DNS RULES END']
|
||||
|
||||
if ctx['web_enabled'] and ctx['int_iface']:
|
||||
@@ -302,6 +323,36 @@ def manage_wireguard(ctx, changed):
|
||||
run(['systemctl', 'restart', unit], check=False)
|
||||
|
||||
|
||||
def manage_directory(ctx):
|
||||
"""Provision the user directory in slapd while a P2S system that has users is configured.
|
||||
|
||||
The directory is left in place when they are switched off, so the users are kept.
|
||||
"""
|
||||
if not (vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)):
|
||||
return
|
||||
if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'):
|
||||
run(['systemctl', 'restart', 'slapd'])
|
||||
try:
|
||||
vpnrouter_ldap.provision()
|
||||
except ldap.LDAPError as exc:
|
||||
fail(f'the user directory cannot be provisioned: {exc}')
|
||||
|
||||
|
||||
def manage_openvpn(ctx, changed):
|
||||
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
||||
unit = 'openvpn-server@vpn-router'
|
||||
|
||||
if not vpnrouter.openvpn_ready(ctx):
|
||||
if unit_is_active(unit):
|
||||
run(['systemctl', 'disable', '--now', unit], check=False)
|
||||
return
|
||||
|
||||
if not unit_is_active(unit):
|
||||
run(['systemctl', 'enable', '--now', unit], check=False)
|
||||
elif 'openvpn' in changed or 'swanctl' in changed:
|
||||
run(['systemctl', 'restart', unit], check=False)
|
||||
|
||||
|
||||
def run_platform_setup(cfg, ctx):
|
||||
"""Let the platform module apply its own state, if it defines any."""
|
||||
module = vpnrouter.platform_module(cfg)
|
||||
@@ -353,6 +404,8 @@ def refresh_web(cfg, ctx):
|
||||
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
|
||||
'p2s': bool(vpnrouter.p2s_ready(ctx)),
|
||||
'wireguard': bool(vpnrouter.wg_ready(ctx)),
|
||||
'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
|
||||
'users': bool(vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)),
|
||||
'configuration': masked,
|
||||
'network': {
|
||||
'external_interface': ctx['wan_iface'],
|
||||
@@ -400,8 +453,10 @@ def main():
|
||||
elif rules_changed:
|
||||
run(['ufw', 'reload'], check=False)
|
||||
|
||||
manage_directory(ctx)
|
||||
manage_strongswan(ctx, changed)
|
||||
manage_wireguard(ctx, changed)
|
||||
manage_openvpn(ctx, changed)
|
||||
|
||||
if 'resolved' in changed:
|
||||
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
|
||||
|
||||
@@ -19,6 +19,7 @@ LIB_DIR = pathlib.Path(__file__).resolve().parent
|
||||
if str(LIB_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(LIB_DIR))
|
||||
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_platforms
|
||||
|
||||
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
||||
@@ -31,6 +32,9 @@ REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf'
|
||||
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
||||
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
||||
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
|
||||
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')
|
||||
@@ -47,6 +51,7 @@ SCHEMA = {
|
||||
'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''},
|
||||
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''},
|
||||
}
|
||||
@@ -56,6 +61,14 @@ WEB_SECRET_MIN = 32
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
|
||||
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})?$')
|
||||
|
||||
@@ -209,6 +222,13 @@ def validate(cfg):
|
||||
_check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs')
|
||||
pool = _get(cfg, 'p2s', 'address_pool')
|
||||
_check_networks([pool] if pool else [], 'p2s.address_pool')
|
||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||
if openvpn_pool:
|
||||
try:
|
||||
if ipaddress.ip_network(openvpn_pool).version != 4:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network')
|
||||
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
|
||||
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
|
||||
|
||||
@@ -232,6 +252,15 @@ def validate(cfg):
|
||||
if not 1 <= port <= 65535:
|
||||
raise ConfigError(f'wireguard.listen_port: {port} is not a valid port')
|
||||
|
||||
openvpn_port = _get(cfg, 'openvpn', 'port')
|
||||
if not 1 <= openvpn_port <= 65535:
|
||||
raise ConfigError(f'openvpn.port: {openvpn_port} is not a valid port')
|
||||
|
||||
protocol = _get(cfg, 'openvpn', 'protocol')
|
||||
if protocol not in OPENVPN_PROTOCOLS:
|
||||
raise ConfigError(
|
||||
f'openvpn.protocol: {protocol!r} is not one of {", ".join(OPENVPN_PROTOCOLS)}')
|
||||
|
||||
web_port = _get(cfg, 'web', 'port')
|
||||
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
|
||||
raise ConfigError(f'web.port: {web_port!r} is not a valid port')
|
||||
@@ -438,6 +467,8 @@ def build_context(cfg, local_id_override=None):
|
||||
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
|
||||
local_cidrs = _get_list(cfg, 'local', 'cidrs')
|
||||
remote_cidrs = _get_list(cfg, 'remote', 'cidrs')
|
||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||
openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None
|
||||
|
||||
if local_id_override:
|
||||
local_id = local_id_override
|
||||
@@ -477,6 +508,23 @@ def build_context(cfg, local_id_override=None):
|
||||
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
|
||||
'wg_address': _get(cfg, 'wireguard', 'address'),
|
||||
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
|
||||
'openvpn_enabled': _get(cfg, 'openvpn', 'enabled'),
|
||||
'openvpn_pool': openvpn_pool,
|
||||
'openvpn_network': str(openvpn_network.network_address) if openvpn_network else '',
|
||||
'openvpn_netmask': str(openvpn_network.netmask) if openvpn_network else '',
|
||||
'openvpn_routes': [(str(network.network_address), str(network.netmask))
|
||||
for network in (ipaddress.ip_network(cidr, strict=False) for cidr in local_cidrs)],
|
||||
'openvpn_port': _get(cfg, 'openvpn', 'port'),
|
||||
'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'),
|
||||
'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),
|
||||
'ldap_uri': vpnrouter_ldap.URI,
|
||||
'ldap_users': vpnrouter_ldap.USERS,
|
||||
'web_enabled': _get(cfg, 'web', 'enabled'),
|
||||
'web_port': _get(cfg, 'web', 'port'),
|
||||
}
|
||||
@@ -504,6 +552,10 @@ def wg_ready(ctx):
|
||||
return bool(ctx['wg_enabled'] and ctx['wg_address'])
|
||||
|
||||
|
||||
def openvpn_ready(ctx):
|
||||
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'])
|
||||
|
||||
|
||||
def _jinja_env():
|
||||
return jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
@@ -593,11 +645,13 @@ def _simple_ca(*args):
|
||||
|
||||
|
||||
def provision_pki(ctx):
|
||||
"""Create the CA and server certificate when the PKI directory is empty."""
|
||||
"""Create the CA, server certificate and CRL when the PKI directory is empty; a CA without a CRL gets one."""
|
||||
PKI_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(PKI_DIR, 0o700)
|
||||
|
||||
if any(PKI_DIR.iterdir()):
|
||||
if (PKI_DIR / 'ca_key.pem').is_file() and not (PKI_DIR / 'crl.pem').is_file():
|
||||
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
|
||||
return
|
||||
if not ctx['p2s_ca_name']:
|
||||
warn('p2s.ca_name is empty, not creating a local CA')
|
||||
@@ -608,6 +662,7 @@ def provision_pki(ctx):
|
||||
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
|
||||
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR),
|
||||
ctx['local_fqdn'], ctx['local_fqdn'])
|
||||
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
|
||||
|
||||
|
||||
def distribute_pki(ctx):
|
||||
@@ -635,7 +690,7 @@ def configure(cfg=None, local_id_override=None):
|
||||
"""Render configuration files and provision PKI.
|
||||
|
||||
Returns (ctx, changed) where changed is a set naming what needs reloading:
|
||||
'swanctl', 'resolved', 'wireguard'.
|
||||
'swanctl', 'resolved', 'wireguard', 'openvpn'.
|
||||
"""
|
||||
if cfg is None:
|
||||
cfg = load_config()
|
||||
@@ -653,7 +708,7 @@ def configure(cfg=None, local_id_override=None):
|
||||
|
||||
changed = set()
|
||||
|
||||
if p2s_ready(ctx):
|
||||
if p2s_ready(ctx) or openvpn_ready(ctx):
|
||||
provision_pki(ctx)
|
||||
if distribute_pki(ctx):
|
||||
changed.add('swanctl')
|
||||
@@ -669,13 +724,14 @@ def configure(cfg=None, local_id_override=None):
|
||||
if p2s_ready(ctx):
|
||||
if render(env, ctx, 'road-warrior.conf.j2', ROAD_WARRIOR, 0o600):
|
||||
changed.add('swanctl')
|
||||
elif remove(ROAD_WARRIOR):
|
||||
changed.add('swanctl')
|
||||
|
||||
if p2s_ready(ctx) or openvpn_ready(ctx):
|
||||
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
|
||||
changed.add('resolved')
|
||||
else:
|
||||
if remove(ROAD_WARRIOR):
|
||||
changed.add('swanctl')
|
||||
if remove(P2S_FORWARDER):
|
||||
changed.add('resolved')
|
||||
elif remove(P2S_FORWARDER):
|
||||
changed.add('resolved')
|
||||
|
||||
if wg_ready(ctx):
|
||||
if render_wg_conf(env, ctx):
|
||||
@@ -683,4 +739,14 @@ def configure(cfg=None, local_id_override=None):
|
||||
elif remove(WG_CONF):
|
||||
changed.add('wireguard')
|
||||
|
||||
if openvpn_ready(ctx):
|
||||
if render(env, ctx, 'openvpn-server.conf.j2', OPENVPN_CONF, 0o600):
|
||||
changed.add('openvpn')
|
||||
if render(env, ctx, 'openvpn-ldap.conf.j2', OPENVPN_LDAP, 0o600):
|
||||
changed.add('openvpn')
|
||||
else:
|
||||
for path in (OPENVPN_CONF, OPENVPN_LDAP):
|
||||
if remove(path):
|
||||
changed.add('openvpn')
|
||||
|
||||
return ctx, changed
|
||||
@@ -0,0 +1,182 @@
|
||||
"""The router's user directory in the local slapd.
|
||||
|
||||
The package keeps its own database, dc=vpn-router,dc=local in /var/lib/ldap/vpn-router,
|
||||
next to the one slapd's installation creates. Root manages it through the local socket
|
||||
with SASL EXTERNAL: the database's root DN is root's peer-credential DN, so there is no
|
||||
administrator password. Users are changed by the user-manager service account, which the
|
||||
ACLs allow to write ou=users and to set, never read, passwords.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import pwd
|
||||
import re
|
||||
import secrets
|
||||
|
||||
import ldap
|
||||
import ldap.modlist
|
||||
import ldap.sasl
|
||||
|
||||
URI = 'ldapi:///'
|
||||
BASE = 'dc=vpn-router,dc=local'
|
||||
USERS = f'ou=users,{BASE}'
|
||||
SERVICES = f'ou=services,{BASE}'
|
||||
POLICIES = f'ou=policies,{BASE}'
|
||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||
ROOT = 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth'
|
||||
|
||||
DB_DIRECTORY = pathlib.Path('/var/lib/ldap/vpn-router')
|
||||
SECRET_DIR = pathlib.Path('/etc/vpn-router/ldap')
|
||||
MANAGER_SECRET = SECRET_DIR / 'user-manager.secret'
|
||||
SLAPD_DEFAULTS = pathlib.Path('/etc/default/slapd')
|
||||
SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
|
||||
|
||||
MODULES = ('argon2', 'ppolicy')
|
||||
PASSWORD_HASH = '{ARGON2}'
|
||||
|
||||
#: The ACLs of the database, in order.
|
||||
ACCESS = (
|
||||
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
|
||||
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
'to * by * read',
|
||||
)
|
||||
|
||||
#: The index of an ordered cn=config value: {0}, {1}, ...
|
||||
ORDER = re.compile(rb'^\{\d+\}')
|
||||
|
||||
|
||||
def unordered(values):
|
||||
"""Values of an ordered cn=config attribute without their {n} prefixes."""
|
||||
return [ORDER.sub(b'', value).decode() for value in values]
|
||||
|
||||
|
||||
def root():
|
||||
"""A connection to slapd as root, through the local socket."""
|
||||
connection = ldap.initialize(URI)
|
||||
connection.sasl_interactive_bind_s('', ldap.sasl.external())
|
||||
return connection
|
||||
|
||||
|
||||
def manager():
|
||||
"""A connection to slapd as the user-manager service account."""
|
||||
connection = ldap.initialize(URI)
|
||||
connection.simple_bind_s(MANAGER, MANAGER_SECRET.read_text(encoding='utf-8'))
|
||||
return connection
|
||||
|
||||
|
||||
def ensure_services():
|
||||
"""Make slapd listen on the local socket only. Returns True when the setting changed."""
|
||||
text = SLAPD_DEFAULTS.read_text(encoding='utf-8')
|
||||
lines = text.splitlines(keepends=True)
|
||||
updated = ''.join(SLAPD_SERVICES + '\n' if line.startswith('SLAPD_SERVICES=') else line for line in lines)
|
||||
if not any(line.startswith('SLAPD_SERVICES=') for line in lines):
|
||||
updated = text.rstrip('\n') + '\n' + SLAPD_SERVICES + '\n'
|
||||
if updated == text:
|
||||
return False
|
||||
SLAPD_DEFAULTS.write_text(updated, encoding='utf-8')
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_modules(connection):
|
||||
dn, attributes = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, '(objectClass=olcModuleList)', ['olcModuleLoad'])[0]
|
||||
loaded = unordered(attributes.get('olcModuleLoad', []))
|
||||
missing = [name for name in MODULES if name not in loaded and f'{name}.la' not in loaded]
|
||||
if missing:
|
||||
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])])
|
||||
|
||||
|
||||
def _ensure_password_hash(connection):
|
||||
dn = 'olcDatabase={-1}frontend,cn=config'
|
||||
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', [])
|
||||
if current != [PASSWORD_HASH.encode()]:
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcPasswordHash', [PASSWORD_HASH.encode()])])
|
||||
|
||||
|
||||
def _ensure_database(connection):
|
||||
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS."""
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
if not found:
|
||||
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
openldap = pwd.getpwnam('openldap')
|
||||
os.chown(DB_DIRECTORY, openldap.pw_uid, openldap.pw_gid)
|
||||
connection.add_s('olcDatabase=mdb,cn=config', ldap.modlist.addModlist({
|
||||
'objectClass': [b'olcDatabaseConfig', b'olcMdbConfig'],
|
||||
'olcDatabase': [b'mdb'],
|
||||
'olcSuffix': [BASE.encode()],
|
||||
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
|
||||
'olcRootDN': [ROOT.encode()],
|
||||
'olcDbIndex': [b'objectClass eq', b'uid eq'],
|
||||
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
|
||||
}))
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
dn, attributes = found[0]
|
||||
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
|
||||
return dn
|
||||
|
||||
|
||||
def _ensure_overlay(connection, database):
|
||||
if not connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcPPolicyConfig)', ['1.1']):
|
||||
connection.add_s(f'olcOverlay=ppolicy,{database}', ldap.modlist.addModlist({
|
||||
'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'],
|
||||
'olcOverlay': [b'ppolicy'],
|
||||
'olcPPolicyDefault': [DEFAULT_POLICY.encode()],
|
||||
}))
|
||||
|
||||
|
||||
def _ensure_entry(connection, dn, attributes):
|
||||
"""Add the entry when it is missing, else set the given attributes that differ."""
|
||||
try:
|
||||
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=list(attributes))[0][1]
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
connection.add_s(dn, ldap.modlist.addModlist(attributes))
|
||||
return
|
||||
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
|
||||
if name != 'objectClass' and sorted(current.get(name, [])) != sorted(values)]
|
||||
if changes:
|
||||
connection.modify_s(dn, changes)
|
||||
|
||||
|
||||
def _ensure_manager(connection):
|
||||
SECRET_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(SECRET_DIR, 0o700)
|
||||
created = False
|
||||
try:
|
||||
connection.search_s(MANAGER, ldap.SCOPE_BASE, attrlist=['1.1'])
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
connection.add_s(MANAGER, ldap.modlist.addModlist({
|
||||
'objectClass': [b'organizationalRole', b'simpleSecurityObject'],
|
||||
'cn': [b'user-manager'],
|
||||
'userPassword': [b'{CRYPT}!'],
|
||||
}))
|
||||
created = True
|
||||
if created or not MANAGER_SECRET.is_file():
|
||||
password = secrets.token_urlsafe(32)
|
||||
connection.passwd_s(MANAGER, None, password)
|
||||
MANAGER_SECRET.touch(mode=0o600)
|
||||
MANAGER_SECRET.chmod(0o600)
|
||||
MANAGER_SECRET.write_text(password, encoding='utf-8')
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create or bring up to date the modules, database, ACLs, password policy, entries and service account."""
|
||||
connection = root()
|
||||
try:
|
||||
_ensure_modules(connection)
|
||||
_ensure_password_hash(connection)
|
||||
database = _ensure_database(connection)
|
||||
_ensure_overlay(connection, database)
|
||||
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
||||
for unit in (USERS, SERVICES, POLICIES):
|
||||
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
|
||||
_ensure_entry(connection, DEFAULT_POLICY, {
|
||||
'objectClass': [b'organizationalRole', b'pwdPolicy'],
|
||||
'cn': [b'default'],
|
||||
'pwdAttribute': [b'userPassword'],
|
||||
'pwdLockout': [b'TRUE'],
|
||||
'pwdMaxFailure': [b'0'],
|
||||
})
|
||||
_ensure_manager(connection)
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
@@ -1,19 +1,24 @@
|
||||
#!/usr/bin/python3
|
||||
"""Serve the status of the router on its internal address.
|
||||
|
||||
Run by vpn-router-web.service. It needs nothing but the Python standard library.
|
||||
Run by vpn-router-web.service. It needs nothing but the Python standard library; users are
|
||||
managed by running vpn-router-user.
|
||||
|
||||
Pages:
|
||||
/ the status for people: plain text for curl and wget, a styled page
|
||||
(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; needs an HS256 JWT in an Authorization: Bearer header
|
||||
that is signed with web.jwt_secret and expires within ten minutes; not
|
||||
served (404) while web.jwt_secret is not set
|
||||
runs, as JSON
|
||||
/users the users of the P2S systems: list, add, set the password, disable, enable,
|
||||
remove, and the OpenVPN client profile (see users())
|
||||
|
||||
/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed
|
||||
with web.jwt_secret and expires within ten minutes; they are not served (404) while
|
||||
web.jwt_secret is not set.
|
||||
|
||||
/ and /status show only the status: whether the services run, the package version, whether
|
||||
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S) and
|
||||
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
|
||||
WireGuard. No configuration data, key or address is shown.
|
||||
|
||||
The address to bind to, the port, the secret and the configuration shown by /config
|
||||
@@ -49,6 +54,21 @@ MAX_LIFETIME = 600
|
||||
|
||||
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
|
||||
|
||||
#: Seconds after which the status page in a browser reloads itself.
|
||||
REFRESH_SECONDS = 60
|
||||
|
||||
USER_TOOL = '/usr/sbin/vpn-router-user'
|
||||
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
|
||||
|
||||
#: Seconds a user command may take; adding a user creates an RSA key.
|
||||
USER_TIMEOUT = 120
|
||||
|
||||
#: The largest request body accepted, in bytes.
|
||||
BODY_LIMIT = 4096
|
||||
|
||||
#: The HTTP status for each exit status of vpn-router-user; any other is 500.
|
||||
USER_STATUS = {0: 200, 2: 400, 3: 404, 4: 409}
|
||||
|
||||
_cache = {'at': 0.0, 'data': None}
|
||||
_lock = threading.Lock()
|
||||
|
||||
@@ -153,6 +173,15 @@ def wireguard_peers():
|
||||
return peers
|
||||
|
||||
|
||||
def openvpn_clients():
|
||||
"""The number of connected OpenVPN clients, from the server's status file, or None when it cannot be read."""
|
||||
try:
|
||||
with open(OPENVPN_STATUS, encoding='utf-8') as handle:
|
||||
return sum(1 for line in handle if line.startswith('CLIENT_LIST,'))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def collect(state):
|
||||
"""The status document. state says which features the router has."""
|
||||
units = {'vpn-router-setup': unit_state('vpn-router-setup.service')}
|
||||
@@ -160,6 +189,10 @@ def collect(state):
|
||||
units['strongswan'] = unit_state('strongswan.service')
|
||||
if state.get('wireguard'):
|
||||
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
|
||||
if state.get('openvpn'):
|
||||
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
|
||||
if state.get('users'):
|
||||
units['slapd'] = unit_state('slapd.service')
|
||||
|
||||
status = {
|
||||
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
|
||||
@@ -175,6 +208,8 @@ def collect(state):
|
||||
status['p2s'] = {'clients': established('road-warrior')}
|
||||
if state.get('wireguard'):
|
||||
status['wireguard'] = {'peers': wireguard_peers()}
|
||||
if state.get('openvpn'):
|
||||
status['openvpn'] = {'clients': openvpn_clients()}
|
||||
return status
|
||||
|
||||
|
||||
@@ -278,6 +313,9 @@ def text_of(status):
|
||||
if 'p2s' in status:
|
||||
clients = status['p2s']['clients']
|
||||
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'openvpn' in status:
|
||||
clients = status['openvpn']['clients']
|
||||
lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'wireguard' in status:
|
||||
peers = status['wireguard']['peers']
|
||||
if peers is None:
|
||||
@@ -293,25 +331,39 @@ def text_of(status):
|
||||
|
||||
def html_of(status):
|
||||
"""The status for a browser, as a page styled with Tailwind CSS."""
|
||||
colour = 'bg-emerald-600' if status['status'] == 'ok' else 'bg-amber-600'
|
||||
body = ''.join(f'<li>{escape(line)}</li>' for line in text_of(status).splitlines()[4:] if line)
|
||||
colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700'
|
||||
# Each "name: value" line of the text is a row; indented lines, such as WireGuard peers, are indented here too.
|
||||
rows = []
|
||||
for line in text_of(status).splitlines()[4:]:
|
||||
if not line:
|
||||
continue
|
||||
name, _, value = line.partition(': ')
|
||||
indent = ' pl-8' if line.startswith(' ') else ''
|
||||
rows.append(f'<tr class="border-t border-slate-200 first:border-t-0 dark:border-slate-700">'
|
||||
f'<th class="py-2 pr-4{indent} text-left font-medium text-slate-600 dark:text-slate-300">{escape(name.strip())}</th>'
|
||||
f'<td class="py-2">{escape(value)}</td></tr>')
|
||||
body = ''.join(rows)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta http-equiv="refresh" content="{REFRESH_SECONDS}">
|
||||
<title>VPN Router</title>
|
||||
<script src="{TAILWIND}"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-900">
|
||||
<body class="min-h-screen bg-slate-100 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div class="mx-auto max-w-2xl px-4 py-10">
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg">
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="{colour} px-6 py-4 text-white">
|
||||
<h1 class="text-xl font-semibold">VPN Router</h1>
|
||||
<p class="mt-1 text-sm">Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}</p>
|
||||
</div>
|
||||
<ul class="space-y-2 px-6 py-6 font-mono text-sm">{body}</ul>
|
||||
<p class="px-6 pb-6 text-xs text-slate-500">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
|
||||
<div class="px-6 py-4">
|
||||
<table class="w-full text-sm"><tbody>{body}</tbody></table>
|
||||
</div>
|
||||
<p class="px-6 pb-6 text-xs text-slate-500 dark:text-slate-400">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -319,6 +371,62 @@ def html_of(status):
|
||||
"""
|
||||
|
||||
|
||||
def user_tool(*args, password=None):
|
||||
"""Run vpn-router-user; the HTTP status for its exit status and its JSON answer."""
|
||||
try:
|
||||
result = subprocess.run([USER_TOOL, *args], input='' if password is None else password + '\n',
|
||||
capture_output=True, text=True, timeout=USER_TIMEOUT)
|
||||
document = json.loads(result.stdout)
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
||||
return 500, {'error': f'vpn-router-user failed: {exc}'}
|
||||
if not isinstance(document, dict):
|
||||
return 500, {'error': 'vpn-router-user gave no object'}
|
||||
return USER_STATUS.get(result.returncode, 500), document
|
||||
|
||||
|
||||
def users(state, method, path, body):
|
||||
"""Answer a /users request: the HTTP status and the JSON document.
|
||||
|
||||
GET /users the users
|
||||
POST /users add a user: {"name": ..., "password": ...}
|
||||
PUT /users/<name>/password set the password: {"password": ...}
|
||||
POST /users/<name>/disable lock the user out of OpenVPN
|
||||
POST /users/<name>/enable let the user in again
|
||||
DELETE /users/<name> remove the user and revoke the certificate
|
||||
GET /users/<name>/profile the OpenVPN client profile
|
||||
"""
|
||||
if not state.get('users'):
|
||||
return 404, {'error': 'no P2S system with users is configured'}
|
||||
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 {}
|
||||
password = body.get('password')
|
||||
if password is not None and (not isinstance(password, str) or '\n' in password or '\r' in password):
|
||||
return 400, {'error': 'the password must be a string of one line'}
|
||||
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
if route == ('GET', 1, None):
|
||||
return user_tool('list')
|
||||
if route == ('POST', 1, None):
|
||||
name = body.get('name')
|
||||
if not isinstance(name, str) or password is None:
|
||||
return 400, {'error': 'name and password are required strings'}
|
||||
code, document = user_tool('add', name, password=password)
|
||||
return (201 if code == 200 else code), document
|
||||
if route == ('PUT', 3, 'password'):
|
||||
if password is None:
|
||||
return 400, {'error': 'password is a required string'}
|
||||
return user_tool('passwd', parts[1], password=password)
|
||||
if route in (('POST', 3, 'disable'), ('POST', 3, 'enable')):
|
||||
return user_tool(parts[2], parts[1])
|
||||
if route == ('DELETE', 2, None):
|
||||
return user_tool('remove', parts[1])
|
||||
if route == ('GET', 3, 'profile'):
|
||||
return user_tool('profile', parts[1])
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
@@ -329,19 +437,31 @@ class Handler(BaseHTTPRequestHandler):
|
||||
_cache['at'] = time.monotonic()
|
||||
return _cache['data']
|
||||
|
||||
def authorized(self):
|
||||
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
|
||||
secret = self.server.state.get('jwt_secret')
|
||||
header = self.headers.get('Authorization', '')
|
||||
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
|
||||
|
||||
def answer(self, send_body):
|
||||
path = self.path.split('?', 1)[0]
|
||||
extra = []
|
||||
if path == '/config':
|
||||
secret = self.server.state.get('jwt_secret')
|
||||
header = self.headers.get('Authorization', '')
|
||||
if not secret:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
elif not header.startswith('Bearer ') or verify(header[7:].strip(), secret) is None:
|
||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||
else:
|
||||
body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
|
||||
# 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
|
||||
protected =path == '/config' or path == '/users' or path.startswith('/users/')
|
||||
if protected and not self.server.state.get('jwt_secret'):
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
elif protected and not self.authorized():
|
||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||
body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif protected and path != '/config':
|
||||
code, document = users(self.server.state, self.command, path, self.request_body())
|
||||
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 == '/status':
|
||||
body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/':
|
||||
@@ -363,12 +483,34 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def request_body(self):
|
||||
"""The request body as JSON, None when there is none, or the string 'invalid'."""
|
||||
length = self.headers.get('Content-Length')
|
||||
if not length:
|
||||
return None
|
||||
try:
|
||||
size = int(length)
|
||||
if not 0 <= size <= BODY_LIMIT:
|
||||
return 'invalid'
|
||||
return json.loads(self.rfile.read(size) or b'null')
|
||||
except ValueError:
|
||||
return 'invalid'
|
||||
|
||||
def do_GET(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.answer(False)
|
||||
|
||||
def do_POST(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_PUT(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_DELETE(self):
|
||||
self.answer(True)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
||||
|
||||
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the users of the router's P2S systems.
|
||||
|
||||
A user is an entry in the router's directory, 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. Passwords are read from the first line of standard input, never from the
|
||||
command line. The answer is JSON on standard output; on failure it is {"error": ...} and
|
||||
the exit status says why: 2 the request is invalid, 3 the user does not exist, 4 the user
|
||||
exists, 1 anything else.
|
||||
|
||||
vpn-router-user list
|
||||
vpn-router-user add <name> (password on stdin)
|
||||
vpn-router-user passwd <name> (password on stdin)
|
||||
vpn-router-user disable <name>
|
||||
vpn-router-user enable <name>
|
||||
vpn-router-user remove <name>
|
||||
vpn-router-user profile <name>
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
import ldap.modlist
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_ldap
|
||||
|
||||
#: 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]$')
|
||||
PASSWORD_MIN = 8
|
||||
PASSWORD_MAX = 128
|
||||
#: The value of pwdAccountLockedTime that locks an account until it is unlocked.
|
||||
LOCKED = b'000001010000Z'
|
||||
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A request that cannot be done; status is the exit status."""
|
||||
|
||||
def __init__(self, message, status=1):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def dn_of(name):
|
||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||
|
||||
|
||||
def certificate(name):
|
||||
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem'
|
||||
|
||||
|
||||
def entry(connection, name):
|
||||
"""The attributes of the user's entry; Failure with status 3 when there is none."""
|
||||
try:
|
||||
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['uid', 'pwdAccountLockedTime'])[0][1]
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
raise Failure(f'user {name} does not exist', 3)
|
||||
|
||||
|
||||
def password():
|
||||
"""The password from the first line of standard input."""
|
||||
value = sys.stdin.readline().rstrip('\n')
|
||||
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
|
||||
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
|
||||
return value
|
||||
|
||||
|
||||
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 summary(attributes):
|
||||
name = attributes['uid'][0].decode()
|
||||
return {
|
||||
'name': name,
|
||||
'enabled': 'pwdAccountLockedTime' not in attributes,
|
||||
'certificate': certificate(name)[0].is_file(),
|
||||
}
|
||||
|
||||
|
||||
def list_users(connection):
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)',
|
||||
['uid', 'pwdAccountLockedTime'])
|
||||
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
|
||||
|
||||
|
||||
def add(connection, name):
|
||||
secret = password()
|
||||
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
|
||||
raise Failure('the router CA does not exist; enable OpenVPN or IKEv2 road-warrior access first')
|
||||
try:
|
||||
connection.add_s(dn_of(name), ldap.modlist.addModlist({
|
||||
'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()],
|
||||
}))
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'user {name} exists', 4)
|
||||
connection.passwd_s(dn_of(name), None, secret)
|
||||
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)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def passwd(connection, name):
|
||||
secret = password()
|
||||
entry(connection, name)
|
||||
connection.passwd_s(dn_of(name), None, secret)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def disable(connection, name):
|
||||
if 'pwdAccountLockedTime' not in entry(connection, name):
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def enable(connection, name):
|
||||
if 'pwdAccountLockedTime' in entry(connection, name):
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def remove(connection, name):
|
||||
entry(connection, name)
|
||||
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)
|
||||
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."""
|
||||
entry(connection, name)
|
||||
cfg = vpnrouter.load_config()
|
||||
if not cfg['openvpn']['enabled']:
|
||||
raise Failure('OpenVPN is not enabled', 2)
|
||||
cert, key = certificate(name)
|
||||
if not cert.is_file() or not key.is_file():
|
||||
raise Failure(f'user {name} has no certificate')
|
||||
fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
text = '\n'.join([
|
||||
'client',
|
||||
'dev tun',
|
||||
f'proto {cfg["openvpn"]["protocol"]}',
|
||||
f'remote {fqdn} {cfg["openvpn"]["port"]}',
|
||||
'nobind',
|
||||
'persist-key',
|
||||
'persist-tun',
|
||||
'remote-cert-tls server',
|
||||
f'verify-x509-name {fqdn} name',
|
||||
'auth-user-pass',
|
||||
'auth-nocache',
|
||||
'verb 3',
|
||||
'<ca>', extract_pem(vpnrouter.CA_CERT_DEST.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>',
|
||||
'',
|
||||
])
|
||||
return {'name': name, 'profile': text}
|
||||
|
||||
|
||||
def extract_pem(text):
|
||||
"""The PEM blocks of the certificates in a file that also holds their text form."""
|
||||
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
|
||||
blocks = []
|
||||
start = text.find(begin)
|
||||
while start != -1:
|
||||
stop = text.index(end, start) + len(end)
|
||||
blocks.append(text[start:stop])
|
||||
start = text.find(begin, stop)
|
||||
return '\n'.join(blocks)
|
||||
|
||||
|
||||
COMMANDS = {'list': list_users, 'add': add, 'passwd': passwd, 'disable': disable, 'enable': enable,
|
||||
'remove': remove, 'profile': profile}
|
||||
|
||||
|
||||
def main(argv):
|
||||
try:
|
||||
if not argv or argv[0] not in COMMANDS or len(argv) != (1 if argv[0] == 'list' else 2):
|
||||
raise Failure('usage: vpn-router-user list | add|passwd|disable|enable|remove|profile <name>', 2)
|
||||
if len(argv) == 2 and not NAME.match(argv[1]):
|
||||
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
|
||||
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOCK_FILE, 'w') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
connection = vpnrouter_ldap.manager()
|
||||
try:
|
||||
answer = COMMANDS[argv[0]](connection, *argv[1:])
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
except Failure as failure:
|
||||
print(json.dumps({'error': str(failure)}))
|
||||
return failure.status
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
print(json.dumps({'error': f'the user directory failed: {exc}'}))
|
||||
return 1
|
||||
print(json.dumps(answer))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -33,6 +33,12 @@
|
||||
"address": "192.168.200.1/24",
|
||||
"listen_port": 51820
|
||||
},
|
||||
"openvpn": {
|
||||
"enabled": false,
|
||||
"address_pool": "172.16.1.0/24",
|
||||
"port": 1194,
|
||||
"protocol": "udp"
|
||||
},
|
||||
"docker": {
|
||||
"mode": "auto",
|
||||
"exposed_ports": ["443"]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<LDAP>
|
||||
URL {{ ldap_uri }}
|
||||
Timeout 5
|
||||
TLSEnable no
|
||||
FollowReferrals no
|
||||
</LDAP>
|
||||
|
||||
<Authorization>
|
||||
BaseDN "{{ ldap_users }}"
|
||||
SearchFilter "(&(objectClass=inetOrgPerson)(uid=%u))"
|
||||
RequireGroup false
|
||||
</Authorization>
|
||||
@@ -0,0 +1,30 @@
|
||||
port {{ openvpn_port }}
|
||||
proto {{ openvpn_protocol }}
|
||||
dev {{ openvpn_device }}
|
||||
dev-type tun
|
||||
topology subnet
|
||||
server {{ openvpn_network }} {{ openvpn_netmask }}
|
||||
|
||||
ca {{ ca_cert }}
|
||||
cert {{ server_cert }}
|
||||
key {{ server_key }}
|
||||
crl-verify {{ crl }}
|
||||
dh none
|
||||
remote-cert-tls client
|
||||
|
||||
plugin /usr/lib/openvpn/openvpn-auth-ldap.so {{ openvpn_ldap_conf }}
|
||||
script-security 2
|
||||
auth-user-pass-verify /usr/lib/vpn-router/openvpn-same-user via-env
|
||||
|
||||
{% for network, netmask in openvpn_routes -%}
|
||||
push "route {{ network }} {{ netmask }}"
|
||||
{% endfor -%}
|
||||
{% for address in p2s_dns_list -%}
|
||||
push "dhcp-option DNS {{ address }}"
|
||||
{% endfor -%}
|
||||
|
||||
keepalive 10 120
|
||||
{% if openvpn_protocol == 'udp' -%}
|
||||
explicit-exit-notify 1
|
||||
{% endif -%}
|
||||
verb 3
|
||||
Reference in new issue
Block a user