"""The request handler of the web service: it reads a request, checks its token and passes it to the module of its path (answer).""" import json import sys import urllib.parse from http.server import BaseHTTPRequestHandler import ldap import vpnrouter import vpnrouter_auth import vpnrouter_net import vpnrouter_oidc import vpnrouter_pki import vpnrouter_sites from .auth import auth, auth_wait, refusal from .backup import backup from .common import BODY_LIMIT, CONFIG_FILE, current, log, read_json, section from .connectivity import remote_access, site_to_site from .dns import dns from .identities import account, groups, service_accounts, users from .logs import logs from .metrics import metrics_history, metrics_settings from .pki import PUBLISHED, ca from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_ticket from .sites import SITE_FILES, site_files, sites from .status import cached, details, html_of, postgresql_databases, shown, text_of class Handler(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1' def claims(self, secret): """The claims of the request's token signed with the secret, or None without an acceptable one.""" header = self.headers.get('Authorization', '') return vpnrouter_auth.verify(header[7:].strip(), secret) if header.startswith('Bearer ') else None def client(self): """The client address: the last X-Forwarded-For entry when the peer is a trusted proxy, else the peer.""" peer = self.client_address[0] trusted = section(read_json(CONFIG_FILE), 'web').get('trusted_proxies') or [self.server.server_address[0]] forwarded = self.headers.get('X-Forwarded-For', '') if isinstance(trusted, list) and peer in trusted and forwarded.strip(): return forwarded.split(',')[-1].strip() return peer def refusal(self, claims, path, payload): """The HTTP status and the message that refuse a request to a protected path, or None when it may be made.""" try: reason = refusal(claims, self.command, path, payload) except (ldap.LDAPError, OSError) as exc: return 500, f'the user directory failed: {exc}' except vpnrouter_sites.DatabaseError as exc: return 500, str(exc) return (403, reason) if reason else None def answer(self, send_body): path = self.path.split('?', 1)[0] extra = [] self.principal = 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 directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup', 'ca', 'shell', 'remote-access', 'logs', 'sites', 'dns') if path == f'/{name}' or path.startswith(f'/{name}/')), None) protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/postgresql') or \ directory is not None signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout') oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \ self.server.server_address[0] == vpnrouter_net.LOOPBACK files = SITE_FILES.match(path) if directory == 'sites' else None query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) state = current() if protected or signing else {} claims = self.claims(state['key']) if protected and state['key'] else None # The body of a file upload is read as a stream, after the request is allowed. payload = self.request_body(EDITOR_BODY_LIMIT if path.startswith('/shell/editor/') else BODY_LIMIT) \ if (protected or signing) and self.command not in ('GET', 'HEAD') and not (files and self.command == 'PUT') \ 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']: 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 elif signing: wait = auth_wait(self.client()) if path not in ('/auth/logout', '/auth/passkey') else 0 if wait: code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'} extra.append(('Retry-After', str(wait))) else: code, document = auth(path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif protected and claims is None: body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401 extra.append(('WWW-Authenticate', 'Bearer')) elif protected and (refused := self.refusal(claims, path, payload)): code, kind = refused[0], 'application/json' body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n' elif path == '/status/full' and self.command in ('GET', 'HEAD'): document = shown(cached(), 'full', self.client()) body, kind, code = json.dumps(document, indent=2).encode() + b'\n', 'application/json', 200 elif path == '/config' and self.command in ('GET', 'HEAD'): body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200 elif path == '/postgresql' and self.command in ('GET', 'HEAD'): body, kind, code = json.dumps(postgresql_databases(), indent=2).encode() + b'\n', 'application/json', 200 elif path == '/metrics/history' and self.command in ('GET', 'HEAD'): query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) code, document = metrics_history(query.get('range', ['hour'])[0]) body, kind = json.dumps(document).encode() + b'\n', 'application/json' elif path == '/metrics/settings': code, document = metrics_settings(self.command, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'): return shell(self, claims.get('sub')) elif directory == 'shell' and path.startswith('/shell/editor/'): code, document = shell_editor(claims.get('sub'), self.command, path.strip('/').split('/'), payload) body, kind = json.dumps(document).encode() + b'\n', 'application/json' elif directory == 'shell': code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \ else (404, {'error': f'no route {self.command} {path}'}) if code != 200: log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}') body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'account': code, document = account(claims['sub'], self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'site-to-site': code, document = site_to_site(self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'remote-access': code, document = remote_access(state, self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'backup': code, document = backup(self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'ca': code, document = ca(self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'logs': code, document = logs(self.command, path, query) body, kind = json.dumps(document).encode() + b'\n', 'application/json' elif files: answered = site_files(self, files.group(1), files.group(2) or '', query, payload, send_body) if answered is None: return code, document = answered body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'sites': code, document = sites(self.command, path, query, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'dns': code, document = dns(self.command, path, query, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'service-accounts': code, document = service_accounts(self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory is not None: code, document = (users if directory == 'users' else groups)(state, self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif oidc: wait = auth_wait(self.client()) if (self.command, path) == ('POST', '/oidc/login') else 0 if wait: code, kind = 429, 'application/json' body = json.dumps({'error': f'too many sign-in calls; retry in {wait} seconds'}).encode() + b'\n' extra.append(('Retry-After', str(wait))) else: code, kind, body, headers = vpnrouter_oidc.answer(self.command, self.path, self.headers, self.raw_body()) extra += headers elif self.command not in ('GET', 'HEAD'): body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \ (vpnrouter_pki.PUBLISH_DIR / path[5:]).is_file(): body, kind, code = (vpnrouter_pki.PUBLISH_DIR / path[5:]).read_bytes(), PUBLISHED[path[-4:]], 200 elif path in ('/status', '/'): document = shown(cached(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client()) agent = self.headers.get('User-Agent', '').lower() if path == '/status': body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif 'curl' in agent or 'wget' in agent: body, kind = text_of(document).encode(), 'text/plain; charset=utf-8' else: body, kind = html_of(document).encode(), 'text/html; charset=utf-8' code = 200 else: body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 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') for name, value in extra: self.send_header(name, value) self.end_headers() if send_body: self.wfile.write(body) def raw_body(self): """The request body's bytes, b'' without one or over BODY_LIMIT.""" try: size = int(self.headers.get('Content-Length') or 0) except ValueError: return b'' return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b'' def request_body(self, limit=BODY_LIMIT): """The request body as JSON, None when there is none, or the string 'invalid', also when it is larger than limit bytes.""" length = self.headers.get('Content-Length') if not length: return None try: size = int(length) if not 0 <= size <= limit: return 'invalid' return json.loads(self.rfile.read(size) or b'null') except ValueError: return 'invalid' 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): principal = getattr(self, 'principal', None) or '-' sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))