14ae67f0b8
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
75 lines
3.1 KiB
Python
75 lines
3.1 KiB
Python
# SPIKE evidence tool (2026-09-29): a minimal "browser" — per-host cookie jar (host-only cookies, as a browser keeps
|
|
# them), follows redirects across hosts, every host resolved to guest 9202's traefik. Never prints a password or a cookie.
|
|
import http.client, json, re, ssl, urllib.parse
|
|
|
|
IP = "192.168.0.114"
|
|
CTX = ssl._create_unverified_context() # scratch guest 9202 serves traefik's self-signed default cert on the LAN
|
|
|
|
|
|
class Browser:
|
|
def __init__(self, name):
|
|
self.name = name
|
|
self.jar = {} # host -> {cookie: value}
|
|
|
|
def req(self, url, method="GET", body=None, headers=None, follow=True, accept="text/html"):
|
|
hops = []
|
|
for _ in range(12):
|
|
u = urllib.parse.urlsplit(url)
|
|
host = u.hostname
|
|
path = u.path + ("?" + u.query if u.query else "")
|
|
h = {"Host": host, "Accept": accept, "User-Agent": "felhom-spike-browser"}
|
|
ck = self.jar.get(host, {})
|
|
if ck:
|
|
h["Cookie"] = "; ".join(f"{k}={v}" for k, v in ck.items())
|
|
h.update(headers or {})
|
|
data = body
|
|
if isinstance(body, dict):
|
|
data = json.dumps(body).encode()
|
|
h["Content-Type"] = "application/json"
|
|
elif isinstance(body, str):
|
|
data = body.encode()
|
|
conn = http.client.HTTPSConnection(IP, 443, context=CTX, timeout=30)
|
|
conn.connect = _sni_connect(conn, host)
|
|
conn.request(method, path, body=data, headers=h)
|
|
r = conn.getresponse()
|
|
rb = r.read()
|
|
for k, v in r.getheaders():
|
|
if k.lower() == "set-cookie":
|
|
nv = v.split(";", 1)[0]
|
|
n, _, val = nv.partition("=")
|
|
if "max-age=-1" in v.lower() or val == "":
|
|
self.jar.setdefault(host, {}).pop(n, None)
|
|
else:
|
|
self.jar.setdefault(host, {})[n] = val
|
|
hops.append((r.status, host, u.path))
|
|
loc = r.getheader("Location")
|
|
if follow and r.status in (301, 302, 303, 307, 308) and loc:
|
|
url = urllib.parse.urljoin(url, loc)
|
|
if r.status in (301, 302, 303):
|
|
method, body = "GET", None
|
|
continue
|
|
return r.status, rb.decode("utf-8", "replace"), hops
|
|
return 0, "", hops
|
|
|
|
def login_dashboard(self, domain, password):
|
|
# The dashboard's own login form (POST /login), exactly as walk.login() does it.
|
|
return self.req(f"https://felhom.{domain}/login", "POST",
|
|
body="password=" + urllib.parse.quote(password),
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
|
|
def hosts_with_cookies(self):
|
|
return {h: sorted(v) for h, v in self.jar.items()}
|
|
|
|
|
|
def _sni_connect(conn, host):
|
|
import socket
|
|
|
|
def connect():
|
|
sock = socket.create_connection((IP, 443), timeout=30)
|
|
conn.sock = CTX.wrap_socket(sock, server_hostname=host)
|
|
return connect
|
|
|
|
|
|
def hopstr(hops):
|
|
return " -> ".join(f"{s} {h}{p}" for s, h, p in hops)
|