feature: the web service creates its key and database itself and shows its starting phase.
Release / release (push) Successful in 29s
Release / release (push) Successful in 29s
This commit is contained in:
1 parent
4f1aa282f6
commit
2e2bacee7d
8 files changed
+245
-37
No files matched your search
@@ -30,6 +30,12 @@ default the address the service listens on, is the last entry of its X-Forwarded
|
||||
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 service starts in a starting phase, while an initialization thread makes the signing key and
|
||||
the refresh tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are
|
||||
ready. Meanwhile / and /status show only that phase and its failure, with the state, step and
|
||||
failure of vpn-router-setup from SETUP_STATE, and answer 503; every other path answers 503 with
|
||||
the phase. Once the initialization succeeds the service serves the pages above.
|
||||
|
||||
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
|
||||
@@ -64,6 +70,12 @@ import vpnrouter_users
|
||||
STATE_FILE = '/run/vpn-router/web.json'
|
||||
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||
|
||||
#: The state, the step and the failure of the last run of vpn-router-setup.
|
||||
SETUP_STATE = '/run/vpn-router/setup.json'
|
||||
|
||||
#: Seconds between two attempts of the initialization.
|
||||
INIT_RETRY_SECONDS = 15
|
||||
|
||||
#: The port when web.port is not set.
|
||||
DEFAULT_PORT = 7081
|
||||
|
||||
@@ -92,6 +104,10 @@ USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
|
||||
_cache = {'at': 0.0, 'data': None}
|
||||
_lock = threading.Lock()
|
||||
|
||||
#: The initialization: its current phase, the failure of the last attempt, and whether it has succeeded.
|
||||
_init = {'phase': 'starting', 'error': None, 'ready': False}
|
||||
_init_lock = threading.Lock()
|
||||
|
||||
#: The times of the recent /auth calls of each client address.
|
||||
_auth_calls = collections.defaultdict(collections.deque)
|
||||
_auth_lock = threading.Lock()
|
||||
@@ -434,9 +450,9 @@ def users(state, method, path, body):
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except psycopg.Error as exc:
|
||||
return 500, {'error': f'the token database failed: {exc}'}
|
||||
except SystemExit:
|
||||
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason.
|
||||
return 500, {'error': 'the router configuration cannot be read'}
|
||||
except SystemExit as exc:
|
||||
# vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
|
||||
return 500, {'error': str(exc.code)}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
@@ -538,8 +554,8 @@ def auth(path, body):
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except psycopg.Error as exc:
|
||||
return 500, {'error': f'the token database failed: {exc}'}
|
||||
except SystemExit:
|
||||
return 500, {'error': 'the router configuration cannot be read'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
|
||||
|
||||
def auth_wait(address):
|
||||
@@ -604,7 +620,24 @@ class Handler(BaseHTTPRequestHandler):
|
||||
payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None
|
||||
if claims is not None:
|
||||
self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
|
||||
if (protected or signing) and not state['key']:
|
||||
with _init_lock:
|
||||
ready = _init['ready']
|
||||
if not ready:
|
||||
starting = starting_status()
|
||||
code = 503
|
||||
if path == '/status':
|
||||
body, kind = json.dumps(starting, indent=2).encode() + b'\n', 'application/json'
|
||||
elif path == '/':
|
||||
agent = self.headers.get('User-Agent', '').lower()
|
||||
if 'curl' in agent or 'wget' in agent:
|
||||
body, kind = starting_text(starting).encode(), 'text/plain; charset=utf-8'
|
||||
else:
|
||||
body, kind = starting_html(starting).encode(), 'text/html; charset=utf-8'
|
||||
else:
|
||||
document = {'error': f'the web service is starting: {starting["service"]["phase"]}', **starting}
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
extra.append(('Retry-After', str(INIT_RETRY_SECONDS)))
|
||||
elif (protected or signing) and not state['key']:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
elif signing and self.command != 'POST':
|
||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||
@@ -704,6 +737,85 @@ def log(message):
|
||||
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def phase(name):
|
||||
with _init_lock:
|
||||
_init['phase'] = name
|
||||
|
||||
|
||||
def initialize():
|
||||
"""Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready."""
|
||||
reported = None
|
||||
while True:
|
||||
try:
|
||||
phase('reading the configuration')
|
||||
cfg = vpnrouter.load_config()
|
||||
phase('making the signing key')
|
||||
vpnrouter_auth.ensure_key()
|
||||
phase('creating the token database')
|
||||
vpnrouter_auth.provision(cfg)
|
||||
phase('connecting to the token database')
|
||||
vpnrouter_auth.connect(cfg).close()
|
||||
except (OSError, psycopg.Error, SystemExit) as exc:
|
||||
with _init_lock:
|
||||
_init['error'] = f'{_init["phase"]} failed: {exc.code if isinstance(exc, SystemExit) else exc}'
|
||||
error = _init['error']
|
||||
if error != reported:
|
||||
log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds')
|
||||
reported = error
|
||||
time.sleep(INIT_RETRY_SECONDS)
|
||||
continue
|
||||
with _init_lock:
|
||||
_init.update(phase='ready', error=None, ready=True)
|
||||
log('ready')
|
||||
return
|
||||
|
||||
|
||||
def starting_status():
|
||||
"""What / and /status show while the service starts: its phase and failure, and the state of vpn-router-setup."""
|
||||
with _init_lock:
|
||||
service = {'phase': _init['phase'], 'error': _init['error']}
|
||||
setup = read_json(SETUP_STATE)
|
||||
return {
|
||||
'status': 'starting',
|
||||
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
'service': service,
|
||||
'setup': {key: setup.get(key) for key in ('state', 'step', 'error', 'time')} if setup else None,
|
||||
}
|
||||
|
||||
|
||||
def starting_text(status):
|
||||
"""The starting status for people, as plain text."""
|
||||
service, setup = status['service'], status['setup']
|
||||
lines = ['VPN Router', 'Status: starting', '', f'Web service: {service["phase"]}']
|
||||
if service['error']:
|
||||
lines.append(f'Web service failure: {service["error"]}, retrying every {INIT_RETRY_SECONDS} s')
|
||||
if setup is None:
|
||||
lines.append('Setup: has not run yet')
|
||||
else:
|
||||
lines.append(f'Setup: {setup["state"]}, step {setup["step"]}, at {setup["time"]}')
|
||||
if setup['error']:
|
||||
lines.append(f'Setup failure: {setup["error"]}')
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def starting_html(status):
|
||||
"""The starting status for a browser: the text in a page that reloads itself every INIT_RETRY_SECONDS."""
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta http-equiv="refresh" content="{INIT_RETRY_SECONDS}">
|
||||
<title>VPN Router - starting</title>
|
||||
</head>
|
||||
<body>
|
||||
<pre>{escape(starting_text(status))}</pre>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def discover():
|
||||
"""Work out where to listen every DISCOVERY_SECONDS, and run the server thread there.
|
||||
|
||||
@@ -736,6 +848,7 @@ def discover():
|
||||
|
||||
def main():
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||
threading.Thread(target=initialize, name='initialization', daemon=True).start()
|
||||
discovery = threading.Thread(target=discover, name='discovery', daemon=True)
|
||||
discovery.start()
|
||||
try:
|
||||
|
||||
Reference in new issue
Block a user