Files
Webka/scripts/test-proxy.py
2026-09-06 17:42:55 +05:00

165 lines
8.2 KiB
Python

#!/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)