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
@@ -49,8 +49,9 @@ port on both interfaces in UFW, so the choice of `web.listen` is made by the ser
|
|||||||
writes the features and the facts it resolves to `/run/vpn-router/web.json`.
|
writes the features and the facts it resolves to `/run/vpn-router/web.json`.
|
||||||
|
|
||||||
Consequences: By default the service is reachable from the internet on the external address;
|
Consequences: By default the service is reachable from the internet on the external address;
|
||||||
`web.listen` set to `internal` keeps it to the internal network. It serves plain HTTP, so the
|
`web.listen` set to `internal` keeps it to the internal network. It serves plain HTTP itself;
|
||||||
passwords of `/auth` and the tokens cross the internet unencrypted. A change of `web.listen`
|
HTTPS is Caddy's (ADR-21), so on the plain HTTP port the passwords of `/auth` and the tokens
|
||||||
|
cross the internet unencrypted. A change of `web.listen`
|
||||||
needs no run of setup, since the firewall does not depend on it; the package's upgrade runs
|
needs no run of setup, since the firewall does not depend on it; the package's upgrade runs
|
||||||
setup, which writes the rules. `/` and `/status` have no authentication, which is acceptable because they are
|
setup, which writes the rules. `/` and `/status` have no authentication, which is acceptable because they are
|
||||||
read only and show no configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`,
|
read only and show no configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`,
|
||||||
@@ -486,3 +487,33 @@ Consequences: slapd's `otp` overlay accepts each code once, so a change right af
|
|||||||
needs the next code. Changing the password revokes the user's refresh tokens, so the user's
|
needs the next code. Changing the password revokes the user's refresh tokens, so the user's
|
||||||
Manager session ends with its access token. An enrolled user who has lost the authenticator
|
Manager session ends with its access token. An enrolled user who has lost the authenticator
|
||||||
needs an administrator.
|
needs an administrator.
|
||||||
|
|
||||||
|
## ADR-21: Caddy from the Ubuntu archive serves HTTPS with a certificate from Let's Encrypt
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: The status web service and the Router Manager are reachable from the internet (ADR-2),
|
||||||
|
and `/auth` and the Manager's sign-in carry passwords. Both are small servers on the standard
|
||||||
|
library's `http.server`, which would need certificates, their renewal and a TLS setup of their
|
||||||
|
own. Browsers trust a public CA, not the router's.
|
||||||
|
|
||||||
|
Decision: Caddy, the `caddy` package of the Ubuntu archive, is the HTTPS proxy. While
|
||||||
|
`https.enabled` is `true` (the default) and `wan.local_fqdn` is set, `vpn-router-setup` writes
|
||||||
|
`/etc/caddy/vpn-router.Caddyfile`: a site for `wan.local_fqdn` on `https.web_port` (7444) for the
|
||||||
|
web service and, while the Router Manager is installed, one on `https.manager_port` (7443) for
|
||||||
|
the manager, each passing calls to the service on `127.0.0.1`. Caddy obtains and renews the
|
||||||
|
certificate from Let's Encrypt with the HTTP challenge on port 80, which the firewall opens on
|
||||||
|
the external interface; `auto_https disable_redirects` keeps port 80 to the challenge. The
|
||||||
|
package's drop-in for `caddy.service` points it at that file and keeps it from starting before
|
||||||
|
the file exists, so `/etc/caddy/Caddyfile`, a configuration file of the `caddy` package, is left
|
||||||
|
unchanged. Both services always listen on `127.0.0.1` as well as on the interfaces their
|
||||||
|
`listen` settings name, and trust `X-Forwarded-For` from it, which Caddy sets from the peer and
|
||||||
|
never takes from the client.
|
||||||
|
|
||||||
|
Consequences: HTTPS needs `wan.local_fqdn` in public DNS pointing at the router and port 80
|
||||||
|
reachable from the internet; until then Caddy retries and the HTTPS ports do not answer. The
|
||||||
|
certificate and the ACME account are in `/var/lib/caddy`. Plain HTTP stays as the `listen`
|
||||||
|
settings set it, so `internal` keeps passwords off the internet. Caddy's admin endpoint listens
|
||||||
|
on `localhost:2019`, which `caddy reload` uses. Port 80 and the HTTPS ports are not available
|
||||||
|
to Docker containers while HTTPS is on. The Manager's HTTPS port is a setting of the router, not
|
||||||
|
of the manager.
|
||||||
@@ -128,6 +128,10 @@ must have the type shown below; an unknown section or setting is an error.
|
|||||||
| `web` | `port` | integer | `7081` | TCP port of the status web service |
|
| `web` | `port` | integer | `7081` | TCP port of the status web service |
|
||||||
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
|
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
|
||||||
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit |
|
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit |
|
||||||
|
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
|
||||||
|
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
|
||||||
|
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
|
||||||
|
| `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager |
|
||||||
|
|
||||||
A minimal site-to-site configuration in `auto` mode:
|
A minimal site-to-site configuration in `auto` mode:
|
||||||
|
|
||||||
@@ -494,8 +498,8 @@ modules shipped with the package; the configuration file accepts any module inst
|
|||||||
## Status web service
|
## Status web service
|
||||||
|
|
||||||
The `vpn-router-web` service shows the state of the router on the addresses of the interfaces
|
The `vpn-router-web` service shows the state of the router on the addresses of the interfaces
|
||||||
`web.listen` names, the internal, the external or both (the default), and `web.port` (7081 by
|
`web.listen` names, the internal, the external or both (the default), and on `127.0.0.1`, where
|
||||||
default). Its pages `/` and `/status` need no authentication and show no
|
Caddy calls it (see HTTPS), with `web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no
|
||||||
configuration: no key, identity, address or subnet.
|
configuration: no key, identity, address or subnet.
|
||||||
|
|
||||||
The service first makes its signing key and the refresh tokens' database, retrying every 15
|
The service first makes its signing key and the refresh tokens' database, retrying every 15
|
||||||
@@ -691,20 +695,47 @@ and the facts for `/config` from `/run/vpn-router/web.json` (readable by root on
|
|||||||
`vpn-router-setup` writes; before that file exists the page shows no features.
|
`vpn-router-setup` writes; before that file exists the page shows no features.
|
||||||
`vpn-router-setup` opens `web.port` on the internal and the external interface in the firewall.
|
`vpn-router-setup` opens `web.port` on the internal and the external interface in the firewall.
|
||||||
|
|
||||||
|
## HTTPS
|
||||||
|
|
||||||
|
Caddy, from Ubuntu's `caddy` package, serves the status web service on `https.web_port` (7444 by
|
||||||
|
default) and, while the Router Manager is installed, the manager on `https.manager_port` (7443),
|
||||||
|
both for `wan.local_fqdn`, while `https.enabled` is `true` and `wan.local_fqdn` is set. It obtains
|
||||||
|
and renews the certificate from Let's Encrypt with the HTTP challenge, so `wan.local_fqdn` must
|
||||||
|
resolve to the router's public address and port 80 must be reachable from the internet; the
|
||||||
|
certificate and the ACME account are kept in `/var/lib/caddy`. Port 80 answers the challenge only;
|
||||||
|
it does not redirect to HTTPS.
|
||||||
|
|
||||||
|
`vpn-router-setup` writes Caddy's configuration to `/etc/caddy/vpn-router.Caddyfile` (root, group
|
||||||
|
`caddy`, the group Caddy runs as, mode 0640), which the
|
||||||
|
package's drop-in `/usr/lib/systemd/system/caddy.service.d/vpn-router.conf` makes `caddy.service`
|
||||||
|
read; `caddy.service` does not start before the file exists, and `/etc/caddy/Caddyfile` is not
|
||||||
|
used. Setup starts Caddy, reloads it when the file changes, and stops it and removes the file when
|
||||||
|
HTTPS is off. In the firewall it opens port 80 on the external interface and the HTTPS ports on the
|
||||||
|
internal and the external interface. Caddy calls both services on `127.0.0.1`, where they listen
|
||||||
|
whatever `web.listen` and the manager's `listen` say, and passes the client's address in
|
||||||
|
`X-Forwarded-For`, which the manager trusts from `127.0.0.1` and the web service by default, as
|
||||||
|
the address the call came in on; a `web.trusted_proxies` that is set must list `127.0.0.1`. The plain HTTP ports stay as
|
||||||
|
`web.listen` and the manager's `listen` set them. `docker.exposed_ports` may not take port 80 or
|
||||||
|
the HTTPS ports while `https.enabled` is `true`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://router.example.com:7444/status
|
||||||
|
```
|
||||||
|
|
||||||
## Router Manager
|
## Router Manager
|
||||||
|
|
||||||
The `vpn-router-manager` package, built in the repository `linux-cloud-router-manager`, is a web
|
The `vpn-router-manager` package, built in the repository `linux-cloud-router-manager`, is a web
|
||||||
application that shows the state of the router and manages its users through the status web
|
application that shows the state of the router and manages its users through the status web
|
||||||
service. It can be installed on the router or on another Linux system. While it is installed on
|
service. It can be installed on the router or on another Linux system. While it is installed on
|
||||||
the router, `vpn-router-setup` opens its port (`port` in `/etc/vpn-router-manager/manager.json`,
|
the router, `vpn-router-setup` opens its port (`port` in `/etc/vpn-router-manager/manager.json`,
|
||||||
7080 by default, HTTP; 7443 is kept for it with TLS) on the internal and the external
|
7080 by default, HTTP; HTTPS is Caddy's, on `https.manager_port`) on the internal and the external
|
||||||
interface; restart `vpn-router-setup` after changing it. The manager's `listen` chooses the
|
interface; restart `vpn-router-setup` after changing it. The manager's `listen` chooses the
|
||||||
interfaces it listens on.
|
interfaces it listens on.
|
||||||
|
|
||||||
## Removal
|
## Removal
|
||||||
|
|
||||||
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
|
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
|
||||||
tunnels and takes `wg-quick@wg0` and `openvpn-server@vpn-router` down, but leaves the
|
tunnels and takes `wg-quick@wg0`, `openvpn-server@vpn-router` and `caddy` down, but leaves the
|
||||||
configuration in place. `strongswan` is left enabled, since it is a shared service that
|
configuration in place. `strongswan` is left enabled, since it is a shared service that
|
||||||
may be in use by something else.
|
may be in use by something else.
|
||||||
|
|
||||||
@@ -712,7 +743,8 @@ may be in use by something else.
|
|||||||
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
||||||
before deleting it so a later reinstall starts clean. It also removes the web service's
|
before deleting it so a later reinstall starts clean. It also removes the web service's
|
||||||
signing key, and the refresh tokens' database, its role and their password file when the package
|
signing key, and the refresh tokens' database, its role and their password file when the package
|
||||||
created them. Key material and users are never deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the
|
created them, and Caddy's configuration `/etc/caddy/vpn-router.Caddyfile`; the certificate in
|
||||||
|
`/var/lib/caddy` belongs to the `caddy` package. Key material and users are never deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the
|
||||||
WireGuard key pair and everything under `/etc/swanctl` are left alone.
|
WireGuard key pair and everything under `/etc/swanctl` are left alone.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|||||||
@@ -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
|
vpn-router (0.12.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the web service listens on the internal address, the external address or both.
|
fix: the web service listens on the internal address, the external address or both.
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ Depends: ${misc:Depends},
|
|||||||
openvpn,
|
openvpn,
|
||||||
openvpn-auth-ldap,
|
openvpn-auth-ldap,
|
||||||
iproute2,
|
iproute2,
|
||||||
systemd-resolved
|
systemd-resolved,
|
||||||
|
caddy
|
||||||
Description: Linux VPN router with IPSec, road-warrior, OpenVPN 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
|
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,
|
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/generate-config usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
||||||
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
src/usr/share/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/
|
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
|
||||||
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
|
|||||||
|
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
awk '
|
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
|
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 = 0; next
|
||||||
}
|
}
|
||||||
skip { next }
|
skip { next }
|
||||||
@@ -49,6 +49,7 @@ case "$1" in
|
|||||||
/etc/wireguard/wg0.conf \
|
/etc/wireguard/wg0.conf \
|
||||||
/etc/openvpn/server/vpn-router.conf \
|
/etc/openvpn/server/vpn-router.conf \
|
||||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||||
|
/etc/caddy/vpn-router.Caddyfile \
|
||||||
/etc/vpn-router/vpn-router.json \
|
/etc/vpn-router/vpn-router.json \
|
||||||
/etc/vpn-router/web.key \
|
/etc/vpn-router/web.key \
|
||||||
/var/lib/vpn-router/setup.done
|
/var/lib/vpn-router/setup.done
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ case "$1" in
|
|||||||
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
|
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# This package enabled wg-quick@wg0 and openvpn-server@vpn-router, so
|
# This package enabled wg-quick@wg0, openvpn-server@vpn-router and
|
||||||
# it takes them down again. Use the debhelper wrappers rather than
|
# caddy, so it takes them down again. Use the debhelper wrappers rather
|
||||||
# systemctl, so the script behaves on a machine without systemd.
|
# than systemctl, so the script behaves on a machine without systemd.
|
||||||
for unit in wg-quick@wg0.service openvpn-server@vpn-router.service; do
|
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
|
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||||
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
|
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
|
||||||
fi
|
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.
|
would be a silent and security-relevant mistake.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import grp
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
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_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
|
||||||
MANAGER_PORT = 7080
|
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'
|
FILTER_ANCHOR = '# End required lines'
|
||||||
|
|
||||||
#: Configuration options left out wherever the configuration is shown.
|
#: Configuration options left out wherever the configuration is shown.
|
||||||
@@ -71,6 +78,7 @@ BLOCKS = (
|
|||||||
('# WEB RULES START', '# WEB RULES END'),
|
('# WEB RULES START', '# WEB RULES END'),
|
||||||
('# BOOTSTRAP RULES START', '# BOOTSTRAP RULES END'),
|
('# BOOTSTRAP RULES START', '# BOOTSTRAP RULES END'),
|
||||||
('# MANAGER RULES START', '# MANAGER RULES END'),
|
('# MANAGER RULES START', '# MANAGER RULES END'),
|
||||||
|
('# HTTPS RULES START', '# HTTPS RULES END'),
|
||||||
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||||
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
||||||
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
||||||
@@ -242,6 +250,19 @@ def filter_block(ctx):
|
|||||||
'# MANAGER RULES END',
|
'# 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):
|
if vpnrouter.routing_ready(ctx):
|
||||||
lines += ['', '# ROUTER FORWARD RULES START']
|
lines += ['', '# ROUTER FORWARD RULES START']
|
||||||
for subnet in ctx['local_cidr_list']:
|
for subnet in ctx['local_cidr_list']:
|
||||||
@@ -433,6 +454,32 @@ def manage_openvpn(ctx, changed):
|
|||||||
run(['systemctl', 'restart', unit], check=False)
|
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):
|
def run_platform_setup(cfg, ctx):
|
||||||
"""Let the platform module apply its own state, if it defines any."""
|
"""Let the platform module apply its own state, if it defines any."""
|
||||||
module = vpnrouter.platform_module(cfg)
|
module = vpnrouter.platform_module(cfg)
|
||||||
@@ -576,6 +623,8 @@ def main():
|
|||||||
problem = manage_admin()
|
problem = manage_admin()
|
||||||
progress('running', 'OpenVPN')
|
progress('running', 'OpenVPN')
|
||||||
manage_openvpn(ctx, changed)
|
manage_openvpn(ctx, changed)
|
||||||
|
progress('running', 'HTTPS proxy')
|
||||||
|
manage_caddy(ctx)
|
||||||
|
|
||||||
if 'resolved' in changed:
|
if 'resolved' in changed:
|
||||||
progress('running', 'resolver')
|
progress('running', 'resolver')
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if str(LIB_DIR) not in sys.path:
|
|||||||
|
|
||||||
import vpnrouter_ldap
|
import vpnrouter_ldap
|
||||||
import vpnrouter_platforms
|
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')
|
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
||||||
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||||
@@ -55,8 +55,12 @@ SCHEMA = {
|
|||||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||||
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': []},
|
'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')
|
DOCKER_MODES = ('auto', 'on', 'off')
|
||||||
|
|
||||||
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
||||||
@@ -264,6 +268,17 @@ def validate(cfg):
|
|||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f'web.listen: {web_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
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'):
|
for item in _get_list(cfg, 'web', 'trusted_proxies'):
|
||||||
_check_address(item, 'web.trusted_proxies')
|
_check_address(item, 'web.trusted_proxies')
|
||||||
|
|
||||||
@@ -280,6 +295,13 @@ def validate(cfg):
|
|||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f'docker.exposed_ports: {item!r} is not a port or a port range')
|
f'docker.exposed_ports: {item!r} is not a port or a port range')
|
||||||
weight += 2 if ':' in item else 1
|
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:
|
if weight > DOCKER_PORT_LIMIT:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f'docker.exposed_ports: at most {DOCKER_PORT_LIMIT} ports are allowed, '
|
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,
|
'ldap_users': vpnrouter_ldap.USERS,
|
||||||
'web_enabled': _get(cfg, 'web', 'enabled'),
|
'web_enabled': _get(cfg, 'web', 'enabled'),
|
||||||
'web_port': _get(cfg, 'web', 'port'),
|
'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'])
|
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():
|
def _jinja_env():
|
||||||
return jinja2.Environment(
|
return jinja2.Environment(
|
||||||
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
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.
|
#: The interfaces a service listens on when it is not set.
|
||||||
DEFAULT_LISTEN = 'both'
|
DEFAULT_LISTEN = 'both'
|
||||||
|
|
||||||
|
#: The address every service also listens on, where the HTTPS proxy calls it.
|
||||||
|
LOOPBACK = '127.0.0.1'
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
class ConfigError(Exception):
|
||||||
"""A setting is present but malformed."""
|
"""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 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
|
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:
|
if choice not in LISTEN_CHOICES:
|
||||||
choice = DEFAULT_LISTEN
|
choice = DEFAULT_LISTEN
|
||||||
@@ -157,9 +160,9 @@ def listen_addresses(configuration, choice):
|
|||||||
def web_listen(configuration, default_port):
|
def web_listen(configuration, default_port):
|
||||||
"""Where the web service listens, from vpn-router.json read as configuration, as ([(address, 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
|
The addresses are those of the interfaces web.listen names (listen_addresses), then
|
||||||
web.port, else default_port. While web.enabled is false or no address is known, the answer
|
LOOPBACK, the port web.port, else default_port. While web.enabled is false or no address of
|
||||||
is ([], the reason).
|
an interface is known, the answer is ([], the reason).
|
||||||
"""
|
"""
|
||||||
web = _section(configuration, 'web')
|
web = _section(configuration, 'web')
|
||||||
if web.get('enabled', True) is False:
|
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))
|
addresses = listen_addresses(configuration, web.get('listen', DEFAULT_LISTEN))
|
||||||
if not addresses:
|
if not addresses:
|
||||||
return [], 'the addresses to listen on are not known yet'
|
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,
|
"port": 7081,
|
||||||
"database": "",
|
"database": "",
|
||||||
"trusted_proxies": []
|
"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