diff --git a/ADR.md b/ADR.md index 320c065..4d16768 100644 --- a/ADR.md +++ b/ADR.md @@ -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 -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 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 -Status: Accepted +Status: Accepted; amended by ADR-7 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 @@ -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 user administration. The API is protected by the token of `/config`, so whoever has the secret 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. diff --git a/README.md b/README.md index b9b3ecc..18a92ca 100644 --- a/README.md +++ b/README.md @@ -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 ``` -`vpn-router-setup` opens `web.port` on the internal interface in the firewall, writes the address, -the port, the secret and the facts for `/config` to `/run/vpn-router/web.json` (readable by root -only) and restarts the service, so a changed `web` section -applies with `systemctl restart vpn-router-setup`. The service ends without an error when -`web.enabled` is `false`, and keeps trying to start until the router has an internal address. +The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every +30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs, +and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port +changes, and stops listening while `web.enabled` is `false` or the address is not known. Each +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 diff --git a/build-and-publish.pl b/build-and-publish.pl new file mode 100755 index 0000000..bce5461 --- /dev/null +++ b/build-and-publish.pl @@ -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()) diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index f208d70..cdaafd1 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -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 Sat, 24 Oct 2026 10:00:00 +0200 + vpn-router (0.5.0-1) unstable; urgency=medium * OpenVPN P2S access, set in the openvpn section (enabled, address_pool, diff --git a/debian-package/debian/install b/debian-package/debian/install index 3713ae7..b07a169 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -1,6 +1,7 @@ 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/vpnrouter_net.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/ diff --git a/debian-package/debian/vpn-router.vpn-router-web.service b/debian-package/debian/vpn-router.vpn-router-web.service index d52f84e..10a5b05 100644 --- a/debian-package/debian/vpn-router.vpn-router-web.service +++ b/debian-package/debian/vpn-router.vpn-router-web.service @@ -1,7 +1,7 @@ [Unit] Description=VPN Router status web service 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 [Service] @@ -11,7 +11,7 @@ Restart=on-failure RestartSec=15 NoNewPrivileges=yes 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 PrivateTmp=yes diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index b432ebc..cab3bb4 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -378,15 +378,13 @@ def refresh_docker_rules(): 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 - router, the secret that signs its tokens, the configuration with its - secrets masked and the facts resolved from the system from a file here, so - that it needs nothing but the standard library and the configuration code. - The file is readable by root only. The service ends quietly when - web.enabled is false. The restart is queued, not waited for, because the - unit is ordered after vpn-router-setup. + The service reads the file at each request: the features of the router, + the configuration with its secrets masked and the facts resolved from the + system, which it cannot work out with the standard library alone. The file + is readable by root only. The service follows a changed address or port + itself. """ masked = { 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.chmod(0o600) 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)), 'p2s': bool(vpnrouter.p2s_ready(ctx)), 'wireguard': bool(vpnrouter.wg_ready(ctx)), @@ -419,7 +413,6 @@ def refresh_web(cfg, ctx): 'remote_cidrs': ctx['remote_cidr_list'], }, }) + '\n', encoding='utf-8') - run(['systemctl', 'restart', '--no-block', 'vpn-router-web.service'], check=False) def main(): diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 63c440f..7dfac67 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -21,6 +21,7 @@ if str(LIB_DIR) not in sys.path: import vpnrouter_ldap import vpnrouter_platforms +from vpnrouter_net import 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') @@ -92,10 +93,6 @@ MODES = ('manual', 'interfaces', 'auto') PLATFORM_NONE = 'none' -class ConfigError(Exception): - """A setting is present but malformed.""" - - def warn(message): 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 -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): """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 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() if 'via' in fields: 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() if len(fields) >= 4 and fields[3].split('/')[0] == address: network = ipaddress.ip_interface(fields[3]).network @@ -399,34 +347,6 @@ def detect_gateway(iface, address): 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): """Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip). diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_net.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_net.py new file mode 100644 index 0000000..7efdacd --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_net.py @@ -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 '' diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 55ea337..cad9d7d 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -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 WireGuard. No configuration data, key or address is shown. -The address to bind to, the port, the secret and the configuration shown by /config -are not read from the configuration here: vpn-router-setup resolves them, with the -interfaces, and writes them to /run/vpn-router/web.json, readable by root only, before -it restarts this service. The service ends with -success when the file says the service is disabled, and with failure, to be -started again, when the file is missing or the router has no internal address yet. +The service needs nothing else to start and does not end on its own. A discovery thread works +out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with +web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts +it, moves it when the address or the port changes, and stops it while the service is disabled +or the address is not known. Every request reads what it needs when it is made: the token secret +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 @@ -42,7 +44,18 @@ import time from html import escape 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' +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 CACHE_SECONDS = 2 @@ -73,6 +86,33 @@ _cache = {'at': 0.0, 'data': None} _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): """The finished process of a command, or None when it cannot be run.""" try: @@ -433,13 +473,12 @@ class Handler(BaseHTTPRequestHandler): def status(self): with _lock: 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() 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.""" - 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 @@ -449,16 +488,17 @@ class Handler(BaseHTTPRequestHandler): # A body that is not read must not be taken for the next request. if self.command not in ('GET', 'HEAD'): self.close_connection = True - protected =path == '/config' or path == '/users' or path.startswith('/users/') - if protected and not self.server.state.get('jwt_secret'): + protected = path == '/config' or path == '/users' or path.startswith('/users/') + 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 - 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 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 + body, kind, code = json.dumps(details(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()) + code, document = users(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 @@ -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)) +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(): - 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)) - print(f'vpn-router-web: serving on http://{state["bind"]}:{state["port"]}') + discovery = threading.Thread(target=discover, name='discovery', daemon=True) + discovery.start() try: - server.serve_forever() + discovery.join() except KeyboardInterrupt: pass - finally: - server.server_close() return 0