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
+370 -139

No files matched your search

+108 -34
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
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