Files
linux-cloud-router/debian-package/src/usr/lib/vpn-router/manager
T

904 lines
43 KiB
Python
Executable File

#!/usr/bin/python3
"""Serve the Router Manager: the web application and its API, which passes calls to the router.
Run by vpn-router-manager.service. It uses python3-requests to call the router and
python3-psycopg to keep the sessions; its settings and its listening loop are in
vpnrouter_manager.
Pages:
/api/info the version of this package and the router it manages, as JSON
/api/session POST signs in through the router's /auth with {"name", "password", "code"?},
GET answers the signed-in user, DELETE signs out
/api/session/passkey
POST begins a sign-in with a passkey at the router's /auth/passkey;
/api/session/passkey/complete POST signs in through /auth/passkey/complete
/api/updates GET the installed and the candidate version of vpn-router
from apt (see updates()), POST updates apt's package lists (see start_check());
needs a session, POST a member of admins
/api/upgrade GET the state and the output of the upgrade of that package (see upgrade_state()),
POST starts it (see start_upgrade()); needs a session, POST a member of admins
/api/system-updates
GET the packages of the system apt-get upgrade would upgrade, with their installed
and candidate versions (see system_updates()); needs a session
/api/system-upgrade
GET the state and the output of the upgrade of the system's packages, POST starts
it (see start_upgrade()); needs a session, POST a member of admins
/api/reboot POST reboots the system (see start_reboot()); needs a member of admins
/api/shell a WebSocket handshake goes to the router's /shell, and the WebSocket is relayed
(see Handler.relay()); POST passes as every other path does
/api/<path> the router's web service at /<path>, with the access token of the session;
the method, the body and the answer pass unchanged; the files of a web site,
/api/sites/<name>/files/..., pass streamed for GET, HEAD and PUT (see Handler.transfer())
/docs/<file> a file of the help in DOCS_DIR, Markdown, SVG or PNG (see doc_file()); needs a session
/<file> the web application from WEB_DIR; a path that names no file gives index.html
A session is a row of the table sessions in PostgreSQL, found by the SHA-256 of a random id in
the HttpOnly cookie COOKIE; it holds the user's access and refresh tokens and ends with the
refresh token. An access token that expires within REFRESH_MARGIN seconds is renewed through
the router's /auth/refresh before a call; a refused renewal ends the session. /api/<path>
without a session answers 401. Sign-in calls carry the browser's address in X-Forwarded-For,
which the router reads for its rate limit when the manager is one of its trusted proxies.
Without router_url the API answers 503.
The manager first initializes, without listening: on the router, unless the settings name a
database, it creates its role, password and database when they are missing; then it connects to
the sessions database and creates its table, retrying every INIT_RETRY_SECONDS until it succeeds
and logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found
libpq in the linker cache, where psycopg looks it up.
The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it
works out where to listen, bind or the interfaces listen names, and runs a server at each address
(vpnrouter_manager.serve). Every request
reads the settings when it is made.
"""
import argparse
import hashlib
import http.cookies
import json
import mimetypes
import os
import re
import secrets
import signal
import socket
import ssl
import subprocess
import sys
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import psycopg
import requests
import urllib3
import vpnrouter_manager
from vpnrouter_manager import DATABASE, DATABASE_SECRET, ROUTER_CONFIG_FILE
#: The address a proxy on this host calls from.
LOOPBACK = '127.0.0.1'
CONFIG_FILE = vpnrouter_manager.CONFIG_FILE
WEB_DIR = '/usr/share/vpn-router/manager/web'
DOCS_DIR = '/usr/share/vpn-router/manager/docs'
#: The files of DOCS_DIR /docs serves, by extension, and their content types.
DOC_TYPES = {'.md': 'text/markdown; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png'}
#: The headers of every /docs answer: no type guessing, and an SVG opened on its own runs no script.
DOC_HEADERS = (('X-Content-Type-Options', 'nosniff'), ('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'"))
#: The cookie that holds the session id.
COOKIE = 'vpn_router_manager_session'
#: Seconds before its expiry an access token is renewed.
REFRESH_MARGIN = 30
#: Seconds between two attempts of the initialization.
INIT_RETRY_SECONDS = 15
#: The packages /api/updates reports.
PACKAGES = ('vpn-router',)
#: Touched by apt at every successful update of its package lists.
APT_UPDATED = '/var/lib/apt/periodic/update-success-stamp'
#: The transient units of the upgrade, of the reboot and of the update of the package lists, outside the manager's unit.
UPGRADE_UNIT = 'vpn-router-manager-upgrade'
SYSTEM_UPGRADE_UNIT = 'vpn-router-manager-system-upgrade'
REBOOT_UNIT = 'vpn-router-manager-reboot'
CHECK_UNIT = 'vpn-router-manager-check'
#: Seconds the update of the package lists waits for apt's lock, which Ubuntu's apt-daily or unattended-upgrades may hold.
CHECK_LOCK_SECONDS = 300
#: The upgrade helper, and the state and the output it records of the router's package and of the system's.
UPGRADE = '/usr/lib/vpn-router/upgrade'
UPGRADE_STATE = '/var/lib/vpn-router/upgrade.json'
UPGRADE_LOG = '/var/lib/vpn-router/upgrade.log'
SYSTEM_UPGRADE_STATE = '/var/lib/vpn-router/system-upgrade.json'
SYSTEM_UPGRADE_LOG = '/var/lib/vpn-router/system-upgrade.log'
#: A package apt-get -s upgrade would upgrade: Inst <name> [<installed>] (<candidate> <release> [<architecture>]).
SIMULATED = re.compile(r'^Inst (\S+) (?:\[([^\]]*)\] )?\((\S+)')
#: The last lines of the upgrade's output /api/upgrade answers.
LOG_LINES = 200
#: Written by packages that need a reboot, with the packages in REBOOT_REQUIRED.pkgs.
REBOOT_REQUIRED = '/var/run/reboot-required'
#: Seconds between the answer of /api/reboot and the reboot.
REBOOT_DELAY = 5
SCHEMA = ('CREATE TABLE IF NOT EXISTS sessions (id_hash text PRIMARY KEY, name text NOT NULL, access_token text NOT NULL, '
'access_expires timestamptz NOT NULL, refresh_token text NOT NULL, expires timestamptz NOT NULL)')
#: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30
#: Seconds a call to the router may take; adding a user creates an RSA key.
ROUTER_TIMEOUT = 130
#: The largest request body passed to the router, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
BODY_LIMIT = 65536
#: The largest body of a save of the Shell page's editor, /api/shell/editor/...: a file of up to 1 MiB, as the router
#: takes, escaped in JSON.
EDITOR_BODY_LIMIT = 2 * 1024 * 1024 + BODY_LIMIT
#: The paths of a web site's files, whose GET, HEAD and PUT pass streamed (see Handler.transfer()).
SITE_FILES = re.compile(r'^/api/sites/[^/]+/files(/.*)?$')
def settings():
return vpnrouter_manager.settings(CONFIG_FILE)
def package_version():
try:
result = subprocess.run(['dpkg-query', '-W', '-f=${Version}', 'vpn-router'],
capture_output=True, text=True, timeout=5)
except (OSError, subprocess.TimeoutExpired):
return None
return result.stdout.strip() if result.returncode == 0 else None
def updates():
"""The installed and the candidate version of each of PACKAGES, from apt-cache policy, and whether an update is available.
The candidate is the version apt would install from the sources it is configured with, as of
its last update of the package lists, whose time checked gives (APT_UPDATED, None when it is
not known); a version apt does not know is None. checking is whether start_check's update of
the package lists runs. Where apt cannot be asked, available is False and the packages are empty.
"""
checking = unit_active(CHECK_UNIT)
try:
result = subprocess.run(['apt-cache', 'policy', *PACKAGES], capture_output=True, text=True, timeout=30)
except (OSError, subprocess.TimeoutExpired):
return {'available': False, 'checked': None, 'checking': checking, 'packages': []}
try:
checked = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(os.path.getmtime(APT_UPDATED)))
except OSError:
checked = None
found = {}
package = None
for line in result.stdout.splitlines():
if line and not line[0].isspace() and line.endswith(':'):
package = found.setdefault(line[:-1], {'name': line[:-1], 'installed': None, 'candidate': None})
elif package is not None and line.strip().startswith(('Installed:', 'Candidate:')):
key, _, value = line.strip().partition(': ')
package[key.lower()] = None if value == '(none)' else value
packages = [dict(found.get(name, {'name': name, 'installed': None, 'candidate': None})) for name in PACKAGES]
for package in packages:
package['update'] = package['installed'] is not None and package['candidate'] not in (None, package['installed'])
return {'available': True, 'checked': checked, 'checking': checking, 'packages': packages}
def system_updates():
"""The packages of the system apt-get upgrade would upgrade, from its simulation (apt-get -s), as updates() answers
its packages, each with update true, sorted by name; the other keys are those of updates().
The simulation takes no lock and changes nothing; it answers what the package lists of apt's last update give.
"""
checking = unit_active(CHECK_UNIT)
try:
result = subprocess.run(['apt-get', '-s', '-q', 'upgrade'], capture_output=True, text=True, timeout=60,
env=dict(os.environ, LC_ALL='C'))
except (OSError, subprocess.TimeoutExpired):
return {'available': False, 'checked': None, 'checking': checking, 'packages': []}
if result.returncode != 0:
return {'available': False, 'checked': None, 'checking': checking, 'packages': []}
try:
checked = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(os.path.getmtime(APT_UPDATED)))
except OSError:
checked = None
found = {}
for line in result.stdout.splitlines():
match = SIMULATED.match(line)
if match:
found[match[1]] = {'name': match[1], 'installed': match[2], 'candidate': match[3], 'update': True}
return {'available': True, 'checked': checked, 'checking': checking, 'packages': [found[name] for name in sorted(found)]}
def unit_active(unit):
"""Whether a unit of systemd is active or activating; False where systemctl cannot be asked."""
try:
result = subprocess.run(['systemctl', 'is-active', f'{unit}.service', f'{unit}.timer'],
capture_output=True, text=True, timeout=5)
except (OSError, subprocess.TimeoutExpired):
return False
return any(line.strip() in ('active', 'activating') for line in result.stdout.splitlines())
def upgrade_state(system=False):
"""The state of the upgrade of the router's package, as GET /api/upgrade answers it, or with system of the system's
packages, as GET /api/system-upgrade answers it.
state is idle before the first upgrade, running, succeeded or failed as the helper recorded it, and interrupted when
it recorded running but its unit is not active, as after a reboot during the upgrade; the other keys of the helper's
state follow, and log holds the last LOG_LINES lines of its output. reboot_required and reboot_packages tell whether
a package asks for a reboot, and rebooting whether one is about to happen.
"""
state_file, log_file = (SYSTEM_UPGRADE_STATE, SYSTEM_UPGRADE_LOG) if system else (UPGRADE_STATE, UPGRADE_LOG)
try:
with open(state_file, encoding='utf-8') as handle:
recorded = json.load(handle)
except (OSError, ValueError):
recorded = {}
if not isinstance(recorded, dict):
recorded = {}
running = unit_active(SYSTEM_UPGRADE_UNIT if system else UPGRADE_UNIT)
state = recorded.get('state', 'idle')
if running:
state = 'running'
elif state == 'running':
state = 'interrupted'
try:
with open(log_file, encoding='utf-8', errors='replace') as handle:
log = handle.read().splitlines()[-LOG_LINES:]
except OSError:
log = []
try:
with open(f'{REBOOT_REQUIRED}.pkgs', encoding='utf-8') as handle:
reboot_packages = sorted({line.strip() for line in handle if line.strip()})
except OSError:
reboot_packages = []
return {
'state': state,
'user': recorded.get('user'),
'started': recorded.get('started'),
'finished': recorded.get('finished'),
'exit_status': recorded.get('exit_status'),
'before': recorded.get('before'),
'after': recorded.get('after'),
'log': log,
'reboot_required': os.path.exists(REBOOT_REQUIRED),
'reboot_packages': reboot_packages,
'rebooting': unit_active(REBOOT_UNIT),
}
def transient(*args):
"""Start a transient unit with systemd-run and the arguments; the HTTP status and the message of a refusal, or None."""
try:
result = subprocess.run(['systemd-run', '--quiet', '--collect', *args], capture_output=True, text=True, timeout=15)
except (OSError, subprocess.TimeoutExpired) as exc:
return 503, f'systemd-run cannot be run here: {exc}'
if result.returncode != 0:
message = result.stderr.strip() or f'systemd-run ended with status {result.returncode}'
return (409 if 'already' in message.lower() else 500), message
return None
def busy():
"""Whether an upgrade, of the router's package or of the system's, or a reboot runs."""
return unit_active(UPGRADE_UNIT) or unit_active(SYSTEM_UPGRADE_UNIT) or unit_active(REBOOT_UNIT)
def start_upgrade(name, system=False):
"""Start the upgrade of the router's package in the transient unit UPGRADE_UNIT, or with system of the system's
packages in SYSTEM_UPGRADE_UNIT, outside this service, so the restarts it makes do not end it.
The HTTP status and the JSON document: 202 when it started, 409 while an upgrade or a reboot runs, 503 where apt
cannot be used.
"""
if busy():
return 409, {'error': 'an upgrade or a reboot is already running'}
if not os.path.exists('/usr/bin/apt-get'):
return 503, {'error': 'apt is not available here'}
if system:
refused = transient(f'--unit={SYSTEM_UPGRADE_UNIT}', '--description=VPN Router system upgrade',
'--property=Type=exec', '--no-block', '/usr/bin/python3', UPGRADE, '--system', name)
else:
refused = transient(f'--unit={UPGRADE_UNIT}', '--description=VPN Router package upgrade', '--property=Type=exec',
'--no-block', '/usr/bin/python3', UPGRADE, name)
if refused:
return refused[0], {'error': refused[1]}
return 202, {'started': True}
def start_check():
"""Update apt's package lists in the transient unit CHECK_UNIT, as root outside this service's sandbox, and touch
APT_UPDATED when it succeeds, so updates() answers the new candidates and the time of the check.
The HTTP status and the JSON document: 202 when it started, 409 while an update of the lists, an upgrade or a
reboot runs, 503 where apt cannot be used.
"""
if unit_active(CHECK_UNIT) or busy():
return 409, {'error': 'a check for updates, an upgrade or a reboot is already running'}
if not os.path.exists('/usr/bin/apt-get'):
return 503, {'error': 'apt is not available here'}
refused = transient(f'--unit={CHECK_UNIT}', '--description=VPN Router package lists update', '--property=Type=oneshot',
f'--property=ExecStartPost=/usr/bin/touch {APT_UPDATED}', '--no-block',
'/usr/bin/apt-get', '-q', '-o', f'DPkg::Lock::Timeout={CHECK_LOCK_SECONDS}', 'update')
if refused:
return refused[0], {'error': refused[1]}
return 202, {'checking': True}
def start_reboot():
"""Reboot the system REBOOT_DELAY seconds from now, so the answer reaches the browser first.
The HTTP status and the JSON document: 202 when the reboot is scheduled, 409 while an upgrade or a reboot runs.
"""
if busy():
return 409, {'error': 'an upgrade or a reboot is already running'}
refused = transient(f'--unit={REBOOT_UNIT}', '--description=VPN Router reboot', f'--on-active={REBOOT_DELAY}',
'/usr/bin/systemctl', 'reboot')
if refused:
return refused[0], {'error': refused[1]}
return 202, {'rebooting': True}
def admin(current, access, client):
"""Whether the signed-in user is a member of the router's admins, as the router's /account answers."""
response = router_call(current, 'GET', '/account', None, {'Authorization': f'Bearer {access}', 'X-Forwarded-For': client})
try:
return response.ok and response.json().get('admin') is True
except ValueError:
return False
def error(code, message):
return code, 'application/json', json.dumps({'error': message}).encode() + b'\n'
class Unavailable(Exception):
"""A setting the API needs is missing, or the router or the database cannot be reached; the HTTP status and message."""
def __init__(self, code, message):
super().__init__(message)
self.code = code
def router_call(current, method, path, body=None, headers=None):
"""The router's answer to a call; Unavailable when it cannot be reached."""
if not current['router_url']:
raise Unavailable(503, f'no router is configured; set router_url in {CONFIG_FILE}')
headers = dict(headers or {}, Accept='application/json')
if body is not None:
headers['Content-Type'] = 'application/json'
try:
return requests.request(method, current['router_url'] + path, data=body, headers=headers,
timeout=ROUTER_TIMEOUT, allow_redirects=False)
except requests.RequestException as failure:
raise Unavailable(502, f'the router at {current["router_url"]} cannot be reached: {failure}')
def answer_of(response):
"""The HTTP status, the content type and the body to pass on for an answer of the router."""
kind = response.headers.get('Content-Type', '')
if response.ok or kind.startswith('application/json'):
return response.status_code, kind or 'application/json', response.content
return error(response.status_code, f'the router answered {response.status_code}: {response.text.strip()}')
def database(current):
"""A connection to the sessions database, with its table; Unavailable when it is not configured or fails."""
if not current['database']:
raise Unavailable(503, f'no session database is configured; set database in {CONFIG_FILE}')
try:
connection = psycopg.connect(current['database'], autocommit=True)
connection.execute(SCHEMA)
except psycopg.Error as failure:
raise Unavailable(503, f'the session database cannot be used: {failure}')
return connection
def psql(sql):
"""The output of an SQL statement, read from standard input, run by psql as postgres with DATABASE as the variable v1."""
result = subprocess.run(['runuser', '-u', 'postgres', '--', 'psql', '-X', '-q', '-t', '-A', '-v', 'ON_ERROR_STOP=1',
'-v', f'v1={DATABASE}'], input=sql, capture_output=True, text=True)
if result.returncode != 0:
raise OSError(f'psql failed: {result.stderr.strip()}')
return result.stdout.strip()
def provision():
"""Create the role, its password file and the database on the router, each when missing.
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
"""
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'"):
if os.path.exists(DATABASE_SECRET):
os.unlink(DATABASE_SECRET)
psql('CREATE ROLE :"v1" LOGIN')
if not os.path.exists(DATABASE_SECRET):
password = secrets.token_urlsafe(32)
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'")
os.makedirs(os.path.dirname(DATABASE_SECRET), exist_ok=True)
descriptor = os.open(DATABASE_SECRET, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
handle.write(password + '\n')
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'"):
psql('CREATE DATABASE :"v1" OWNER :"v1"')
def initialize():
"""Create the sessions database on the router when it is missing and connect to it, retrying every INIT_RETRY_SECONDS."""
reported = None
while True:
phase = 'reading the settings'
try:
configured = vpnrouter_manager.text_setting(vpnrouter_manager.read_json(CONFIG_FILE), 'database')
if not configured and os.path.exists(ROUTER_CONFIG_FILE):
phase = 'creating the sessions database'
provision()
phase = 'connecting to the sessions database'
database(settings()).close()
except (OSError, Unavailable) as exc:
failure = f'{phase} failed: {exc}'
if failure != reported:
log(f'{failure}; retrying every {INIT_RETRY_SECONDS} seconds')
reported = failure
time.sleep(INIT_RETRY_SECONDS)
continue
log('ready')
return
def digest(session_id):
return hashlib.sha256(session_id.encode()).hexdigest()
def tokens_of(response):
"""The tokens of a 200 answer of /auth or /auth/refresh, or None when it holds none."""
try:
document = response.json()
values = (document['access_token'], int(document['expires_in']), document['refresh_token'],
int(document['refresh_expires_in']))
except (ValueError, KeyError, TypeError):
return None
return values if response.status_code == 200 and all(values) else None
def sign_in(current, path, body, client):
"""Sign in through the router's path, /auth or /auth/passkey/complete: the HTTP status, the content type, the body
and the session id, or None."""
response = router_call(current, 'POST', path, body, {'X-Forwarded-For': client})
tokens = tokens_of(response)
if tokens is None:
return (*answer_of(response), None)
try:
name = response.json()['name']
except (ValueError, KeyError, TypeError):
return (*error(502, 'the router named no user'), None)
session_id = secrets.token_urlsafe(32)
access, access_seconds, refresh, refresh_seconds = tokens
with database(current) as connection:
connection.execute('DELETE FROM sessions WHERE expires < now()')
connection.execute('INSERT INTO sessions VALUES (%s, %s, %s, now() + make_interval(secs => %s), %s, '
'now() + make_interval(secs => %s))',
(digest(session_id), name, access, access_seconds, refresh, refresh_seconds))
return 200, 'application/json', json.dumps({'name': name}).encode() + b'\n', session_id
def session(current, session_id, client):
"""The user name and a current access token of the session, renewed when it expires soon, or None."""
if not session_id:
return None
with database(current) as connection:
with connection.transaction():
row = connection.execute('SELECT name, access_token, access_expires < now() + make_interval(secs => %s), '
'refresh_token FROM sessions WHERE id_hash = %s AND expires > now() FOR UPDATE',
(REFRESH_MARGIN, digest(session_id))).fetchone()
if row is None:
return None
name, access, stale, refresh = row
if not stale:
return name, access
response = router_call(current, 'POST', '/auth/refresh', json.dumps({'refresh_token': refresh}).encode(),
{'X-Forwarded-For': client})
tokens = tokens_of(response)
if tokens is None:
connection.execute('DELETE FROM sessions WHERE id_hash = %s', (digest(session_id),))
return None
access, access_seconds, refresh, refresh_seconds = tokens
connection.execute('UPDATE sessions SET access_token = %s, access_expires = now() + make_interval(secs => %s), '
'refresh_token = %s, expires = now() + make_interval(secs => %s) WHERE id_hash = %s',
(access, access_seconds, refresh, refresh_seconds, digest(session_id)))
return name, access
def sign_out(current, session_id):
"""End the session and revoke its refresh token at the router."""
if not session_id:
return
with database(current) as connection:
row = connection.execute('DELETE FROM sessions WHERE id_hash = %s RETURNING refresh_token',
(digest(session_id),)).fetchone()
if row:
router_call(current, 'POST', '/auth/logout', json.dumps({'refresh_token': row[0]}).encode())
def static(path):
"""A file of the web application; a path that names no file gives index.html, so the application routes it."""
root = os.path.realpath(WEB_DIR)
name = os.path.realpath(os.path.join(root, path.lstrip('/')))
if not name.startswith(root + os.sep) or not os.path.isfile(name):
name = os.path.join(root, 'index.html')
try:
with open(name, 'rb') as handle:
data = handle.read()
except OSError:
return 404, 'text/plain; charset=utf-8', b'Not found\n'
return 200, mimetypes.guess_type(name)[0] or 'application/octet-stream', data
def doc_file(path, root=DOCS_DIR):
"""The file of the help that a /docs/<file> path names and its content type, or None: a file inside root whose
extension DOC_TYPES lists."""
root = os.path.realpath(root)
name = os.path.realpath(os.path.join(root, urllib.parse.unquote(path).lstrip('/')))
kind = DOC_TYPES.get(os.path.splitext(name)[1].lower())
if kind is None or not name.startswith(root + os.sep) or not os.path.isfile(name):
return None
return name, kind
class Stream:
"""A request body of length bytes read from a stream as requests sends it, which takes its length from len()."""
def __init__(self, stream, length):
self.stream = stream
self.length = length
self.left = length
def __len__(self):
return self.length
def read(self, size=-1):
size = self.left if size is None or size < 0 else min(size, self.left)
data = self.stream.read(size) if size else b''
self.left -= len(data)
return data
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def session_id(self):
"""The session id of the request's cookie, or ''."""
cookies = http.cookies.SimpleCookie()
try:
cookies.load(self.headers.get('Cookie', ''))
except http.cookies.CookieError:
return ''
return cookies[COOKIE].value if COOKIE in cookies else ''
def proxied(self):
"""True for a call from a proxy on this host, such as the router's HTTPS proxy, which names the client."""
return self.client_address[0] == LOOPBACK and 'X-Forwarded-For' in self.headers
def client(self):
"""The browser's address: the last X-Forwarded-For entry of a proxied call, else the peer."""
if self.proxied():
return self.headers['X-Forwarded-For'].split(',')[-1].strip()
return self.client_address[0]
def api(self, path, route, payload):
"""Answer an /api/ request: the HTTP status, the content type, the body and the Set-Cookie value or None."""
current = settings()
client = self.client()
session_id = self.session_id()
if route == '/api/session/passkey' and self.command == 'POST':
return (*answer_of(router_call(current, 'POST', '/auth/passkey', b'{}', {'X-Forwarded-For': client})), None)
if route in ('/api/session', '/api/session/passkey/complete') and self.command == 'POST':
code, kind, body, created = sign_in(current, '/auth' if route == '/api/session' else '/auth/passkey/complete',
payload, client)
if created is None:
return code, kind, body, None
return code, kind, body, f'{COOKIE}={created}; HttpOnly; SameSite=Strict; Path=/'
if route == '/api/session' and self.command == 'DELETE':
sign_out(current, session_id)
return 200, 'application/json', b'{"signed_out": true}\n', f'{COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0'
found = session(current, session_id, client)
if found is None:
return (*error(401, 'not signed in'), None)
name, access = found
if route == '/api/session':
return 200, 'application/json', json.dumps({'name': name}).encode() + b'\n', None
if route == '/api/updates' and self.command in ('GET', 'HEAD'):
return 200, 'application/json', json.dumps(updates()).encode() + b'\n', None
if route == '/api/updates' and self.command == 'POST':
if not admin(current, access, client):
return (*error(403, f'user {name} is not a member of admins'), None)
code, document = start_check()
return code, 'application/json', json.dumps(document).encode() + b'\n', None
if route == '/api/system-updates' and self.command in ('GET', 'HEAD'):
return 200, 'application/json', json.dumps(system_updates()).encode() + b'\n', None
if route in ('/api/upgrade', '/api/system-upgrade') and self.command in ('GET', 'HEAD'):
return 200, 'application/json', json.dumps(upgrade_state(route == '/api/system-upgrade')).encode() + b'\n', None
if route in ('/api/upgrade', '/api/system-upgrade', '/api/reboot') and self.command == 'POST':
if not admin(current, access, client):
return (*error(403, f'user {name} is not a member of admins'), None)
code, document = start_reboot() if route == '/api/reboot' else start_upgrade(name, route == '/api/system-upgrade')
return code, 'application/json', json.dumps(document).encode() + b'\n', None
return (*answer_of(router_call(current, self.command, path[4:], payload,
{'Authorization': f'Bearer {access}', 'X-Forwarded-For': client})), None)
def relay(self, path):
"""Pass the WebSocket handshake of /api/shell to the router's /shell with the session's access token, then
relay the bytes both ways until either side closes; the HTTP status, the content type and the body of a
refusal, or None after a relayed WebSocket.
The handshake must come from a page of this host (Origin), and asks the router for no extension, so the
frames pass unchanged.
"""
current = settings()
client = self.client()
if urllib.parse.urlsplit(self.headers.get('Origin', '')).netloc != self.headers.get('Host', ''):
return error(403, 'the shell must be opened from a page of the Router Manager')
found = session(current, self.session_id(), client)
if found is None:
return error(401, 'not signed in')
if not current['router_url']:
raise Unavailable(503, f'no router is configured; set router_url in {CONFIG_FILE}')
url = urllib.parse.urlsplit(current['router_url'] + path[4:])
secure = url.scheme == 'https'
request = '\r\n'.join([
f'GET {url.path}?{url.query} HTTP/1.1', f'Host: {url.netloc}', 'Upgrade: websocket', 'Connection: Upgrade',
f'Sec-WebSocket-Key: {self.headers.get("Sec-WebSocket-Key", "")}',
f'Sec-WebSocket-Version: {self.headers.get("Sec-WebSocket-Version", "")}',
f'Authorization: Bearer {found[1]}', f'X-Forwarded-For: {client}', '', ''])
try:
upstream = socket.create_connection((url.hostname, url.port or (443 if secure else 80)), timeout=ROUTER_TIMEOUT)
if secure:
upstream = ssl.create_default_context().wrap_socket(upstream, server_hostname=url.hostname)
upstream.sendall(request.encode())
reader = upstream.makefile('rb')
code = int(reader.readline().split(b' ', 2)[1])
headers = {}
while (line := reader.readline().strip()):
name, _, value = line.decode('latin-1').partition(':')
headers[name.strip().lower()] = value.strip()
if code != 101:
body = reader.read(int(headers.get('content-length', '0')))
upstream.close()
return code, headers.get('content-type', 'application/json'), body
upstream.settimeout(None)
except (OSError, ValueError, IndexError) as failure:
raise Unavailable(502, f'the router at {current["router_url"]} cannot open a shell: {failure}')
self.close_connection = True
try:
self.send_response(101, 'Switching Protocols')
self.send_header('Upgrade', 'websocket')
self.send_header('Connection', 'Upgrade')
self.send_header('Sec-WebSocket-Accept', headers.get('sec-websocket-accept', ''))
self.end_headers()
except ConnectionError:
# The client went away before the shell was relayed; the router ends the shell when upstream closes.
upstream.close()
return None
def pump(source, target):
try:
while data := source.read1(65536):
target.sendall(data)
except OSError:
pass
finally:
for end in (upstream, self.connection):
try:
end.shutdown(socket.SHUT_RDWR)
except OSError:
pass
thread = threading.Thread(target=pump, args=(reader, self.connection), name='shell output', daemon=True)
thread.start()
pump(self.rfile, upstream)
thread.join()
upstream.close()
return None
def transfer(self, path, send_body):
"""Pass a GET, HEAD or PUT of a web site's files to the router with the session's access token, streaming the
request body and the answer; the HTTP status, the content type and the body of a refusal, or None once the
router's answer was passed on.
The body of a PUT goes to the router as it arrives, with its Content-Type and Content-Length, so it is not held
to BODY_LIMIT; the answer passes with its Content-Type, Content-Length and Content-Disposition.
"""
current = settings()
client = self.client()
found = session(current, self.session_id(), client)
if found is None:
return error(401, 'not signed in')
if not current['router_url']:
raise Unavailable(503, f'no router is configured; set router_url in {CONFIG_FILE}')
headers = {'Authorization': f'Bearer {found[1]}', 'X-Forwarded-For': client}
body = None
if self.command == 'PUT':
length = self.headers.get('Content-Length', '')
if not length.isdigit():
return error(411, 'the body needs a Content-Length')
headers['Content-Type'] = self.headers.get('Content-Type') or 'application/octet-stream'
body = Stream(self.rfile, int(length))
try:
response = requests.request(self.command, current['router_url'] + path[4:], data=body, headers=headers,
timeout=ROUTER_TIMEOUT, allow_redirects=False, stream=True)
except requests.RequestException as failure:
raise Unavailable(502, f'the router at {current["router_url"]} cannot be reached: {failure}')
with response:
try:
self.send_response(response.status_code)
for name in ('Content-Type', 'Content-Length', 'Content-Disposition'):
if name in response.headers:
self.send_header(name, response.headers[name])
self.send_header('Cache-Control', 'no-store')
self.end_headers()
if send_body:
for chunk in response.raw.stream(65536, decode_content=False):
self.wfile.write(chunk)
except ConnectionError:
# The client went away before the answer was passed on.
self.close_connection = True
except urllib3.exceptions.HTTPError as failure:
# The router's answer broke off after its headers were passed on.
self.close_connection = True
log(f'the router\'s answer to {self.command} {path.split("?", 1)[0]} broke off: {failure}')
return None
def doc(self, path):
"""Answer GET /docs/<path> with a file of the help (doc_file): the HTTP status, the content type and the body; 401
without a session, 404 for a path that names no such file."""
try:
if session(settings(), self.session_id(), self.client()) is None:
return error(401, 'not signed in')
except Unavailable as failure:
return error(failure.code, str(failure))
except psycopg.Error as failure:
return error(503, f'the session database failed: {failure}')
found = doc_file(path)
if found is None:
return 404, 'text/plain; charset=utf-8', b'Not found\n'
try:
with open(found[0], 'rb') as handle:
return 200, found[1], handle.read()
except OSError:
return 404, 'text/plain; charset=utf-8', b'Not found\n'
def answer(self, send_body):
path = self.path.split('#', 1)[0]
route = path.split('?', 1)[0]
cookie = None
# 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
if route == '/api/info':
https = self.proxied() and self.headers.get('X-Forwarded-Proto') == 'https'
status = vpnrouter_manager.status_url(CONFIG_FILE, self.headers.get('Host', ''), self.server.server_address[0], https)
body = json.dumps({'version': package_version(), 'status_url': status or None}).encode()
code, kind = 200, 'application/json'
elif route == '/api/shell' and self.command == 'GET' and self.headers.get('Upgrade', '').lower() == 'websocket':
try:
refused = self.relay(path)
except Unavailable as failure:
refused = error(failure.code, str(failure))
except psycopg.Error as failure:
refused = error(503, f'the session database failed: {failure}')
if refused is None:
return
code, kind, body = refused
elif SITE_FILES.match(route) and self.command in ('GET', 'HEAD', 'PUT'):
try:
refused = self.transfer(path, send_body)
except Unavailable as failure:
refused = error(failure.code, str(failure))
except psycopg.Error as failure:
refused = error(503, f'the session database failed: {failure}')
if refused is None:
return
code, kind, body = refused
elif route.startswith('/api/'):
limit = EDITOR_BODY_LIMIT if route.startswith('/api/shell/editor/') else BODY_LIMIT
payload = self.request_body(limit)
if payload is False:
code, kind, body = error(413, f'the body is larger than {limit} bytes')
else:
try:
code, kind, body, cookie = self.api(path, route, payload)
except Unavailable as failure:
code, kind, body = error(failure.code, str(failure))
except psycopg.Error as failure:
code, kind, body = error(503, f'the session database failed: {failure}')
elif route.startswith('/docs/') and self.command in ('GET', 'HEAD'):
code, kind, body = self.doc(route[len('/docs/'):])
elif self.command in ('GET', 'HEAD'):
code, kind, body = static(route)
else:
code, kind, body = 405, 'text/plain; charset=utf-8', b'Method not allowed\n'
try:
self.send_response(code)
self.send_header('Content-Type', kind)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store' if route.startswith('/api/') or not route.startswith('/assets/') else 'max-age=31536000, immutable')
if cookie:
self.send_header('Set-Cookie', cookie)
for name, value in DOC_HEADERS if route.startswith('/docs/') else ():
self.send_header(name, value)
self.end_headers()
if send_body:
self.wfile.write(body)
except ConnectionError:
# The client went away before the answer was written.
self.close_connection = True
def request_body(self, limit=BODY_LIMIT):
"""The request body, None when there is none, or False when it is larger than limit bytes."""
length = self.headers.get('Content-Length')
if not length:
return None
try:
size = int(length)
except ValueError:
return False
if not 0 <= size <= limit:
return False
return self.rfile.read(size)
def do_GET(self):
self.answer(True)
def do_HEAD(self):
self.answer(False)
def do_POST(self):
self.answer(True)
def do_PUT(self):
self.answer(True)
def do_DELETE(self):
self.answer(True)
def log_message(self, format, *args):
sys.stderr.write('%s - - [%s] %s\n' % (self.client(), self.log_date_time_string(), format % args))
def log(message):
print(f'vpn-router-manager: {message}', file=sys.stderr, flush=True)
def main():
global CONFIG_FILE
parser = argparse.ArgumentParser(description='Serve the VPN Router Manager.')
parser.add_argument('--config', default=CONFIG_FILE, help=f'the settings file (default: {CONFIG_FILE})')
CONFIG_FILE = parser.parse_args().config
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
initialize()
try:
vpnrouter_manager.serve(lambda: vpnrouter_manager.listen(CONFIG_FILE), Handler, log, DISCOVERY_SECONDS)
except KeyboardInterrupt:
pass
return 0
if __name__ == '__main__':
sys.exit(main())