Files
ft/CodexOutput/upserver/network_fault_proxy.py

183 lines
7.3 KiB
Python

"""Loopback-only, fixed-upstream transport fixture. Never logs headers or payloads."""
import http.client
import json
import select
import socket
import socketserver
import threading
import time
from pathlib import Path
from urllib.parse import urlsplit, parse_qs
ROOT = Path(__file__).parent
HOST, PORT = 's101.local', 18080
lock = threading.Lock()
state = dict(mode='pass', blocked_until=0, upgrades=0, active=0, negotiate=0,
login_forwarded=0, faults=0, errors=0)
channels = set()
def snapshot():
with lock:
return {k: v for k, v in state.items() if k != 'blocked_until'}
class Handler(socketserver.BaseRequestHandler):
def reply(self, code, body=b'', extra=()):
headers = [('Content-Length', str(len(body))), ('Connection', 'close'), *extra]
data = f'HTTP/1.1 {code} Result\r\n' + ''.join(f'{k}: {v}\r\n' for k, v in headers)
self.request.sendall(data.encode('latin1') + b'\r\n' + body)
def handle(self):
upstream = None
registered = False
try:
self.request.settimeout(25)
incoming = b''
while b'\r\n\r\n' not in incoming:
chunk = self.request.recv(4096)
if not chunk:
return
incoming += chunk
if len(incoming) > 65536:
return
head, body = incoming.split(b'\r\n\r\n', 1)
lines = head.decode('latin1').split('\r\n')
method, target, _ = lines[0].split(' ')
headers = dict(line.split(':', 1) for line in lines[1:])
lower = {k.lower(): v.strip() for k, v in headers.items()}
parsed = urlsplit(target)
path = parsed.path
if path == '/__status':
self.reply(200, json.dumps(snapshot()).encode())
return
if path == '/__control':
mode = parse_qs(parsed.query).get('mode', [''])[0]
if mode not in ('pass', '401', 'timeout', 'reset', 'drop', 'stop'):
self.reply(400)
return
with lock:
if mode == 'drop':
state['blocked_until'] = time.monotonic() + 3
victims = list(channels)
else:
state['mode'] = mode
victims = []
for peer in victims:
try:
peer.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self.reply(200, b'OK')
if mode == 'stop':
threading.Thread(target=self.server.shutdown, daemon=True).start()
return
if path == '/gamehub/negotiate':
with lock:
state['negotiate'] += 1
self.reply(403)
return
if path == '/gamehub' and lower.get('upgrade', '').lower() == 'websocket':
with lock:
blocked = time.monotonic() < state['blocked_until']
if blocked:
self.reply(503)
return
upstream = socket.create_connection((HOST, PORT), timeout=8)
rebuilt = [lines[0]] + [f'{k}: {v.strip()}' for k, v in headers.items() if k.lower() != 'host']
rebuilt.append(f'Host: {HOST}:{PORT}')
upstream.sendall(('\r\n'.join(rebuilt) + '\r\n\r\n').encode('latin1') + body)
response = b''
while b'\r\n\r\n' not in response:
response += upstream.recv(4096)
if len(response) > 65536:
raise ValueError('oversized upgrade')
self.request.sendall(response)
if not response.startswith(b'HTTP/1.1 101 '):
return
with lock:
state['upgrades'] += 1
state['active'] += 1
channels.add(self.request)
registered = True
peers = (self.request, upstream)
while True:
ready, _, _ = select.select(peers, [], [], 1)
for src in ready:
chunk = src.recv(65536)
if not chunk:
return
(upstream if src is self.request else self.request).sendall(chunk)
return
if method != 'POST' or path not in ('/account/Login', '/playerdata/Get'):
self.reply(403)
return
size = int(lower.get('content-length', '0'))
if size > 2048 or 'transfer-encoding' in lower:
self.reply(400)
return
while len(body) < size:
chunk = self.request.recv(size - len(body))
if not chunk:
return
body += chunk
payload = json.loads(body[:size])
if (path == '/account/Login' and payload not in ({'playerId': '101'}, {'playerId': '102'})) or (path == '/playerdata/Get' and payload != {}):
self.reply(403)
return
with lock:
mode = state['mode']
if path == '/account/Login' and mode in ('401', 'timeout', 'reset'):
state['mode'] = 'pass'
state['faults'] += 1
else:
mode = 'pass'
if mode == '401':
self.reply(401)
return
if mode == 'timeout':
time.sleep(18)
self.reply(504)
return
if mode == 'reset':
self.request.shutdown(socket.SHUT_RDWR)
return
conn = http.client.HTTPConnection(HOST, PORT, timeout=10)
try:
forward = {'Content-Type': 'application/json'}
if 'cookie' in lower:
forward['Cookie'] = lower['cookie']
conn.request('POST', path, body=body[:size], headers=forward)
res = conn.getresponse()
value = res.read()
extra = [(k, v) for k, v in res.getheaders() if k.lower() in ('set-cookie', 'content-type')]
self.reply(res.status, value, extra)
if path == '/account/Login':
with lock:
state['login_forwarded'] += 1
finally:
conn.close()
except (OSError, ValueError):
with lock:
state['errors'] += 1
finally:
if registered:
with lock:
channels.discard(self.request)
state['active'] -= 1
if upstream:
upstream.close()
class Server(socketserver.ThreadingTCPServer):
daemon_threads = True
with Server(('127.0.0.1', 0), Handler) as server:
(ROOT / 'network-proxy-address.json').write_text(json.dumps({'url': f'http://127.0.0.1:{server.server_address[1]}/'}))
print('Loopback transport fixture started; fixed upstream s101.local:18080', flush=True)
try:
server.serve_forever(poll_interval=0.2)
finally:
(ROOT / 'network-proxy-final.json').write_text(json.dumps(snapshot(), indent=2))