fix: the web service works in every state of the router.
Release / release (push) Successful in 11s

This commit is contained in:
slawek committed 2026-10-23 22:35:53 +02:00
1 parent cc6748a622
commit a4cffb67e4
10 files changed
+369 -138

No files matched your search

+28 -2
View File
@@ -29,7 +29,7 @@ restores the rules. The rules cover IPv4 only.
## ADR-2: A status web service of the standard library, bound to the internal address ## ADR-2: A status web service of the standard library, bound to the internal address
Status: Accepted Status: Accepted; amended by ADR-7
Context: The router has no way to see its state without a shell. A check from the protected Context: The router has no way to see its state without a shell. A check from the protected
network, by a person or by a monitor, needs a page or an endpoint. The router is an network, by a person or by a monitor, needs a page or an endpoint. The router is an
@@ -55,7 +55,7 @@ address is picked up at the next run of `vpn-router-setup`.
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret ## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
Status: Accepted Status: Accepted; amended by ADR-7
Context: A client such as manage-ui needs the configuration and the resolved state of the Context: A client such as manage-ui needs the configuration and the resolved state of the
router, which `/` and `/status` must not show. The service has only the standard library, listens router, which `/` and `/status` must not show. The service has only the standard library, listens
@@ -148,3 +148,29 @@ Consequences: The web service still imports nothing outside the standard library
program serves the command line and the API. Each request starts a process, which is fine for program serves the command line and the API. Each request starts a process, which is fine for
user administration. The API is protected by the token of `/config`, so whoever has the secret user administration. The API is protected by the token of `/config`, so whoever has the secret
can manage users. can manage users.
## ADR-7: The web service starts on its own and reads its inputs at each request
Status: Accepted
Context: The web service took the address to listen on, the port, the token secret and the
features from `/run/vpn-router/web.json`, written by `vpn-router-setup`, and ended with a failure
while the file or the internal address was missing. It was ordered after `vpn-router-setup`, so a
problem in the setup kept the status page, the tool to see that problem, from running.
Decision: The service depends on nothing to start and does not end on its own. A discovery
thread works out, every 30 seconds, the internal address from `vpn-router.json` as
`general.mode` directs, with the discovery code in `vpnrouter_net.py`, which uses only the
standard library and which `vpnrouter.py` uses too, and `web.port` and `web.enabled` from the
same file. It runs the HTTP server in a thread of its own at that address and port, stops it and
starts a new one when they change, and runs none while the service is disabled, the address is
not known or it cannot be bound. Each request reads the token secret from `vpn-router.json` and
the features and resolved facts from `web.json`, which setup still writes, because they need the
configuration code; while that file is missing the router shows no features. The unit is not
ordered after `vpn-router-setup`, and setup does not restart it.
Consequences: The status page answers as soon as the internal address exists, whatever state
setup is in, and follows a changed address, port or `web.enabled` within 30 seconds without a
restart. `web.json` no longer holds the address, the port or the secret, so the secret is only in
`vpn-router.json`. A change of the secret applies to the next request. The interfaces are read
every 30 seconds, which costs a few `ip` commands.
+8 -5
View File
@@ -493,11 +493,14 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:8080/users -d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:8080/users
``` ```
`vpn-router-setup` opens `web.port` on the internal interface in the firewall, writes the address, The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
the port, the secret and the facts for `/config` to `/run/vpn-router/web.json` (readable by root 30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs,
only) and restarts the service, so a changed `web` section and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port
applies with `systemctl restart vpn-router-setup`. The service ends without an error when changes, and stops listening while `web.enabled` is `false` or the address is not known. Each
`web.enabled` is `false`, and keeps trying to start until the router has an internal address. request reads the token secret from `vpn-router.json`, and the features and the facts for
`/config` from `/run/vpn-router/web.json` (readable by root only), which `vpn-router-setup` writes;
before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the
internal interface in the firewall.
## Removal ## Removal
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Commit everything, tag the version of the newest changelog entry and push main with the tags.
The tag is the upstream version of the first entry of debian-package/debian/changelog,
without the Debian revision: vpn-router (0.5.1-1) is tagged 0.5.1, which starts the
release workflow.
"""
import argparse
import pathlib
import shlex
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parent
CHANGELOG = ROOT / 'debian-package' / 'debian' / 'changelog'
#: Print the git commands instead of running them; set by -n/--dry-run.
DRY_RUN = False
def git(*args):
"""Run git in the repository, or print the command on a dry run; stop with git's exit status when it fails."""
command = ['git', *args]
if DRY_RUN:
print(shlex.join(command))
return
result = subprocess.run(command, cwd=ROOT)
if result.returncode != 0:
sys.exit(result.returncode)
def summary():
"""The summary line of the first changelog entry, or None when it has none.
The summary is the entry's first text line: indented by two spaces, not a "*" item,
with a blank line between it and the header and a blank line after it.
"""
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
return None
line = lines[2]
text = line.strip()
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
return None
return text
def version():
"""The upstream version of the first changelog entry."""
first = CHANGELOG.read_text(encoding='utf-8').splitlines()[0]
start, end = first.find('('), first.find(')')
if start == -1 or end < start:
sys.exit(f'{CHANGELOG}: the first line has no version: {first}')
full = first[start + 1:end]
return full.rsplit('-', 1)[0] if '-' in full else full
def main():
global DRY_RUN
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('-m', '--message', help='commit message; default: the summary line of the newest changelog entry, a line without "*" between blank lines right after its header')
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
args = parser.parse_args()
DRY_RUN = args.dry_run
message = args.message or summary()
if not message:
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
'without "*", indented by two spaces, with a blank line above and below it')
tag = version()
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
capture_output=True).returncode == 0:
sys.exit(f'tag {tag} exists; add a new changelog entry first')
git('add', '-A')
git('commit', '-m', message)
git('tag', tag)
if not args.no_push:
git('push', 'origin', 'main', '--tags')
return 0
if __name__ == '__main__':
sys.exit(main())
+18
View File
@@ -1,3 +1,21 @@
vpn-router (0.5.1-1) unstable; urgency=medium
fix: the web service works in every state of the router.
* vpn-router-web works whether /etc/vpn-router/pki and
/etc/swanctl/x509crl exist or not; they exist only while OpenVPN or
road-warrior access is configured. The paths the unit may write are
used when they exist and ignored when they do not.
* vpn-router-web starts on its own and keeps running. Every 30 seconds a
discovery thread works out the internal address from vpn-router.json as
general.mode directs, with web.port and web.enabled, and starts, moves
or stops the server thread to match. Each request reads the token
secret, the features and the facts it shows. The service is no longer
ordered after vpn-router-setup, which no longer restarts it, and
web.json no longer holds the address, the port or the secret.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 10:00:00 +0200
vpn-router (0.5.0-1) unstable; urgency=medium vpn-router (0.5.0-1) unstable; urgency=medium
* OpenVPN P2S access, set in the openvpn section (enabled, address_pool, * OpenVPN P2S access, set in the openvpn section (enabled, address_pool,
+1
View File
@@ -1,6 +1,7 @@
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/ 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.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-same-user 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/sbin/vpn-router-user usr/sbin/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
@@ -1,7 +1,7 @@
[Unit] [Unit]
Description=VPN Router status web service Description=VPN Router status web service
Documentation=file:/usr/share/doc/vpn-router/README.md Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target vpn-router-setup.service After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
@@ -11,7 +11,7 @@ Restart=on-failure
RestartSec=15 RestartSec=15
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=/run/vpn-router /etc/vpn-router/pki /etc/swanctl/x509crl ReadWritePaths=-/run/vpn-router -/etc/vpn-router/pki -/etc/swanctl/x509crl
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
+6 -13
View File
@@ -378,15 +378,13 @@ def refresh_docker_rules():
def refresh_web(cfg, ctx): def refresh_web(cfg, ctx):
"""Hand the web service what it needs and restart it. """Hand the web service the features and facts it shows.
The service takes the address to bind to, the port, the features of the The service reads the file at each request: the features of the router,
router, the secret that signs its tokens, the configuration with its the configuration with its secrets masked and the facts resolved from the
secrets masked and the facts resolved from the system from a file here, so system, which it cannot work out with the standard library alone. The file
that it needs nothing but the standard library and the configuration code. is readable by root only. The service follows a changed address or port
The file is readable by root only. The service ends quietly when itself.
web.enabled is false. The restart is queued, not waited for, because the
unit is ordered after vpn-router-setup.
""" """
masked = { masked = {
section: {option: '********' if SECRET_OPTION.search(option) and value else value section: {option: '********' if SECRET_OPTION.search(option) and value else value
@@ -397,10 +395,6 @@ def refresh_web(cfg, ctx):
WEB_STATE.touch(mode=0o600) WEB_STATE.touch(mode=0o600)
WEB_STATE.chmod(0o600) WEB_STATE.chmod(0o600)
WEB_STATE.write_text(json.dumps({ WEB_STATE.write_text(json.dumps({
'enabled': bool(ctx['web_enabled']),
'bind': ctx['int_addr'],
'port': ctx['web_port'],
'jwt_secret': cfg['web']['jwt_secret'],
'site_to_site': bool(vpnrouter.s2s_ready(ctx)), 'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
'p2s': bool(vpnrouter.p2s_ready(ctx)), 'p2s': bool(vpnrouter.p2s_ready(ctx)),
'wireguard': bool(vpnrouter.wg_ready(ctx)), 'wireguard': bool(vpnrouter.wg_ready(ctx)),
@@ -419,7 +413,6 @@ def refresh_web(cfg, ctx):
'remote_cidrs': ctx['remote_cidr_list'], 'remote_cidrs': ctx['remote_cidr_list'],
}, },
}) + '\n', encoding='utf-8') }) + '\n', encoding='utf-8')
run(['systemctl', 'restart', '--no-block', 'vpn-router-web.service'], check=False)
def main(): def main():
@@ -21,6 +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 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')
@@ -92,10 +93,6 @@ MODES = ('manual', 'interfaces', 'auto')
PLATFORM_NONE = 'none' PLATFORM_NONE = 'none'
class ConfigError(Exception):
"""A setting is present but malformed."""
def warn(message): def warn(message):
print(f'vpn-router: warning: {message}', file=sys.stderr) print(f'vpn-router: warning: {message}', file=sys.stderr)
@@ -327,55 +324,6 @@ def deferred(cfg):
return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE
def _ip_route(*args):
result = subprocess.run(['ip', *args], capture_output=True, text=True)
return result.stdout if result.returncode == 0 else ''
def detect_interfaces():
"""Work out the external and internal interface names.
Only reached in auto mode, where the operator has accepted that a wrong
guess is possible. The external interface is whichever one carries the
default route; the internal one is the only other addressed interface.
"""
tokens = _ip_route('route', 'get', '1.1.1.1').split()
external = ''
for index, token in enumerate(tokens):
if token == 'dev' and index + 1 < len(tokens):
external = tokens[index + 1]
break
if not external:
raise ConfigError('auto mode: cannot determine the external interface')
candidates = [name for name in _addressed_interfaces() if name != external]
if len(candidates) != 1:
raise ConfigError(
f'auto mode: expected one internal interface, found {len(candidates)}; '
'use mode = interfaces and name them')
return external, candidates[0]
def _is_virtual(name):
"""True for an interface the kernel creates without a device behind it.
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
which is where the Docker bridge is; a NIC of the virtual machine does not.
"""
link = pathlib.Path('/sys/class/net') / name
return '/devices/virtual/' in str(link.resolve())
def _addressed_interfaces():
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
names = []
for line in _ip_route('-o', '-4', 'addr', 'show').splitlines():
fields = line.split()
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
names.append(fields[1])
return names
def detect_gateway(iface, address): def detect_gateway(iface, address):
"""Derive the next hop on the internal side. """Derive the next hop on the internal side.
@@ -383,12 +331,12 @@ def detect_gateway(iface, address):
back to the first host address of the connected subnet, which is a back to the first host address of the connected subnet, which is a
convention rather than a fact - so say so. convention rather than a fact - so say so.
""" """
for line in _ip_route('-o', 'route', 'show', 'dev', iface).splitlines(): for line in ip_output('-o', 'route', 'show', 'dev', iface).splitlines():
fields = line.split() fields = line.split()
if 'via' in fields: if 'via' in fields:
return fields[fields.index('via') + 1] return fields[fields.index('via') + 1]
for line in _ip_route('-o', '-4', 'addr', 'show', 'dev', iface).splitlines(): for line in ip_output('-o', '-4', 'addr', 'show', 'dev', iface).splitlines():
fields = line.split() fields = line.split()
if len(fields) >= 4 and fields[3].split('/')[0] == address: if len(fields) >= 4 and fields[3].split('/')[0] == address:
network = ipaddress.ip_interface(fields[3]).network network = ipaddress.ip_interface(fields[3]).network
@@ -399,34 +347,6 @@ def detect_gateway(iface, address):
return '' return ''
def interface_addresses(name, setting):
"""IPv4 addresses configured on an interface.
The interface name is configuration; its addresses are read from the
system. Looking up the addresses of a named interface is deterministic -
unlike working out which interface to use, which is a guess the package
deliberately does not make.
"""
if not name:
return []
if not (pathlib.Path('/sys/class/net') / name).exists():
raise ConfigError(f'{setting}: interface {name!r} does not exist')
result = subprocess.run(
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
capture_output=True, text=True,
)
if result.returncode != 0:
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
addresses = []
for line in result.stdout.splitlines():
fields = line.split()
if len(fields) >= 4:
addresses.append(fields[3].split('/')[0])
return addresses
def resolve_interfaces(cfg): def resolve_interfaces(cfg):
"""Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip). """Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip).
@@ -0,0 +1,109 @@
"""Interfaces and addresses of the router, read from the system.
Only the Python standard library is used, so the web service can use this module too.
"""
import pathlib
import subprocess
class ConfigError(Exception):
"""A setting is present but malformed."""
def ip_output(*args):
"""The output of an ip command, or '' when it fails."""
try:
result = subprocess.run(['ip', *args], capture_output=True, text=True)
except OSError:
return ''
return result.stdout if result.returncode == 0 else ''
def detect_interfaces():
"""Work out the external and internal interface names.
Only reached in auto mode, where the operator has accepted that a wrong
guess is possible. The external interface is whichever one carries the
default route; the internal one is the only other addressed interface.
"""
tokens = ip_output('route', 'get', '1.1.1.1').split()
external = ''
for index, token in enumerate(tokens):
if token == 'dev' and index + 1 < len(tokens):
external = tokens[index + 1]
break
if not external:
raise ConfigError('auto mode: cannot determine the external interface')
candidates = [name for name in _addressed_interfaces() if name != external]
if len(candidates) != 1:
raise ConfigError(
f'auto mode: expected one internal interface, found {len(candidates)}; '
'use mode = interfaces and name them')
return external, candidates[0]
def _is_virtual(name):
"""True for an interface the kernel creates without a device behind it.
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
which is where the Docker bridge is; a NIC of the virtual machine does not.
"""
link = pathlib.Path('/sys/class/net') / name
return '/devices/virtual/' in str(link.resolve())
def _addressed_interfaces():
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
names = []
for line in ip_output('-o', '-4', 'addr', 'show').splitlines():
fields = line.split()
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
names.append(fields[1])
return names
def interface_addresses(name, setting):
"""IPv4 addresses configured on an interface.
The interface name is configuration; its addresses are read from the
system. Looking up the addresses of a named interface is deterministic -
unlike working out which interface to use, which is a guess the package
deliberately does not make.
"""
if not name:
return []
if not (pathlib.Path('/sys/class/net') / name).exists():
raise ConfigError(f'{setting}: interface {name!r} does not exist')
result = subprocess.run(
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
capture_output=True, text=True,
)
if result.returncode != 0:
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
addresses = []
for line in result.stdout.splitlines():
fields = line.split()
if len(fields) >= 4:
addresses.append(fields[3].split('/')[0])
return addresses
def internal_address(mode, internal, int_addr):
"""The router's address on the internal network, as general.mode directs, or '' when it is not known yet.
manual takes local.int_addr; interfaces reads the address of interfaces.internal; auto also
works out which interface that is.
"""
if mode == 'manual':
return int_addr
try:
if mode == 'auto':
internal = detect_interfaces()[1]
addresses = interface_addresses(internal, 'interfaces.internal')
except ConfigError:
return ''
return addresses[0] if addresses else ''
+107 -33
View File
@@ -21,12 +21,14 @@ web.jwt_secret is not set.
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN 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. WireGuard. No configuration data, key or address is shown.
The address to bind to, the port, the secret and the configuration shown by /config The service needs nothing else to start and does not end on its own. A discovery thread works
are not read from the configuration here: vpn-router-setup resolves them, with the out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
interfaces, and writes them to /run/vpn-router/web.json, readable by root only, before web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
it restarts this service. The service ends with it, moves it when the address or the port changes, and stops it while the service is disabled
success when the file says the service is disabled, and with failure, to be or the address is not known. Every request reads what it needs when it is made: the token secret
started again, when the file is missing or the router has no internal address yet. from the configuration, and the features of the router and the facts shown by /config from
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
the router has no features, and /config shows no configuration or interfaces.
""" """
import base64 import base64
@@ -42,7 +44,18 @@ import time
from html import escape from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter_net
STATE_FILE = '/run/vpn-router/web.json' STATE_FILE = '/run/vpn-router/web.json'
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
#: The port when web.port is not set.
DEFAULT_PORT = 8080
#: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30
COMMAND_TIMEOUT = 5 COMMAND_TIMEOUT = 5
CACHE_SECONDS = 2 CACHE_SECONDS = 2
@@ -73,6 +86,33 @@ _cache = {'at': 0.0, 'data': None}
_lock = threading.Lock() _lock = threading.Lock()
def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object."""
try:
with open(path, encoding='utf-8') as handle:
data = json.load(handle)
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def section(configuration, name):
"""A section of the configuration, or {} when it is missing or not an object."""
value = configuration.get(name)
return value if isinstance(value, dict) else {}
def current():
"""What a request needs, read now: the features and facts setup resolved, and the token secret.
Before setup has run the router has no features, and without a secret /config and /users are
not served.
"""
state = read_json(STATE_FILE)
secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret')
return dict(state, jwt_secret=secret if isinstance(secret, str) else '')
def run(*args): def run(*args):
"""The finished process of a command, or None when it cannot be run.""" """The finished process of a command, or None when it cannot be run."""
try: try:
@@ -433,13 +473,12 @@ class Handler(BaseHTTPRequestHandler):
def status(self): def status(self):
with _lock: with _lock:
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS: if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
_cache['data'] = collect(self.server.state) _cache['data'] = collect(current())
_cache['at'] = time.monotonic() _cache['at'] = time.monotonic()
return _cache['data'] return _cache['data']
def authorized(self): def authorized(self, secret):
"""Whether the request carries a token signed with the secret; False also while no secret is set.""" """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', '') header = self.headers.get('Authorization', '')
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
@@ -450,15 +489,16 @@ class Handler(BaseHTTPRequestHandler):
if self.command not in ('GET', 'HEAD'): if self.command not in ('GET', 'HEAD'):
self.close_connection = True self.close_connection = True
protected = path == '/config' or path == '/users' or path.startswith('/users/') protected = path == '/config' or path == '/users' or path.startswith('/users/')
if protected and not self.server.state.get('jwt_secret'): state = current() if protected else {}
if protected and not state['jwt_secret']:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
elif protected and not self.authorized(): elif protected and not self.authorized(state['jwt_secret']):
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401 body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
extra.append(('WWW-Authenticate', 'Bearer')) extra.append(('WWW-Authenticate', 'Bearer'))
elif path == '/config' and self.command in ('GET', 'HEAD'): 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 body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
elif protected and path != '/config': elif protected and path != '/config':
code, document = users(self.server.state, self.command, path, self.request_body()) code, document = users(state, self.command, path, self.request_body())
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif self.command not in ('GET', 'HEAD'): elif self.command not in ('GET', 'HEAD'):
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
@@ -515,31 +555,65 @@ class Handler(BaseHTTPRequestHandler):
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args)) sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
def wanted():
"""Where the service should listen now, as (address, port), or the reason it should not listen."""
configuration = read_json(CONFIG_FILE)
web = section(configuration, 'web')
if web.get('enabled', True) is False:
return None, 'the web service is disabled in the configuration'
port = web.get('port', DEFAULT_PORT)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
port = DEFAULT_PORT
general, interfaces, local = (section(configuration, name) for name in ('general', 'interfaces', 'local'))
address = vpnrouter_net.internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
str(local.get('int_addr') or ''))
if not address:
return None, 'the internal address is not known yet'
return (address, port), ''
def log(message):
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
def discover():
"""Work out where to listen every DISCOVERY_SECONDS, and run the server thread there.
When the address or the port changes, the server thread is stopped and a new one listens at
the new place; while the service is disabled, the address is not known or it cannot be bound,
no server runs and the next round tries again.
"""
server, thread, listening, reported = None, None, None, None
while True:
target, reason = wanted()
if target != listening and server is not None:
server.shutdown()
server.server_close()
thread.join()
server, listening = None, None
if target is not None and server is None:
try:
server = ThreadingHTTPServer(target, Handler)
except OSError as exc:
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}'
else:
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True)
thread.start()
listening, reason = target, f'serving on http://{target[0]}:{target[1]}'
if reason and reason != reported:
log(reason)
reported = reason
time.sleep(DISCOVERY_SECONDS)
def main(): def main():
try:
with open(STATE_FILE, encoding='utf-8') as handle:
state = json.load(handle)
except (OSError, ValueError) as exc:
print(f'vpn-router-web: cannot read {STATE_FILE}: {exc}', file=sys.stderr)
return 1
if not state.get('enabled'):
print('vpn-router-web: the web service is disabled in the configuration')
return 0
if not state.get('bind'):
print('vpn-router-web: the router has no internal address yet', file=sys.stderr)
return 1
server = ThreadingHTTPServer((state['bind'], state['port']), Handler)
server.state = state
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
print(f'vpn-router-web: serving on http://{state["bind"]}:{state["port"]}') discovery = threading.Thread(target=discover, name='discovery', daemon=True)
discovery.start()
try: try:
server.serve_forever() discovery.join()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally:
server.server_close()
return 0 return 0