239 lines
13 KiB
Python
239 lines
13 KiB
Python
"""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))
|