NODEDC_MISSION_CORE/src/k1link/cli.py

542 lines
18 KiB
Python

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()