Files
framebridge/source/expanded/framebridge_expanded/connection_monitor.py
T

359 lines
15 KiB
Python

"""Private connection logs and evidence-based terminal status.
This module monitors the packaged tunnel client. A ready tunnel is not evidence
that the intended ChatGPT account has completed consent or made a tool call.
"""
from __future__ import annotations
import datetime
import fcntl
import http.client
import ipaddress
import json
import math
import os
from pathlib import Path
import re
import selectors
import stat
import threading
import time
import uuid
import unicodedata
from urllib.parse import urlsplit
MAX_LINE = 64 * 1024
MAX_LOG = 2 * 1024 * 1024
LOG_COPIES = 3
POLL_FRESH_SECONDS = 75
_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))")
class ConnectionMonitorError(RuntimeError):
"""A safe public error; details belong only in sanitized logs."""
def _private_directory(path):
path = Path(path).absolute()
for part in [path, *path.parents]:
if part.is_symlink():
raise ConnectionMonitorError("The private connection directory is unsafe.")
try:
path.mkdir(mode=0o700)
except FileExistsError:
pass
info = path.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise ConnectionMonitorError("The private connection directory is unsafe.")
return path
def redact(text, secret):
"""Normalize terminal controls before redacting and persisting a record."""
text = _ANSI.sub("", str(text))
text = "".join(c for c in text if c in "\n\t" or not unicodedata.category(c).startswith("C"))
if secret:
text = text.replace(secret, "[REDACTED]")
# A child may exit after emitting only a credential prefix.
tail = text.rstrip("\r\n")
suffix = text[len(tail):]
for length in range(min(len(secret) - 1, len(tail)), 7, -1):
if tail.endswith(secret[:length]):
text = tail[:-length] + "[REDACTED]" + suffix
break
text = re.sub(r"\bsk-[A-Za-z0-9_-]{8,}", "[REDACTED]", text)
# Preserve a useful proxy/server location without credential-bearing userinfo.
text = re.sub(r"(?i)(\b[a-z][a-z0-9+.-]*://)[^/\s?#]*@", r"\1[REDACTED]@", text)
# Conservative whole-tail redaction prevents partial Cookie/password leaks.
text = re.sub(r"(?im)(\b(?:authorization|proxy-authorization|api[_-]?key|access[_-]?token|"
r"refresh[_-]?token|password|passwd|cookie|set-cookie)\b[\"']?\s*[:=]\s*).*$",
r"\1[REDACTED]", text)
return text
class PrivateLog:
"""A bounded rotating log, protected against symlinks and shared files."""
def __init__(self, state, secret, *, max_bytes=MAX_LOG):
root = _private_directory(state)
root = _private_directory(root / "logs")
self.directory = _private_directory(root / "chatgpt")
self.path = self.directory / "connection.log"
self.secret = secret
self.max_bytes = max_bytes
self.directory_fd = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
self.lock_fd = self._open("connection.lock", os.O_RDWR | os.O_CREAT)
except Exception:
os.close(self.directory_fd)
raise
self.mutex = threading.Lock()
def _open(self, name, flags):
fd = os.open(name, flags | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, 0o600, dir_fd=self.directory_fd)
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
or info.st_nlink != 1 or info.st_mode & 0o077):
os.close(fd)
raise ConnectionMonitorError("The private connection log is unsafe.")
return fd
def _validate_existing(self, name):
try:
fd = self._open(name, os.O_RDONLY)
except FileNotFoundError:
return False
os.close(fd)
return True
def write(self, text):
clean = redact(str(text), self.secret)
# All callers provide a complete bounded logical record.
if len(clean.encode("utf-8")) > MAX_LINE * 4:
clean = "[Oversized log record omitted]"
stamp = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
data = (stamp + " " + clean.rstrip("\r\n") + "\n").encode("utf-8", errors="replace")
with self.mutex:
deadline = time.monotonic() + 1.5
while True:
try:
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise ConnectionMonitorError("The private connection log is busy.") from None
time.sleep(0.02)
try:
fd = self._open("connection.log", os.O_WRONLY | os.O_APPEND | os.O_CREAT)
try:
size = os.fstat(fd).st_size
finally:
os.close(fd)
if size + len(data) > self.max_bytes:
existing = {i: self._validate_existing("connection.log" + ("." + str(i) if i else ""))
for i in range(LOG_COPIES)}
if existing[LOG_COPIES - 1]:
os.unlink("connection.log." + str(LOG_COPIES - 1), dir_fd=self.directory_fd)
for i in range(LOG_COPIES - 2, -1, -1):
if existing[i]:
os.rename("connection.log" + ("." + str(i) if i else ""),
"connection.log." + str(i + 1),
src_dir_fd=self.directory_fd, dst_dir_fd=self.directory_fd)
fd = self._open("connection.log", os.O_WRONLY | os.O_APPEND | os.O_CREAT)
try:
view = memoryview(data)
while view:
count = os.write(fd, view)
if count <= 0:
raise OSError("Short private log write")
view = view[count:]
finally:
os.close(fd)
finally:
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
def close(self):
self.secret = None
for name in ("lock_fd", "directory_fd"):
fd = getattr(self, name, None)
if fd is not None:
os.close(fd)
setattr(self, name, None)
class Attempt:
def __init__(self, state):
root = _private_directory(state)
self.root = _private_directory(root / "connection-runtime")
self.directory = _private_directory(self.root / uuid.uuid4().hex)
self.health_file = self.directory / "health.url"
def close(self):
# Remove only this attempt's own known file, never a symlink or unknown data.
try:
info = self.health_file.lstat()
if stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid() and info.st_nlink == 1:
self.health_file.unlink()
except FileNotFoundError:
pass
try:
self.directory.rmdir()
except OSError:
pass
def health_endpoint(path):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1
or info.st_mode & 0o022 or not 0 < info.st_size <= 512):
raise ConnectionMonitorError("The tunnel health endpoint is unsafe.")
text = os.read(fd, 513).decode("ascii").strip()
finally:
os.close(fd)
value = urlsplit(text)
try:
address = ipaddress.ip_address(value.hostname or "")
port = value.port
except ValueError:
raise ConnectionMonitorError("The tunnel health endpoint is unsafe.") from None
if (value.scheme != "http" or not address.is_loopback or not port or "%" in text
or value.username or value.password or value.path not in ("", "/")
or value.query or value.fragment):
raise ConnectionMonitorError("The tunnel health endpoint is unsafe.")
return str(address), port
def _get(endpoint, path, limit):
# Direct literal-loopback HTTP: no DNS, inherited proxy, or redirects.
connection = http.client.HTTPConnection(*endpoint, timeout=0.6)
try:
connection.request("GET", path, headers={"Connection": "close"})
response = connection.getresponse()
body = response.read(limit + 1)
if len(body) > limit:
raise ConnectionMonitorError("The tunnel health response is too large.")
return response.status, body
finally:
connection.close()
def probe(health_file, expected_tunnel, expected_mcp, started, *, now=None):
"""Return a public state and private detail from the pinned client contract."""
now = time.time() if now is None else now
try:
endpoint = health_endpoint(health_file)
except FileNotFoundError:
return "starting", "Waiting for the tunnel health endpoint."
except (OSError, ValueError, ConnectionMonitorError):
return "checking", "The tunnel health endpoint is not available or valid."
try:
code, body = _get(endpoint, "/readyz", 8192)
message = body.decode("utf-8", errors="replace").strip()
if code != 200 or message != "ready":
return "checking", "MCP readiness is pending: " + message
code, body = _get(endpoint, "/api/status", 65536)
if code != 200:
return "checking", "Tunnel status is not available."
status = json.loads(body)
if not isinstance(status, dict):
return "checking", "Tunnel status has an unexpected format."
if (status.get("control_plane_tunnel_id") != expected_tunnel
or status.get("mcp_server_url") != expected_mcp):
return "checking", "Tunnel status does not match this profile."
created = datetime.datetime.fromisoformat(status["started_at"].replace("Z", "+00:00")).timestamp()
if not started - 5 <= created <= now + 5:
return "checking", "Tunnel status belongs to a different startup."
metadata = status.get("tunnel_metadata") or {}
if not isinstance(metadata, dict):
return "checking", "Tunnel metadata has an unexpected format."
if status.get("tunnel_metadata_error"):
detail = str(status["tunnel_metadata_error"])
if re.search(r"\b(?:401|403|unauthorized|forbidden)\b", detail, re.I):
return "rejected", "Tunnel access was rejected: " + detail
return "access", "Tunnel metadata request failed: " + detail
if metadata.get("ID") != expected_tunnel:
return "access", "Waiting for authenticated tunnel metadata."
code, body = _get(endpoint, "/metrics", 256 * 1024)
if code != 200:
return "access", "Waiting for authenticated polling evidence."
values = []
for line in body.decode("ascii", errors="replace").splitlines():
if line.startswith("#"):
continue
match = re.fullmatch(r"commands_poll_last_successful_timestamp_seconds(?:\{[^\n]*\})?\s+([0-9.eE+-]+)(?:\s+\d+)?", line)
if match:
values.append(float(match.group(1)))
if not values or not all(math.isfinite(v) for v in values):
return "access", "Waiting for authenticated polling evidence."
recent = max(values)
if recent < started - 5 or recent > now + 5 or now - recent > POLL_FRESH_SECONDS:
return "access", "A successful authenticated poll has not been confirmed recently."
return "ready", "Local MCP initialization, tunnel identity, metadata and recent authenticated polling verified."
except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException, ConnectionMonitorError):
return "checking", "The tunnel health check is temporarily unavailable."
class Progress:
def __init__(self, output):
self.output = output
self.tty = bool(getattr(output, "isatty", lambda: False)())
self.state = None
self.message = ""
self.width = 0
self.index = 0
def change(self, state, message):
if self.state == state and self.message == message:
return
self.clear()
self.state, self.message = state, message
self.output.write(message + "\n")
self.output.flush()
def clear(self):
if self.tty and self.width:
self.output.write("\r" + " " * self.width + "\r")
self.width = 0
def tick(self):
if self.tty and self.state not in {"ready", "closed", "error", "rejected", "unverified"}:
text = "Connecting " + "|/-\\"[self.index % 4] + " Press Ctrl+C to cancel."
self.index += 1
self.width = max(self.width, len(text))
self.output.write("\r" + text.ljust(self.width))
self.output.flush()
def capture_output(stream, log, errors, stop):
"""Drain binary output without ever forwarding it to the terminal."""
buffer = bytearray()
dropping = False
try:
fd = stream.fileno()
selector = selectors.DefaultSelector()
selector.register(fd, selectors.EVENT_READ)
while True:
if not selector.select(timeout=0.1):
if stop.is_set():
break
continue
chunk = os.read(fd, 4096)
if not chunk:
break
for part in chunk.splitlines(keepends=True):
ending = part.endswith(b"\n") or part.endswith(b"\r")
if not dropping:
if len(buffer) + len(part) > MAX_LINE:
buffer.clear()
dropping = True
else:
buffer.extend(part)
if ending:
if dropping:
log.write("[Oversized client output line omitted]")
else:
log.write(buffer.decode("utf-8", errors="replace"))
buffer.clear()
dropping = False
if buffer and not dropping:
log.write(buffer.decode("utf-8", errors="replace"))
elif dropping:
log.write("[Oversized client output line omitted]")
except Exception:
errors.append("Connection output could not be written to its private log.")
finally:
if "selector" in locals():
selector.close()
if stop.is_set():
# If the main thread timed out waiting for draining, this thread
# remains the owner of its live stream/log until it finishes.
try:
stream.close()
except Exception:
errors.append("Connection output stream cleanup failed.")
try:
log.close()
except Exception:
errors.append("Private connection log cleanup failed.")