183 lines
7.3 KiB
Python
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))
|