0.1.3 - Add user UID, capture source & TCP support

Introduce automatic user UID extraction and optional --user-uid override, include user_uid and capture_source in exported JSON, and prepend UID to export filenames (falls back to unknown). Propagate detected protocol (tcp/udp) through live/libpcap parsing and accept TCP packets for UID detection; adjust libpcap filter to capture TCP as well. Update CLI flags (--copy-clipboard semantics, --user-uid), prompt user when UID is missing, and wire UID through mitmproxy and live capture flows. Bump package/exporter version to 0.1.3 and add tests for UID extraction and filename behavior.
This commit is contained in:
Golumpa 2026-06-17 11:20:31 +01:00
parent 98d97cb36a
commit df1add6cf1
15 changed files with 283 additions and 49 deletions

View file

@ -10,7 +10,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
from nte_history_exporter.live_capture.windows_raw import ParsedIpUdpPacket, parse_ipv4_udp_packet
from nte_history_exporter.live_capture.windows_raw import ParsedIpUdpPacket, parse_ipv4_packet
PCAP_ERRBUF_SIZE = 256
SNAP_LENGTH = 65535
@ -351,7 +351,7 @@ class LibpcapCapture:
raise LibpcapUnavailable(_pcap_error(self.lib, self.handle))
program = _BpfProgram()
filter_expression = f"udp and host {local_ip}".encode("ascii")
filter_expression = f"host {local_ip} and (udp or tcp)".encode("ascii")
if self.lib.pcap_compile(self.handle, ctypes.byref(program), filter_expression, 1, 0xFFFFFFFF) != 0:
raise LibpcapUnavailable(_pcap_error(self.lib, self.handle))
try:
@ -382,7 +382,7 @@ class LibpcapCapture:
ipv4_packet = _extract_ipv4_frame(frame, self.datalink)
if ipv4_packet is None:
continue
packet = parse_ipv4_udp_packet(ipv4_packet)
packet = parse_ipv4_packet(ipv4_packet)
if packet is not None:
yield packet

View file

@ -24,6 +24,10 @@ EXPORT_PREFIXES = {
"arc_miracle_box": "Arc",
}
CAPTURE_SOURCE_LABELS = {
"windows_raw": "windows_packet",
}
def copy_to_clipboard(text: str) -> bool:
try:
@ -60,8 +64,9 @@ def run_live_capture(
*,
interface_ip: str | None = None,
capture_backend: str = "auto",
copy_clipboard: bool = True,
copy_clipboard: bool = False,
write_debug_csv: bool = False,
user_uid: str | None = None,
) -> dict:
local_ip = interface_ip or detect_local_ipv4()
session = LiveHistorySession(local_ip)
@ -88,6 +93,7 @@ def run_live_capture(
src_port=packet.src_port,
dst_port=packet.dst_port,
payload=packet.payload,
protocol=packet.protocol,
)
)
if matched:
@ -130,6 +136,10 @@ def run_live_capture(
)
exports = []
resolved_user_uid = user_uid or session.user_uid
if session.kinds_seen() and not resolved_user_uid:
resolved_user_uid = console.prompt_user_uid()
capture_source = CAPTURE_SOURCE_LABELS.get(capture.name, capture.name)
for kind in session.kinds_seen():
pairs = session.pairs_for_kind(kind)
best_run, run_warnings = select_continuous_run_from_page_1(pairs)
@ -138,13 +148,15 @@ def run_live_capture(
rows = annotate_groups(rows)
warnings = run_warnings
pages_seen = [p[0] for p in best_run]
csv_path, json_path = export_paths(kind)
csv_path, json_path = export_paths(kind, resolved_user_uid)
if write_debug_csv:
write_csv(csv_path, rows)
export = build_export_json(
rows,
warnings,
source="live_capture",
capture_source=capture_source,
user_uid=resolved_user_uid,
pages_seen=pages_seen,
)
payload = json.dumps(export, ensure_ascii=False, indent=2)
@ -162,9 +174,9 @@ def run_live_capture(
console.print_results_header()
if not exports:
console.print_problem("No history pages were captured.")
console.print_note("This tool must already be running when you press Start on the")
console.print_note("game's main menu. Log out to the main menu, enter the game")
console.print_note("again, then reopen the history screen.")
console.print_note("Make sure the capture backend is running, then reopen the")
console.print_note("history screen and scroll from page 1. If no page messages")
console.print_note("appear, return to the main menu and re-enter the game.")
return {"exports": []}
for item in exports:
@ -189,24 +201,33 @@ def run_live_capture(
console.print_success("Export copied to clipboard - paste it straight into your tracker.")
else:
console.print_note("Clipboard tool unavailable; use the JSON file shown above.")
elif len(exports) > 1:
elif copy_clipboard and len(exports) > 1:
console.print_note("Multiple banners captured; clipboard copy skipped so one export")
console.print_note("does not overwrite another.")
return {"exports": exports}
def export_paths(kind: str) -> tuple[Path, Path]:
def export_paths(kind: str, user_uid: str | None = None) -> tuple[Path, Path]:
export_dir = Path("exports")
export_dir.mkdir(parents=True, exist_ok=True)
prefix = EXPORT_PREFIXES.get(kind, "History")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base = export_dir / f"{prefix}_{stamp}"
uid_prefix = _safe_filename_part(user_uid) if user_uid else "unknown"
base_name = f"{uid_prefix}_{prefix}_{stamp}"
base = export_dir / base_name
csv_path = base.with_suffix(".csv")
json_path = base.with_suffix(".json")
counter = 2
while csv_path.exists() or json_path.exists():
base = export_dir / f"{prefix}_{stamp}_{counter}"
base = export_dir / f"{base_name}_{counter}"
csv_path = base.with_suffix(".csv")
json_path = base.with_suffix(".json")
counter += 1
return csv_path, json_path
def _safe_filename_part(value: str | None) -> str:
if not value:
return "unknown"
cleaned = "".join(ch for ch in value.strip() if ch.isalnum() or ch in ("-", "_"))
return cleaned or "unknown"

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections import deque
from collections import Counter, deque
from dataclasses import dataclass
from typing import Any
@ -19,6 +19,7 @@ from nte_history_exporter.decoder.protocol import (
request_page,
)
from nte_history_exporter.decoder.run import build_rows_from_pairs
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
@dataclass
@ -29,6 +30,7 @@ class UdpPacket:
src_port: int
dst_port: int
payload: bytes
protocol: str = "udp"
@dataclass
@ -57,6 +59,8 @@ class LiveHistorySession:
self.last_capture_was_replacement = False
self.requested_pages: dict[str, set[int]] = {}
self.unanswered_pages: dict[str, dict[int, str]] = {}
self.user_uid: str | None = None
self.user_uid_candidates: Counter[str] = Counter()
def _mark_unanswered(self, request: PendingRequest) -> None:
if request.response_candidates:
@ -93,6 +97,12 @@ class LiveHistorySession:
def process_packet(self, packet: UdpPacket) -> bool:
self.packet_count += 1
candidates = extract_user_uid_candidates(packet.payload)
if candidates:
self.user_uid_candidates.update(candidates)
self.user_uid = self.user_uid_candidates.most_common(1)[0][0]
if packet.protocol != "udp":
return False
if packet.src_ip == self.local_ip and is_history_request(packet.payload):
offset = int.from_bytes(packet.payload[31:35], "little")

View file

@ -10,12 +10,16 @@ RECEIVE_BUFFER_SIZE = 4 * 1024 * 1024
@dataclass
class ParsedIpUdpPacket:
class ParsedIpPacket:
src_ip: str
dst_ip: str
src_port: int
dst_port: int
payload: bytes
protocol: str = "udp"
ParsedIpUdpPacket = ParsedIpPacket
def detect_local_ipv4() -> str:
@ -42,24 +46,41 @@ def detect_local_ipv4() -> str:
return candidates[0]
def parse_ipv4_udp_packet(data: bytes) -> ParsedIpUdpPacket | None:
def parse_ipv4_packet(data: bytes) -> ParsedIpPacket | None:
if len(data) < 28:
return None
version_ihl = data[0]
if version_ihl >> 4 != 4:
return None
ihl = (version_ihl & 0x0F) * 4
total_length = struct.unpack_from("!H", data, 2)[0]
if total_length > 0:
data = data[:total_length]
if len(data) < ihl + 8:
return None
protocol = data[9]
if protocol != 17:
return None
src_ip = socket.inet_ntoa(data[12:16])
dst_ip = socket.inet_ntoa(data[16:20])
src_port, dst_port, udp_len, _checksum = struct.unpack_from("!HHHH", data, ihl)
payload = data[ihl + 8 : ihl + udp_len]
return ParsedIpUdpPacket(src_ip, dst_ip, src_port, dst_port, payload)
if protocol == 17:
src_port, dst_port, udp_len, _checksum = struct.unpack_from("!HHHH", data, ihl)
payload = data[ihl + 8 : ihl + udp_len]
return ParsedIpPacket(src_ip, dst_ip, src_port, dst_port, payload, "udp")
if protocol == 6:
if len(data) < ihl + 20:
return None
src_port, dst_port = struct.unpack_from("!HH", data, ihl)
tcp_header_len = (data[ihl + 12] >> 4) * 4
if tcp_header_len < 20 or len(data) < ihl + tcp_header_len:
return None
payload = data[ihl + tcp_header_len :]
return ParsedIpPacket(src_ip, dst_ip, src_port, dst_port, payload, "tcp")
return None
def parse_ipv4_udp_packet(data: bytes) -> ParsedIpPacket | None:
packet = parse_ipv4_packet(data)
return packet if packet and packet.protocol == "udp" else None
def open_raw_udp_socket(local_ip: str) -> socket.socket:
@ -79,7 +100,7 @@ def read_packets(sock: socket.socket):
except socket.timeout:
yield None
continue
packet = parse_ipv4_udp_packet(data)
packet = parse_ipv4_packet(data)
if packet is None:
continue
yield packet