#!/usr/bin/env python3 """POSIX integration: HTTPS reverse proxy -> Webka HTTP upstream, no mDNS.""" import base64 import hashlib import http.client from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path import signal import socket import ssl import subprocess import tempfile import threading import time import urllib.error import urllib.request root = Path(__file__).resolve().parents[1] def free_port(): with socket.socket() as sock: sock.bind(('127.0.0.1', 0)) return sock.getsockname()[1] upstream_port = free_port() proxy_port = free_port() homepage = f'https://127.0.0.1:{proxy_port}' class Proxy(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1' def log_message(self, *args): pass def forward(self): body = self.rfile.read(int(self.headers.get('Content-Length', 0))) headers = dict(self.headers) headers['X-Forwarded-Proto'] = 'https' headers['X-Forwarded-For'] = '192.168.1.99' connection = http.client.HTTPConnection('127.0.0.1', upstream_port, timeout=5) try: connection.request(self.command, self.path, body=body, headers=headers) response = connection.getresponse() self.send_response(response.status) for name, value in response.getheaders(): if name.lower() not in ['transfer-encoding', 'content-length', 'connection']: self.send_header(name, value) if response.status == 101: self.send_header('Connection', 'Upgrade') self.end_headers() self.wfile.flush() # Establish the WebSocket upgrade across TLS and forward its first server frame. # Explicit HTTPConnection upgrade retains the underlying socket. head = response.fp.read(2) self.wfile.write(head) size = head[1] & 127 if size == 126: extended = response.fp.read(2) self.wfile.write(extended) size = int.from_bytes(extended, 'big') self.wfile.write(response.fp.read(size)) self.wfile.flush() self.close_connection = True else: data = response.read() self.send_header('Content-Length', str(len(data))) self.send_header('Connection', 'close') self.end_headers() self.wfile.write(data) self.close_connection = True finally: connection.close() do_GET = forward do_POST = forward with tempfile.TemporaryDirectory(prefix='webka-proxy-test-') as temporary: directory = Path(temporary) cert, key = directory / 'cert.pem', directory / 'key.pem' subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(key), '-out', str(cert), '-days', '1', '-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1'], check=True, capture_output=True) context = ssl.create_default_context(cafile=str(cert)) data = directory / 'data' # Deliberately stale values must be overridden by homepage mode. env = dict(os.environ, WEBKA_DATA=str(data), WEBKA_MDNS='1', WEBKA_ORIGIN='https://old.local', WEBKA_ALLOWED_ORIGINS='http://old.example.com') proxy = ThreadingHTTPServer(('127.0.0.1', proxy_port), Proxy) server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) server_context.load_cert_chain(cert, key) proxy.socket = server_context.wrap_socket(proxy.socket, server_side=True) thread = threading.Thread(target=proxy.serve_forever, daemon=True) thread.start() logfile = directory / 'launch.log' try: with logfile.open('w') as output: proc = subprocess.Popen(['npm', 'run', 'dev:all', '--', '--homepage', homepage, '--port', str(upstream_port)], cwd=root, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True) try: for _ in range(1200): if proc.poll() is not None: raise AssertionError(logfile.read_text()) if 'Webka is ready!' in logfile.read_text(): break time.sleep(0.5) else: raise AssertionError('Startup timeout') assert 'mDNS: disabled' in logfile.read_text() assert f'Open in browser: {homepage}' in logfile.read_text() def request(path, body=None, origin=homepage, cookie=None): headers = {'Origin': origin, 'Content-Type': 'application/json'} if cookie: headers['Cookie'] = cookie req = urllib.request.Request(homepage + path, data=json.dumps(body).encode() if body is not None else None, headers=headers) return urllib.request.urlopen(req, context=context, timeout=5) with request('/login') as response: assert response.status == 200 and 'text/html' in response.headers['Content-Type'] credentials = {'username': 'owner', 'password': 'proxy-test-password123', 'setup_token': (data / 'setup-token').read_text()} with request('/api/bootstrap', credentials) as response: assert response.status == 201 with request('/api/login', credentials) as response: assert 'Secure' in response.headers['Set-Cookie'] assert 'HttpOnly' in response.headers['Set-Cookie'] cookie = response.headers['Set-Cookie'].split(';')[0] with request('/api/status', cookie=cookie) as response: assert json.load(response)['local_url'] == homepage + '/watch' with request('/api/session', cookie=cookie) as response: assert json.load(response)['role'] == 'admin' for wrong_origin in ['https://old.local', f'http://127.0.0.1:{upstream_port}', 'http://old.example.com']: try: request('/api/logout', {}, origin=wrong_origin, cookie=cookie) raise AssertionError('Unexpected origin allowed') except urllib.error.HTTPError as error: assert error.code == 403 # A real WSS handshake is forwarded to the HTTP WebSocket upstream. ws_key = base64.b64encode(os.urandom(16)).decode() conn = http.client.HTTPSConnection('127.0.0.1', proxy_port, context=context, timeout=5) conn.request('GET', '/api/signal?mode=host', headers={'Origin': homepage, 'Cookie': cookie, 'Connection': 'Upgrade', 'Upgrade': 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': ws_key}) response = conn.getresponse() assert response.status == 101 expected = base64.b64encode(hashlib.sha1((ws_key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode() assert response.headers['Sec-WebSocket-Accept'] == expected head = response.fp.read(2) size = head[1] & 127 if size == 126: size = int.from_bytes(response.fp.read(2), 'big') assert json.loads(response.fp.read(size))['type'] == 'welcome' conn.close() print('PASS: HTTPS proxy login, Secure cookie, public share URL, origin restrictions, WSS upgrade, mDNS disabled.', flush=True) finally: if proc.poll() is None: os.killpg(proc.pid, signal.SIGINT) try: proc.wait(timeout=12) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) proc.wait() raise AssertionError('Launcher did not stop') finally: proxy.shutdown() proxy.server_close() thread.join(timeout=5) with socket.socket() as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(('0.0.0.0', upstream_port)) print('PASS: proxy and launcher stopped; HTTP upstream port released.', flush=True)