from __future__ import annotations import asyncio import json import platform import shutil import subprocess import sys from pathlib import Path from typing import Annotated, TypedDict import typer import uvicorn from bleak.exc import BleakError from rich.console import Console from rich.table import Table from k1link import __version__ from k1link.analyze import ( DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES, MAX_STREAM_SUMMARY_PAYLOAD_BYTES, summarize_mqtt_streams, ) from k1link.artifacts import write_json_atomic from k1link.ble.gatt import dump_metadata from k1link.ble.scanner import scan from k1link.ble.wifi_provisioning import ( PROFILE_ID, WriteMode, provision_wifi_once, ) from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials from k1link.mqtt import ( DEFAULT_MAX_MESSAGE_BYTES, MAX_CONFIGURABLE_MESSAGE_BYTES, CaptureError, capture_mqtt, ) from k1link.net.snapshot import snapshot from k1link.usb.snapshot import snapshot as usb_snapshot app = typer.Typer( name="k1link", help="Safe, evidence-led research tooling for an owner-controlled XGRIDS K1.", no_args_is_help=True, ) console = Console() ble_app = typer.Typer(help="Bluetooth LE discovery and metadata commands.", no_args_is_help=True) net_app = typer.Typer(help="Read-only local network observation commands.", no_args_is_help=True) usb_app = typer.Typer(help="Read-only macOS USB metadata commands.", no_args_is_help=True) analyze_app = typer.Typer(help="Bounded offline evidence analysis commands.", no_args_is_help=True) app.add_typer(ble_app, name="ble") app.add_typer(net_app, name="net") app.add_typer(usb_app, name="usb") app.add_typer(analyze_app, name="analyze") class ToolStatus(TypedDict): name: str available: bool path: str | None class NetworkStatus(TypedDict): wifi_interface: str | None default_route_interface: str | None vpn_default_route: bool class DoctorPayload(TypedDict): k1link_version: str python_version: str python_executable: str python_is_3_12: bool local_venv: bool platform: str machine: str network: NetworkStatus tools: list[ToolStatus] notes: list[str] def _tool_status(name: str) -> ToolStatus: path = shutil.which(name) return {"name": name, "available": path is not None, "path": path} def _command_output(args: list[str]) -> str | None: try: result = subprocess.run( args, check=False, capture_output=True, text=True, timeout=5, ) except (OSError, subprocess.SubprocessError): return None if result.returncode != 0: return None return result.stdout.strip() def _default_route_interface() -> str | None: output = _command_output(["route", "-n", "get", "default"]) if output is None: return None for line in output.splitlines(): key, separator, value = line.strip().partition(":") if separator and key == "interface": return value.strip() return None def _wifi_interface() -> str | None: output = _command_output(["networksetup", "-listallhardwareports"]) if output is None: return None blocks = output.split("\n\n") for block in blocks: if "Hardware Port: Wi-Fi" not in block: continue for line in block.splitlines(): key, separator, value = line.partition(":") if separator and key.strip() == "Device": return value.strip() return None def _doctor_payload() -> DoctorPayload: executable = Path(sys.executable) default_route = _default_route_interface() wifi_interface = _wifi_interface() notes = [ "Bluetooth permission is intentionally not requested by doctor.", "Missing tshark/nmap is acceptable before the network-analysis gate.", "No Homebrew or system changes are performed by this command.", ] if default_route is not None and default_route.startswith("utun"): notes.append( "Default route uses a VPN/tunnel interface; future K1 commands must resolve " "the route for the confirmed K1 IP instead of assuming the default route." ) return { "k1link_version": __version__, "python_version": platform.python_version(), "python_executable": str(executable), "python_is_3_12": sys.version_info[:2] == (3, 12), "local_venv": Path(sys.prefix).name == ".venv", "platform": platform.platform(), "machine": platform.machine(), "network": { "wifi_interface": wifi_interface, "default_route_interface": default_route, "vpn_default_route": bool(default_route and default_route.startswith("utun")), }, "tools": [_tool_status(name) for name in ("uv", "tcpdump", "tshark", "nmap", "ffmpeg")], "notes": notes, } @app.callback() def main() -> None: """Run safe research commands for an owner-controlled XGRIDS K1.""" @app.command() def doctor( json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."), ) -> None: """Inspect the local toolchain without touching the K1 or system configuration.""" payload = _doctor_payload() if json_output: typer.echo(json.dumps(payload, ensure_ascii=False, indent=2)) return console.print(f"k1link {payload['k1link_version']}") console.print(f"Python {payload['python_version']} ({payload['python_executable']})") console.print( "Local .venv: " + ("[green]yes[/green]" if payload["local_venv"] else "[red]no[/red]") ) network = payload["network"] console.print( "Wi-Fi interface: " f"{network['wifi_interface'] or '-'}; default route: " f"{network['default_route_interface'] or '-'}" ) table = Table(title="External tools") table.add_column("Tool") table.add_column("Available") table.add_column("Path") for item in payload["tools"]: table.add_row( str(item["name"]), "yes" if item["available"] else "no", str(item["path"] or "-"), ) console.print(table) for note in payload["notes"]: console.print(f"- {note}") @app.command("serve") def serve_console( port: Annotated[ int, typer.Option(min=1024, max=65535, help="Loopback HTTP port for the local console."), ] = 8000, ) -> None: """Serve the built Mission Core Control Station and loopback control API.""" frontend = Path(__file__).resolve().parents[2] / "apps" / "control-station" / "dist" if not frontend.is_dir(): console.print( "[red]Frontend build is missing.[/red] Run npm install && npm run build " "inside apps/control-station." ) raise typer.Exit(code=2) console.print(f"NODEDC MISSION CORE: http://127.0.0.1:{port}") console.print("The credential endpoint is bound to this Mac only.") uvicorn.run( "k1link.web.app:app", host="127.0.0.1", port=port, log_level="info", access_log=True, ) @ble_app.command("scan") def ble_scan( out: Annotated[ Path, typer.Option(help="Ignored session JSON path; parent directories are created."), ], duration: Annotated[ float, typer.Option(min=1.0, max=300.0, help="Scan duration in seconds."), ] = 30.0, ) -> None: """Discover BLE advertisements without connecting or changing device configuration.""" try: result = asyncio.run(scan(duration)) except (BleakError, OSError, ValueError) as exc: console.print(f"[red]BLE scan failed:[/red] {type(exc).__name__}: {exc}") console.print("Check System Settings → Privacy & Security → Bluetooth.") raise typer.Exit(code=2) from exc write_json_atomic(out, result) table = Table(title=f"BLE devices ({result['device_count']})") table.add_column("Candidate") table.add_column("Name") table.add_column("RSSI") table.add_column("macOS UUID") for device in result["devices"]: table.add_row( "K1?" if device["k1_name_candidate"] else "", device["local_name"] or device["name"] or "-", str(device["rssi"]), device["macos_uuid"], ) console.print(table) console.print(f"Saved: {out}") @ble_app.command("gatt-dump") def ble_gatt_dump( device: Annotated[str, typer.Option(help="CoreBluetooth/macOS UUID from ble scan.")], out: Annotated[ Path, typer.Option(help="Ignored session JSON path; parent directories are created."), ], timeout: Annotated[ float, typer.Option(min=5.0, max=120.0, help="Connection timeout in seconds."), ] = 45.0, ) -> None: """Enumerate GATT metadata only: no characteristic reads, subscriptions or writes.""" console.print( "Connecting for service discovery only; no characteristic values will be read or written." ) try: result = asyncio.run(dump_metadata(device, timeout)) except (BleakError, OSError, ValueError) as exc: console.print(f"[red]GATT metadata failed:[/red] {type(exc).__name__}: {exc}") raise typer.Exit(code=2) from exc write_json_atomic(out, result) characteristic_count = sum(len(service["characteristics"]) for service in result["services"]) console.print( f"Device: {result['device_name']}; services: {len(result['services'])}; " f"characteristics: {characteristic_count}" ) console.print(f"Saved: {out}") @ble_app.command("wifi-configure") def ble_wifi_configure( device: Annotated[str, typer.Option(help="CoreBluetooth/macOS UUID from ble scan.")], out: Annotated[ Path, typer.Option(help="Ignored sensitive session JSON path; parent directories are created."), ], profile: Annotated[ str, typer.Option(help="Exact reviewed provisioning profile ID."), ], confirm_write: Annotated[ bool, typer.Option( "--confirm-write", help="Confirm one state-changing BLE Wi-Fi provisioning write.", ), ] = False, write_mode: Annotated[ WriteMode, typer.Option(help="ATT write mode; auto follows the live characteristic properties."), ] = "auto", timeout: Annotated[ float, typer.Option(min=10.0, max=120.0, help="Status polling timeout after the write."), ] = 45.0, ) -> None: """Send router credentials once using the reviewed K1 firmware-3 profile.""" if profile != PROFILE_ID: console.print(f"[red]Unknown or unreviewed profile:[/red] {profile}") raise typer.Exit(code=2) if not confirm_write: console.print( "[red]Write not confirmed.[/red] Add --confirm-write after reviewing the profile." ) raise typer.Exit(code=2) console.print( "Two local macOS dialogs will request the Wi-Fi name and hidden password. " "The password is never printed, logged, or written to the result file; " "the K1 may echo the SSID in the ignored sensitive status result." ) try: ssid, password = prompt_wifi_credentials() except CredentialDialogError as exc: console.print(f"[red]Credential entry failed:[/red] {exc}") raise typer.Exit(code=2) from exc console.print("Credentials accepted locally. Starting the single reviewed BLE write.") try: result = asyncio.run( provision_wifi_once( device, ssid, password, timeout_seconds=timeout, write_mode=write_mode, ) ) except (BleakError, OSError, TimeoutError, ValueError) as exc: console.print(f"[red]Wi-Fi provisioning failed:[/red] {type(exc).__name__}: {exc}") console.print("No automatic retry was attempted.") raise typer.Exit(code=2) from exc finally: password = "" ssid = "" write_json_atomic(out, result) observations = result["observations"] final_status = observations[-1]["status"] if observations else result["baseline_status"] console.print(f"Outcome: {result['outcome']}; ATT mode: {result['write_mode']}") console.print( f"Final status code: {final_status['status_code']}; " f"reported IPv4: {final_status['ipv4'] or '-'}" ) console.print("Saved sensitive device/network metadata; do not commit the output.") console.print(f"Saved: {out}") @net_app.command("snapshot") def net_snapshot( out: Annotated[ Path, typer.Option(help="Ignored session JSON path; parent directories are created."), ], ) -> None: """Save routes, interfaces and the existing neighbor table without scanning the LAN.""" result = snapshot() write_json_atomic(out, result) console.print("Saved a sensitive local network snapshot; do not commit the output.") console.print(f"Saved: {out}") @net_app.command("mqtt-capture") def net_mqtt_capture( host: Annotated[ str, typer.Option("--host", help="Confirmed K1 RFC1918 IPv4 address; hostnames are rejected."), ], out: Annotated[ Path, typer.Option( "--out", help="Sensitive output directory; use captures/... so Git ignores it.", ), ], confirm_owned_device: Annotated[ bool, typer.Option( "--confirm-owned-device", help="Confirm the target is an owner-controlled K1 before connecting.", ), ] = False, port: Annotated[ int, typer.Option(min=1, max=65535, help="MQTT broker TCP port."), ] = 1883, duration: Annotated[ float, typer.Option(min=1.0, max=3600.0, help="Capture duration after SUBACK, in seconds."), ] = 60.0, max_message_bytes: Annotated[ int, typer.Option( min=1, max=MAX_CONFIGURABLE_MESSAGE_BYTES, help="Abort before storing a payload larger than this byte limit.", ), ] = DEFAULT_MAX_MESSAGE_BYTES, ) -> None: """Capture fixed K1 MQTT report topics once; never publish or reconnect.""" if not confirm_owned_device: console.print( "[red]Target ownership not confirmed.[/red] " "Add --confirm-owned-device for the confirmed K1 IPv4 address." ) raise typer.Exit(code=2) console.print( "Starting one read-only MQTT subscription session. " "No application messages will be published and no reconnect will be attempted." ) try: result = capture_mqtt( host, out, port=port, duration_seconds=duration, max_message_bytes=max_message_bytes, on_ready=lambda: console.print( "[green]MQTT subscriptions active; capture timer started.[/green]" ), ) except CaptureError as exc: console.print(f"[red]MQTT capture failed:[/red] {exc}") if exc.summary is not None: console.print(f"Partial artifacts preserved in: {out}") raise typer.Exit(code=2) from exc except (OSError, RuntimeError, ValueError) as exc: console.print(f"[red]MQTT capture failed:[/red] {type(exc).__name__}: {exc}") raise typer.Exit(code=2) from exc console.print( f"Capture stopped: {result['stop_reason']}; messages: {result['message_count']}; " f"payload bytes: {result['payload_bytes']}" ) console.print("Saved sensitive raw MQTT evidence; do not commit the output.") console.print(f"Saved: {out}") @usb_app.command("snapshot") def usb_snapshot_command( out: Annotated[ Path, typer.Option(help="Ignored session JSON path; parent directories are created."), ], ) -> None: """Save XGRIDS USB/interface/storage metadata without opening device files.""" result = usb_snapshot() write_json_atomic(out, result) console.print( "Saved sensitive USB metadata only; no sudo, device-file reads or device writes used." ) console.print(f"XGRIDS candidates: {result['xgrids_device_count']}") console.print(f"Saved: {out}") @analyze_app.command("mqtt-streams") def analyze_mqtt_streams( capture: Annotated[ Path, typer.Option( "--capture", exists=True, file_okay=True, dir_okay=False, readable=True, help="Repository-native mqtt.raw.k1mqtt capture to analyze offline.", ), ], out: Annotated[ Path, typer.Option( "--out", help=( "Sensitive atomic JSON output; keep under captures/, sessions/, or " "artifacts/decoded/." ), ), ], max_payload_bytes: Annotated[ int, typer.Option( "--max-payload-bytes", min=1, max=MAX_STREAM_SUMMARY_PAYLOAD_BYTES, help="Reject a capture frame larger than this bounded payload limit.", ), ] = DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES, ) -> None: """Summarize captured firmware-3 point-cloud and pose streams without coordinates.""" if capture.expanduser().resolve() == out.expanduser().resolve(): console.print("[red]Analysis failed:[/red] capture and output must be different files") raise typer.Exit(code=2) try: result = summarize_mqtt_streams( capture, max_payload_bytes=max_payload_bytes, ) write_json_atomic(out, result) except (OSError, RuntimeError, ValueError) as exc: console.print(f"[red]Analysis failed:[/red] {type(exc).__name__}: {exc}") raise typer.Exit(code=2) from exc console.print( f"Frames: {result['frames']['count']}; decode successes: " f"{result['decoding']['successes']}; errors: {result['decoding']['errors']}" ) console.print("Saved sensitive aggregate-only output; keep it in ignored storage.") console.print(f"Saved: {out}") if __name__ == "__main__": app()