542 lines
18 KiB
Python
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()
|