165 lines
8.2 KiB
Python
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)
|