#!/usr/bin/env python3
"""Loopback network regression for custom routes and TLS-terminating proxies.
Requires websockets and openssl. Uses an actual verified HTTPS proxy, HTTP
upstream, and isolated server data; never contacts an external service.
"""
import argparse
import asyncio
import base64
from contextlib import contextmanager
import http.client
import json
from pathlib import Path
import select
import socket
import socketserver
import ssl
import subprocess
import tempfile
import threading
import time
import xml.etree.ElementTree as ET
import websockets
ORIGIN = 'https://browser.example'
WS_PATH = '/private/xmpp/socket'
BOSH_PATH = '/private/xmpp/bind'
PUBLIC_WS = '/public/chat/socket'
PUBLIC_BOSH = '/public/chat/bind'
BOSH = 'http://jabber.org/protocol/httpbind'
FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing'
SASL = 'urn:ietf:params:xml:ns:xmpp-sasl'
CLIENT = 'jabber:client'
BIND = 'urn:ietf:params:xml:ns:xmpp-bind'
PASSWORD = 'proxy-integration-password'
PLAIN = base64.b64encode(f'\0alice\0{PASSWORD}'.encode()).decode()
def free_port():
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
return probe.getsockname()[1]
def request(port, path, method='GET', body=b'', origin=ORIGIN,
forwarded=None, context=None, address='127.0.0.1', upgrade=False, rfc=False):
headers = {'Host': f'localhost:{port}', 'Connection': 'close'}
if origin is not None:
headers['Origin'] = origin
if forwarded is not None:
headers['Forwarded' if rfc else 'X-Forwarded-Proto'] = (
f'for=198.51.100.77;proto={forwarded}' if rfc else forwarded)
if method == 'POST':
headers['Content-Type'] = 'text/xml'
if upgrade:
headers.update({'Connection': 'keep-alive, Upgrade', 'Upgrade': 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Protocol': 'xmpp'})
connection = (http.client.HTTPSConnection(address, port, context=context, timeout=8)
if context else http.client.HTTPConnection(address, port, timeout=8))
try:
connection.request(method, path, body=body, headers=headers)
response = connection.getresponse()
return response.status, dict(response.getheaders()), response.read()
finally:
connection.close()
@contextmanager
def server(binary, config, root):
path = root / 'config.json'
path.write_text(json.dumps(config))
with (root / 'server.log').open('w+') as log:
process = subprocess.Popen([binary, 'serve', str(path), '--experimental'],
stdout=log, stderr=log)
try:
for _ in range(100):
assert process.poll() is None, 'server exited during startup'
try:
with socket.create_connection(('127.0.0.1', config['global']['http_ports'][0]), .1):
break
except OSError:
time.sleep(.05)
else:
raise AssertionError('HTTP listener startup timed out')
yield
except BaseException:
log.flush()
log.seek(0)
print('SERVER LOG:\n' + log.read())
raise
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
class Proxy(socketserver.ThreadingTCPServer):
"""Small TLS-terminating, path-rewriting TCP proxy supporting WS upgrades."""
daemon_threads = True
allow_reuse_address = True
def __init__(self, backend, tls, source='127.0.0.2', proto='https', rfc=False):
self.backend, self.tls, self.source, self.proto = backend, tls, source, proto
self.rfc = rfc
self.errors = []
super().__init__(('127.0.0.1', 0), ProxyHandler)
def get_request(self):
stream, peer = super().get_request()
try:
return self.tls.wrap_socket(stream, server_side=True), peer
except BaseException:
stream.close()
raise
def handle_error(self, request, client_address):
import traceback
self.errors.append(traceback.format_exc())
class ProxyHandler(socketserver.BaseRequestHandler):
def handle(self):
client = self.request
client.settimeout(8)
incoming = b''
while b'\r\n\r\n' not in incoming:
chunk = client.recv(4096)
if not chunk:
return
incoming += chunk
assert len(incoming) < 65536, 'oversized proxy request headers'
head, payload = incoming.split(b'\r\n\r\n', 1)
lines = head.decode('latin1').split('\r\n')
method, path, version = lines[0].split(' ', 2)
for public, internal in ((PUBLIC_WS, WS_PATH), (PUBLIC_BOSH, BOSH_PATH)):
if path == public or path.startswith(public + '/') or path.startswith(public + '?'):
path = internal + path[len(public):]
break
# The fixture models a proxy which replaces client-supplied metadata.
headers = [line for line in lines[1:] if line.split(':', 1)[0].lower()
not in ('forwarded', 'x-forwarded-for', 'x-forwarded-proto')]
if self.server.rfc:
headers += [f'Forwarded: for=198.51.100.77;proto={self.server.proto}']
else:
headers += ['X-Forwarded-For: 198.51.100.7', f'X-Forwarded-Proto: {self.server.proto}']
outgoing = ('\r\n'.join([f'{method} {path} {version}', *headers]) + '\r\n\r\n').encode('latin1')
with socket.create_connection(('127.0.0.1', self.server.backend), timeout=8,
source_address=(self.server.source, 0)) as upstream:
upstream.sendall(outgoing + payload)
while True:
# SSLSocket may already have decrypted bytes buffered internally.
readable = [client] if client.pending() else select.select([client, upstream], [], [], 8)[0]
if not readable:
return
for source in readable:
try:
data = source.recv(65536)
if not data:
return
(upstream if source is client else client).sendall(data)
except (ConnectionResetError, BrokenPipeError, ssl.SSLEOFError):
# Raw handshake probes intentionally close after HTTP101.
return
@contextmanager
def proxy(backend, tls, **kwargs):
with Proxy(backend, tls, **kwargs) as fixture:
worker = threading.Thread(target=fixture.serve_forever, daemon=True)
worker.start()
try:
yield fixture.server_address[1]
assert not fixture.errors, '\n'.join(fixture.errors)
finally:
fixture.shutdown()
worker.join(timeout=5)
async def websocket_exchange(port, context, path, authenticate=False):
async with websockets.connect(f'wss://localhost:{port}{path}', ssl=context,
origin=ORIGIN, subprotocols=['xmpp'], open_timeout=8) as ws:
assert ws.subprotocol == 'xmpp'
await ws.send(f'