feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
Release / release (push) Successful in 23s
Release / release (push) Successful in 23s
This commit is contained in:
1 parent
993a7a9fa9
commit
45d73abe7b
13 files changed
+226
-20
No files matched your search
@@ -1,3 +1,26 @@
|
||||
vpn-router (0.13.0-1) unstable; urgency=medium
|
||||
|
||||
feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
|
||||
|
||||
* The package depends on caddy. While https.enabled is true, the
|
||||
default, and wan.local_fqdn is set, vpn-router-setup writes
|
||||
/etc/caddy/vpn-router.Caddyfile and runs Caddy, which serves the
|
||||
status page on https.web_port (7444) and the Router Manager, when it
|
||||
is installed, on https.manager_port (7443) for wan.local_fqdn, and
|
||||
obtains the certificate from Let's Encrypt with the HTTP challenge.
|
||||
* The package's drop-in for caddy.service makes Caddy read that file
|
||||
and keeps it from starting before the file exists. The file belongs
|
||||
to root and the group caddy, which Caddy runs as, with mode 0640.
|
||||
* The firewall opens port 80 on the external interface and the HTTPS
|
||||
ports on both interfaces while HTTPS is on.
|
||||
* The web service also listens on 127.0.0.1, where Caddy calls it.
|
||||
* docker.exposed_ports may not take port 80 or the HTTPS ports while
|
||||
HTTPS is on.
|
||||
* Removing the package stops and disables caddy; purging it removes
|
||||
/etc/caddy/vpn-router.Caddyfile.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 15:00:00 +0200
|
||||
|
||||
vpn-router (0.12.3-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service listens on the internal address, the external address or both.
|
||||
|
||||
@@ -28,7 +28,8 @@ Depends: ${misc:Depends},
|
||||
openvpn,
|
||||
openvpn-auth-ldap,
|
||||
iproute2,
|
||||
systemd-resolved
|
||||
systemd-resolved,
|
||||
caddy
|
||||
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,
|
||||
|
||||
@@ -19,4 +19,5 @@ src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
||||
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
||||
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
|
||||
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
|
||||
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk '
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||
skip = 1; pending = 0; next
|
||||
}
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||
skip = 0; next
|
||||
}
|
||||
skip { next }
|
||||
@@ -49,6 +49,7 @@ case "$1" in
|
||||
/etc/wireguard/wg0.conf \
|
||||
/etc/openvpn/server/vpn-router.conf \
|
||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||
/etc/caddy/vpn-router.Caddyfile \
|
||||
/etc/vpn-router/vpn-router.json \
|
||||
/etc/vpn-router/web.key \
|
||||
/var/lib/vpn-router/setup.done
|
||||
|
||||
@@ -11,10 +11,10 @@ case "$1" in
|
||||
swanctl --terminate --ike road-warrior >/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
|
||||
# This package enabled wg-quick@wg0, openvpn-server@vpn-router and
|
||||
# caddy, 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 caddy.service; do
|
||||
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Caddy is the HTTPS proxy of vpn-router: it reads the configuration vpn-router-setup writes, and
|
||||
# does not start until that exists.
|
||||
[Unit]
|
||||
ConditionPathExists=/etc/caddy/vpn-router.Caddyfile
|
||||
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/caddy run --environ --adapter caddyfile --config /etc/caddy/vpn-router.Caddyfile
|
||||
ExecReload=
|
||||
ExecReload=/usr/bin/caddy reload --adapter caddyfile --config /etc/caddy/vpn-router.Caddyfile --force
|
||||
@@ -16,6 +16,7 @@ firewall and NAT rules are written against them, so choosing the wrong one
|
||||
would be a silent and security-relevant mistake.
|
||||
"""
|
||||
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
@@ -58,6 +59,12 @@ MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
|
||||
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
|
||||
MANAGER_PORT = 7080
|
||||
|
||||
#: The configuration of Caddy, the HTTPS proxy, which the package's drop-in for caddy.service names;
|
||||
#: Caddy runs as the user and group CADDY_GROUP, which the caddy package creates.
|
||||
CADDYFILE = pathlib.Path('/etc/caddy/vpn-router.Caddyfile')
|
||||
CADDY_UNIT = 'caddy.service'
|
||||
CADDY_GROUP = 'caddy'
|
||||
|
||||
FILTER_ANCHOR = '# End required lines'
|
||||
|
||||
#: Configuration options left out wherever the configuration is shown.
|
||||
@@ -71,6 +78,7 @@ BLOCKS = (
|
||||
('# WEB RULES START', '# WEB RULES END'),
|
||||
('# BOOTSTRAP RULES START', '# BOOTSTRAP RULES END'),
|
||||
('# MANAGER RULES START', '# MANAGER RULES END'),
|
||||
('# HTTPS RULES START', '# HTTPS RULES END'),
|
||||
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
||||
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
||||
@@ -242,6 +250,19 @@ def filter_block(ctx):
|
||||
'# MANAGER RULES END',
|
||||
]
|
||||
|
||||
# Let's Encrypt reaches the HTTP challenge on the external interface; 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']),
|
||||
(MANAGER.exists(), ctx['https_manager_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 []),
|
||||
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name in interfaces for port in ports),
|
||||
'# HTTPS RULES END',
|
||||
]
|
||||
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
lines += ['', '# ROUTER FORWARD RULES START']
|
||||
for subnet in ctx['local_cidr_list']:
|
||||
@@ -433,6 +454,32 @@ def manage_openvpn(ctx, changed):
|
||||
run(['systemctl', 'restart', unit], check=False)
|
||||
|
||||
|
||||
def manage_caddy(ctx):
|
||||
"""Run Caddy, the HTTPS proxy of the web service and the Router Manager, while HTTPS is ready, and stop it when not.
|
||||
|
||||
CADDYFILE serves the web service on https.web_port and, while the manager is installed, the
|
||||
manager on https.manager_port, both for wan.local_fqdn with a certificate from Let's Encrypt,
|
||||
and both calling the service on its loopback address.
|
||||
"""
|
||||
if not vpnrouter.https_ready(ctx):
|
||||
vpnrouter.remove(CADDYFILE)
|
||||
if unit_is_active(CADDY_UNIT):
|
||||
run(['systemctl', 'disable', '--now', CADDY_UNIT], check=False)
|
||||
return
|
||||
|
||||
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)
|
||||
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:
|
||||
run(['systemctl', 'reload', CADDY_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)
|
||||
@@ -576,6 +623,8 @@ def main():
|
||||
problem = manage_admin()
|
||||
progress('running', 'OpenVPN')
|
||||
manage_openvpn(ctx, changed)
|
||||
progress('running', 'HTTPS proxy')
|
||||
manage_caddy(ctx)
|
||||
|
||||
if 'resolved' in changed:
|
||||
progress('running', 'resolver')
|
||||
|
||||
@@ -21,7 +21,7 @@ if str(LIB_DIR) not in sys.path:
|
||||
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_platforms
|
||||
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, ConfigError, detect_interfaces, interface_addresses, ip_output
|
||||
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')
|
||||
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||
@@ -55,8 +55,12 @@ SCHEMA = {
|
||||
'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': []},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443},
|
||||
}
|
||||
|
||||
#: The port of Let's Encrypt's HTTP challenge, which Caddy answers while HTTPS is on.
|
||||
ACME_HTTP_PORT = 80
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
|
||||
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
||||
@@ -264,6 +268,17 @@ def validate(cfg):
|
||||
raise ConfigError(
|
||||
f'web.listen: {web_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
||||
|
||||
https_ports = []
|
||||
for option in ('web_port', 'manager_port'):
|
||||
port = _get(cfg, 'https', option)
|
||||
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
||||
raise ConfigError(f'https.{option}: {port!r} is not a valid port')
|
||||
if port == ACME_HTTP_PORT:
|
||||
raise ConfigError(f'https.{option}: {port} is the port of the HTTP challenge')
|
||||
https_ports.append(port)
|
||||
if https_ports[0] == https_ports[1]:
|
||||
raise ConfigError('https.web_port and https.manager_port must differ')
|
||||
|
||||
for item in _get_list(cfg, 'web', 'trusted_proxies'):
|
||||
_check_address(item, 'web.trusted_proxies')
|
||||
|
||||
@@ -280,6 +295,13 @@ def validate(cfg):
|
||||
raise ConfigError(
|
||||
f'docker.exposed_ports: {item!r} is not a port or a port range')
|
||||
weight += 2 if ':' in item else 1
|
||||
first, _, last = item.partition(':')
|
||||
if _get(cfg, 'https', 'enabled'):
|
||||
for port in (ACME_HTTP_PORT, *https_ports):
|
||||
if int(first) <= port <= int(last or first):
|
||||
raise ConfigError(
|
||||
f'docker.exposed_ports: {item!r} takes port {port}, which Caddy serves while '
|
||||
'https.enabled is true')
|
||||
if weight > DOCKER_PORT_LIMIT:
|
||||
raise ConfigError(
|
||||
f'docker.exposed_ports: at most {DOCKER_PORT_LIMIT} ports are allowed, '
|
||||
@@ -448,6 +470,11 @@ def build_context(cfg, local_id_override=None):
|
||||
'ldap_users': vpnrouter_ldap.USERS,
|
||||
'web_enabled': _get(cfg, 'web', 'enabled'),
|
||||
'web_port': _get(cfg, 'web', 'port'),
|
||||
'https_enabled': _get(cfg, 'https', 'enabled'),
|
||||
'https_email': _get(cfg, 'https', 'email'),
|
||||
'https_web_port': _get(cfg, 'https', 'web_port'),
|
||||
'https_manager_port': _get(cfg, 'https', 'manager_port'),
|
||||
'loopback': LOOPBACK,
|
||||
}
|
||||
|
||||
|
||||
@@ -477,6 +504,11 @@ def openvpn_ready(ctx):
|
||||
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'])
|
||||
|
||||
|
||||
def https_ready(ctx):
|
||||
"""Caddy serves HTTPS once it is enabled and wan.local_fqdn names the certificate."""
|
||||
return bool(ctx['https_enabled'] and ctx['local_fqdn'])
|
||||
|
||||
|
||||
def _jinja_env():
|
||||
return jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
|
||||
@@ -13,6 +13,9 @@ LISTEN_CHOICES = ('internal', 'external', 'both')
|
||||
#: The interfaces a service listens on when it is not set.
|
||||
DEFAULT_LISTEN = 'both'
|
||||
|
||||
#: The address every service also listens on, where the HTTPS proxy calls it.
|
||||
LOOPBACK = '127.0.0.1'
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""A setting is present but malformed."""
|
||||
@@ -140,7 +143,7 @@ def listen_addresses(configuration, choice):
|
||||
"""The addresses a service listens on for choice of LISTEN_CHOICES, from vpn-router.json read as configuration.
|
||||
|
||||
The internal address comes first; an address not known yet is left out. A choice that is not
|
||||
one of LISTEN_CHOICES is DEFAULT_LISTEN.
|
||||
one of LISTEN_CHOICES is DEFAULT_LISTEN. LOOPBACK is not among them; web_listen adds it.
|
||||
"""
|
||||
if choice not in LISTEN_CHOICES:
|
||||
choice = DEFAULT_LISTEN
|
||||
@@ -157,9 +160,9 @@ def listen_addresses(configuration, choice):
|
||||
def web_listen(configuration, default_port):
|
||||
"""Where the web service listens, from vpn-router.json read as configuration, as ([(address, port), ...], '').
|
||||
|
||||
The addresses are those of the interfaces web.listen names (listen_addresses), the port
|
||||
web.port, else default_port. While web.enabled is false or no address is known, the answer
|
||||
is ([], the reason).
|
||||
The addresses are those of the interfaces web.listen names (listen_addresses), then
|
||||
LOOPBACK, the port web.port, else default_port. While web.enabled is false or no address of
|
||||
an interface is known, the answer is ([], the reason).
|
||||
"""
|
||||
web = _section(configuration, 'web')
|
||||
if web.get('enabled', True) is False:
|
||||
@@ -170,4 +173,4 @@ def web_listen(configuration, default_port):
|
||||
addresses = listen_addresses(configuration, web.get('listen', DEFAULT_LISTEN))
|
||||
if not addresses:
|
||||
return [], 'the addresses to listen on are not known yet'
|
||||
return [(address, port) for address in addresses], ''
|
||||
return [(address, port) for address in [*addresses, LOOPBACK]], ''
|
||||
@@ -49,5 +49,11 @@
|
||||
"port": 7081,
|
||||
"database": "",
|
||||
"trusted_proxies": []
|
||||
},
|
||||
"https": {
|
||||
"enabled": true,
|
||||
"email": "",
|
||||
"web_port": 7444,
|
||||
"manager_port": 7443
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.
|
||||
{
|
||||
auto_https disable_redirects
|
||||
{%- if https_email %}
|
||||
email {{ https_email }}
|
||||
{%- endif %}
|
||||
}
|
||||
{% if web_enabled %}
|
||||
{{ local_fqdn }}:{{ https_web_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ web_port }}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if manager_port %}
|
||||
{{ local_fqdn }}:{{ https_manager_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ manager_port }}
|
||||
}
|
||||
{% endif %}
|
||||
Reference in new issue
Block a user