From 897ac8a3f3968d112092a108927285b4d3761216 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 22:10:48 +0000 Subject: [PATCH] Add TCP MITM capture tab (TcpBridgePanel) New 'TCP Capture' tab in seismo_lab.py: listens on a configurable local port for an incoming Blastware connection, transparently forwards all traffic to the real seismograph device, and saves both directions to raw_bw_.bin / raw_s3_.bin in the same format the Analyzer already understands. Session start wires up Analyzer live mode automatically via the same on_bridge_started callback as the COM-port bridge. https://claude.ai/code/session_014NczSHUz9uTzCAf4cVASTJ --- seismo_lab.py | 287 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) diff --git a/seismo_lab.py b/seismo_lab.py index 2c85222..11e7f11 100644 --- a/seismo_lab.py +++ b/seismo_lab.py @@ -22,6 +22,7 @@ from __future__ import annotations import datetime import os import queue +import socket import subprocess import sys import threading @@ -309,6 +310,284 @@ class BridgePanel(tk.Frame): messagebox.showerror("Error", f"Failed to send mark:\n{e}") +# ───────────────────────────────────────────────────────────────────────────── +# TCP Bridge panel — MITM capture over IP +# ───────────────────────────────────────────────────────────────────────────── + +class TcpBridgePanel(tk.Frame): + """ + TCP man-in-the-middle capture panel. + + Listens on a local TCP port for an incoming Blastware connection, forwards + all traffic to the real device, and saves both directions to raw .bin files. + + Calls on_bridge_started(raw_bw_path, raw_s3_path, None) when a session + begins so the Analyzer can wire up live mode — same signature as BridgePanel. + """ + + def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, **kw): + super().__init__(parent, bg=BG2, **kw) + self._on_started = on_bridge_started + self._on_stopped = on_bridge_stopped + self._server: Optional[socket.socket] = None + self._stop_event = threading.Event() + self._log_q: queue.Queue[str] = queue.Queue() + self._build() + self._poll_log_q() + + # ── build ───────────────────────────────────────────────────────────── + + def _build(self) -> None: + pad = {"padx": 6, "pady": 4} + + cfg = tk.Frame(self, bg=BG2) + cfg.pack(side=tk.TOP, fill=tk.X, padx=4, pady=4) + + # Row 0: listen port + remote host + remote port + tk.Label(cfg, text="Listen port:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=0, sticky="e", **pad) + self.listen_port_var = tk.StringVar(value="9034") + tk.Entry(cfg, textvariable=self.listen_port_var, width=8, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=0, column=1, sticky="w", **pad) + + tk.Label(cfg, text="Device host:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=2, sticky="e", **pad) + self.remote_host_var = tk.StringVar(value="63.43.212.232") + tk.Entry(cfg, textvariable=self.remote_host_var, width=18, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=0, column=3, sticky="w", **pad) + + tk.Label(cfg, text="Port:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=4, sticky="e", **pad) + self.remote_port_var = tk.StringVar(value="9034") + tk.Entry(cfg, textvariable=self.remote_port_var, width=8, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=0, column=5, sticky="w", **pad) + + # Row 1: log dir + tk.Label(cfg, text="Log dir:", bg=BG2, fg=FG, font=MONO).grid(row=1, column=0, sticky="e", **pad) + self.logdir_var = tk.StringVar(value=str(SCRIPT_DIR / "bridges" / "captures" / "mitm")) + tk.Entry(cfg, textvariable=self.logdir_var, width=40, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=1, column=1, columnspan=4, sticky="we", **pad) + tk.Button(cfg, text="Browse", bg=BG3, fg=FG, relief="flat", cursor="hand2", + font=MONO, command=self._choose_dir).grid(row=1, column=5, **pad) + + # Row 2: capture checkboxes + self._raw_bw_on = tk.BooleanVar(value=True) + self._raw_s3_on = tk.BooleanVar(value=True) + tk.Checkbutton(cfg, text="Capture BW→device raw", variable=self._raw_bw_on, + bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, + font=MONO).grid(row=2, column=0, columnspan=2, sticky="w", **pad) + tk.Checkbutton(cfg, text="Capture device→BW raw", variable=self._raw_s3_on, + bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, + font=MONO).grid(row=2, column=2, columnspan=2, sticky="w", **pad) + + # Row 3: buttons + status + btn_row = tk.Frame(self, bg=BG2) + btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2) + + self.start_btn = tk.Button(btn_row, text="Start Listening", bg=GREEN, fg="#000000", + relief="flat", padx=12, cursor="hand2", font=MONO_B, + command=self.start_server) + self.start_btn.pack(side=tk.LEFT, padx=6) + + self.stop_btn = tk.Button(btn_row, text="Stop", bg=BG3, fg=FG, + relief="flat", padx=12, cursor="hand2", font=MONO, + command=self.stop_server, state="disabled") + self.stop_btn.pack(side=tk.LEFT, padx=4) + + self.status_var = tk.StringVar(value="Idle") + tk.Label(btn_row, textvariable=self.status_var, + bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=10) + + # Log output + self.log_view = scrolledtext.ScrolledText( + self, height=18, font=MONO_SM, + bg=BG, fg=FG, insertbackground=FG, + relief="flat", state="disabled", + ) + self.log_view.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) + + # ── helpers ─────────────────────────────────────────────────────────── + + def _choose_dir(self) -> None: + path = filedialog.askdirectory(initialdir=self.logdir_var.get()) + if path: + self.logdir_var.set(path) + + def _append_log(self, text: str) -> None: + self.log_view.configure(state="normal") + self.log_view.insert(tk.END, text) + self.log_view.see(tk.END) + self.log_view.configure(state="disabled") + + def _poll_log_q(self) -> None: + try: + while True: + msg = self._log_q.get_nowait() + if msg == "<>": + if self._server is not None: + self.status_var.set(f"Listening on :{self.listen_port_var.get()}") + self._on_stopped() + else: + self._append_log(msg) + except queue.Empty: + pass + finally: + self.after(100, self._poll_log_q) + + # ── server control ──────────────────────────────────────────────────── + + def start_server(self) -> None: + try: + listen_port = int(self.listen_port_var.get().strip()) + remote_host = self.remote_host_var.get().strip() + remote_port = int(self.remote_port_var.get().strip()) + except ValueError: + messagebox.showerror("Error", "Invalid port number.") + return + if not remote_host: + messagebox.showerror("Error", "Please enter the device host.") + return + + try: + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("0.0.0.0", listen_port)) + srv.listen(5) + srv.settimeout(1.0) + except OSError as e: + messagebox.showerror("Error", f"Cannot bind to port {listen_port}:\n{e}") + return + + self._server = srv + self._stop_event.clear() + self.start_btn.configure(state="disabled") + self.stop_btn.configure(state="normal", bg=RED) + self.status_var.set(f"Listening on :{listen_port}") + + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + self._append_log( + f"== TCP Bridge started [{ts}]\n" + f" Listening on 0.0.0.0:{listen_port}\n" + f" Forwarding to {remote_host}:{remote_port}\n" + f" Point Blastware call-home to this machine on port {listen_port}\n==\n" + ) + + logdir = self.logdir_var.get().strip() or "." + raw_bw_on = self._raw_bw_on.get() + raw_s3_on = self._raw_s3_on.get() + + threading.Thread( + target=self._accept_loop, + args=(srv, remote_host, remote_port, logdir, raw_bw_on, raw_s3_on), + daemon=True, + ).start() + + def stop_server(self) -> None: + self._stop_event.set() + if self._server: + try: + self._server.close() + except OSError: + pass + self._server = None + self.start_btn.configure(state="normal") + self.stop_btn.configure(state="disabled", bg=BG3) + self.status_var.set("Idle") + self._append_log("== TCP Bridge stopped ==\n") + self._on_stopped() + + # ── accept / session threads ────────────────────────────────────────── + + def _accept_loop(self, srv: socket.socket, remote_host: str, remote_port: int, + logdir: str, raw_bw_on: bool, raw_s3_on: bool) -> None: + while not self._stop_event.is_set(): + try: + client_sock, addr = srv.accept() + except socket.timeout: + continue + except OSError: + break + + peer = f"{addr[0]}:{addr[1]}" + self._log_q.put(f"[TCP] Blastware connected from {peer}\n") + + try: + dev_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + dev_sock.connect((remote_host, remote_port)) + except OSError as e: + self._log_q.put(f"[TCP] Cannot reach device {remote_host}:{remote_port}: {e}\n") + client_sock.close() + continue + + self._log_q.put(f"[TCP] Connected to device at {remote_host}:{remote_port}\n") + + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + os.makedirs(logdir, exist_ok=True) + raw_bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin") if raw_bw_on else None + raw_s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") if raw_s3_on else None + + self.after(0, self._notify_session_start, raw_bw_path, raw_s3_path, peer, remote_host, remote_port) + + self._run_session(client_sock, dev_sock, raw_bw_path, raw_s3_path, ts) + + self._log_q.put("<>") + + def _notify_session_start(self, raw_bw_path, raw_s3_path, peer, remote_host, remote_port) -> None: + self.status_var.set(f"Active: {peer} → {remote_host}:{remote_port}") + self._on_started(raw_bw_path, raw_s3_path, None) + + def _run_session(self, bw_sock: socket.socket, dev_sock: socket.socket, + raw_bw_path: Optional[str], raw_s3_path: Optional[str], + ts: str) -> None: + bw_fh = open(raw_bw_path, "wb") if raw_bw_path else None + s3_fh = open(raw_s3_path, "wb") if raw_s3_path else None + bw_bytes = [0] + s3_bytes = [0] + + def _pipe(src, dst, fh, counter): + try: + while True: + data = src.recv(4096) + if not data: + break + dst.sendall(data) + if fh: + fh.write(data) + fh.flush() + counter[0] += len(data) + except OSError: + pass + finally: + try: + dst.shutdown(socket.SHUT_WR) + except OSError: + pass + + t_bw = threading.Thread(target=_pipe, args=(bw_sock, dev_sock, bw_fh, bw_bytes), daemon=True) + t_s3 = threading.Thread(target=_pipe, args=(dev_sock, bw_sock, s3_fh, s3_bytes), daemon=True) + t_bw.start() + t_s3.start() + t_bw.join() + t_s3.join() + + bw_sock.close() + dev_sock.close() + if bw_fh: + bw_fh.close() + if s3_fh: + s3_fh.close() + + self._log_q.put( + f"[TCP] Session {ts} done " + f"BW→dev: {bw_bytes[0]} bytes dev→BW: {s3_bytes[0]} bytes\n" + ) + if raw_bw_path: + self._log_q.put(f"[TCP] BW capture: {raw_bw_path}\n") + if raw_s3_path: + self._log_q.put(f"[TCP] S3 capture: {raw_s3_path}\n") + + # ───────────────────────────────────────────────────────────────────────────── # Analyzer panel (tk.Frame — lives inside a notebook tab) # Extracted from gui_analyzer.py; accepts external path injection. @@ -1887,6 +2166,13 @@ class SeismoLab(tk.Tk): ) nb.add(self._bridge_panel, text=" Bridge ") + self._tcp_bridge_panel = TcpBridgePanel( + nb, + on_bridge_started=self._on_bridge_started, + on_bridge_stopped=self._on_bridge_stopped, + ) + nb.add(self._tcp_bridge_panel, text=" TCP Capture ") + self._analyzer_panel = AnalyzerPanel(nb, db=self._db) nb.add(self._analyzer_panel, text=" Analyzer ") @@ -1927,6 +2213,7 @@ class SeismoLab(tk.Tk): def _on_close(self) -> None: self._bridge_panel.stop_bridge() + self._tcp_bridge_panel.stop_server() self._serial_watch_panel._stop() self.destroy()