diff --git a/docs/HUB.md b/docs/HUB.md index de89eb2..bb2562e 100644 --- a/docs/HUB.md +++ b/docs/HUB.md @@ -116,6 +116,27 @@ When an operation requires target authority, the Agent Host or another explicitl Longer term, credentials should move into OS keyrings or another local secret store with process-scoped access. +## Current first slice + +The first implemented Hub slice is intentionally read-only and runs locally on the Agent Host: + +```bash +relmote hub inventory +relmote hub inventory --live +relmote hub inventory --json +``` + +It reports: + +- the co-located Agent Host identity/platform/capabilities/tools; +- the exact local Relmote version/build; +- credential-blind paired-target summaries; +- optionally, live Target reachability and authority state. + +The live probe distinguishes a reachable-but-revoked Target from an unreachable Target. The snapshot does not expose paired-target bearer credentials or stored endpoint URLs. + +This is inventory only. It cannot create grants, execute Target operations, update nodes, or act as a relay. + ## Initial Hub MVP The first useful Hub does not need to be a full RMM platform. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 004884d..26417b2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -118,6 +118,8 @@ Build an optional self-hosted control plane for people who administer or support Potential scope: +- [x] credential-blind local Agent Host and paired-target inventory; +- [x] optional live paired-target reachability/authority probes; - support a co-located Hub + Agent Host deployment as a first-class self-hosted topology; - discover and organize software and hardware Relmote nodes; - show node identity, availability, target, transport/path, and capability status; diff --git a/docs/STATUS.md b/docs/STATUS.md index 0e3120a..f35df3a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -23,6 +23,8 @@ This document is the canonical high-level implementation-status snapshot. Detail ## Implemented, actively experimental +- First Hub MVP slice: credential-blind local Agent Host/paired-target inventory with optional live reachability/authority probes. + - Cross-platform/private-transport portability beyond the exercised Linux direct-Tailscale path. - Wayland ScreenCast portal integration. - Screen-provider discovery/selection architecture. diff --git a/src/relmote/cli.py b/src/relmote/cli.py index 83b03d1..c641cc1 100644 --- a/src/relmote/cli.py +++ b/src/relmote/cli.py @@ -22,6 +22,7 @@ from .agent_profiles import save_profile from .agent_host import detect_agent_host from .paired_targets import PairedTargetService +from .hub_inventory import HubInventory from .app import run_app from .version import build_info from .updater import update_repo_preview @@ -680,6 +681,88 @@ def run_workspace(args): workspace_sub.choices["git-status"].set_defaults(func=run_workspace) workspace_sub.choices["git-diff"].set_defaults(func=run_workspace) + hub_parser = sub.add_parser( + "hub", + help="preview the optional self-hosted Hub role", + ) + hub_sub = hub_parser.add_subparsers(dest="hub_command", required=True) + + hub_inventory = hub_sub.add_parser( + "inventory", + help="show credential-blind local Agent Host and paired-target inventory", + ) + hub_inventory.add_argument( + "--live", + action="store_true", + help="probe paired Targets for current reachability/authority state", + ) + hub_inventory.add_argument( + "--json", + action="store_true", + help="emit the complete structured inventory snapshot", + ) + + def run_hub_inventory(args): + snapshot = HubInventory().snapshot(live=args.live) + if args.json: + print(json.dumps(snapshot, indent=2)) + return 0 + + hub = snapshot["hub"] + host = snapshot["agent_host"] + build = host["relmote"] + + print("RELMOTE HUB INVENTORY") + print(f"Mode: {hub['mode']} · authority: {hub['authority']}") + print() + print("AGENT HOST") + print(f"Name: {host['name']}") + print(f"Platform: {host['platform']} / {host['architecture']}") + print( + f"Relmote: {build['display_version']} " + f"(build {build['short_commit']})" + ) + print("Capabilities:") + for capability in host.get("capabilities", []): + print(f" - {capability}") + print("Tools:") + for tool in host.get("tools", []): + print(f" - {tool}") + + print() + print("PAIRED TARGETS") + targets = snapshot["paired_targets"] + if not targets: + print("No paired Relmote targets.") + return 0 + + for target in targets: + print(f"- {target['name']}") + print(f" workspace: {target.get('workspace') or '?'}") + print(f" stored state: {target.get('state') or '?'}") + capabilities = target.get("capabilities") or [] + print( + " stored capabilities: " + + (", ".join(capabilities) if capabilities else "none") + ) + if args.live: + live = target.get("live") or {} + reachable = live.get("reachable") + if reachable is True: + reachability = "reachable" + elif reachable is False: + reachability = "unreachable" + else: + reachability = "unknown" + print(f" live: {reachability} · {live.get('authority') or 'unknown'}") + if live.get("state"): + print(f" live state: {live['state']}") + if live.get("detail"): + print(f" detail: {live['detail']}") + return 0 + + hub_inventory.set_defaults(func=run_hub_inventory) + status_parser = sub.add_parser( "status", help="show read-only software-node status (useful locally or over SSH)", diff --git a/src/relmote/hub_inventory.py b/src/relmote/hub_inventory.py new file mode 100644 index 0000000..526dca9 --- /dev/null +++ b/src/relmote/hub_inventory.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from .agent_host import AgentHost, detect_agent_host +from .paired_targets import PairedTargetService +from .version import build_info + + +class HubInventory: + """Read-only local inventory for the first Relmote Hub MVP. + + The Hub consumes credential-blind paired-target summaries. Live probes use + the Agent Host's existing paired-target service but never return bearer + credentials or stored endpoint URLs. + """ + + def __init__( + self, + *, + paired_targets: PairedTargetService | None = None, + host_factory: Callable[[], AgentHost] = detect_agent_host, + build_factory: Callable[[], dict[str, str]] = build_info, + ): + self._paired_targets = paired_targets or PairedTargetService() + self._host_factory = host_factory + self._build_factory = build_factory + + @staticmethod + def _live_error(exc: Exception) -> dict[str, Any]: + if isinstance(exc, PermissionError): + return { + "reachable": True, + "authority": "denied", + "state": None, + "detail": str(exc), + } + if isinstance(exc, ConnectionError): + return { + "reachable": False, + "authority": "unknown", + "state": None, + "detail": str(exc), + } + return { + "reachable": None, + "authority": "unknown", + "state": None, + "detail": str(exc), + } + + def snapshot(self, *, live: bool = False) -> dict[str, Any]: + host = self._host_factory() + build = self._build_factory() + + targets = [] + for target in self._paired_targets.targets(): + item = dict(target) + item["role"] = "paired_target" + if live: + try: + status = self._paired_targets.status(str(item["name"])) + except ( + PermissionError, + ConnectionError, + KeyError, + ValueError, + OSError, + ) as exc: + item["live"] = self._live_error(exc) + else: + state = status.get("state") + item["live"] = { + "reachable": True, + "authority": "active" if state == "active" else "inactive", + "state": state, + "workspace": status.get("workspace"), + "capabilities": list(status.get("capabilities") or []), + } + targets.append(item) + + return { + "hub": { + "role": "hub", + "mode": "local-read-only", + "co_located_agent_host": True, + "authority": "inventory-only", + }, + "agent_host": { + "role": "agent_host", + **host.as_dict(), + "relmote": { + "display_version": build["display_version"], + "commit": build["commit"], + "short_commit": build["short_commit"], + "channel": build["channel"], + }, + }, + "paired_targets": targets, + } diff --git a/tests/test_hub_cli.py b/tests/test_hub_cli.py new file mode 100644 index 0000000..70b17e9 --- /dev/null +++ b/tests/test_hub_cli.py @@ -0,0 +1,10 @@ +from relmote.cli import build_parser + + +def test_parser_accepts_hub_inventory(): + args = build_parser().parse_args(["hub", "inventory", "--live", "--json"]) + + assert args.command == "hub" + assert args.hub_command == "inventory" + assert args.live is True + assert args.json is True diff --git a/tests/test_hub_inventory.py b/tests/test_hub_inventory.py new file mode 100644 index 0000000..82406d1 --- /dev/null +++ b/tests/test_hub_inventory.py @@ -0,0 +1,111 @@ +from relmote.agent_host import AgentHost +from relmote.hub_inventory import HubInventory + + +class FakePairedTargets: + def __init__(self, *, status_result=None, status_error=None): + self.status_result = status_result + self.status_error = status_error + self.status_calls = [] + + def targets(self): + return [ + { + "name": "target-a", + "workspace": "/workspace", + "state": "active", + "capabilities": ["workspace.list", "workspace.read"], + } + ] + + def status(self, target): + self.status_calls.append(target) + if self.status_error is not None: + raise self.status_error + return self.status_result or { + "state": "active", + "workspace": "/workspace", + "capabilities": ["workspace.list", "workspace.read"], + } + + +def fake_host(): + return AgentHost( + name="agent-host-a", + platform="linux", + architecture="x86_64", + capabilities=("relmote.agent-client", "development.codex"), + tools=("git", "codex"), + ) + + +def fake_build(): + return { + "display_version": "0.1.0-dev.12", + "commit": "abcdef1234567890", + "short_commit": "abcdef12", + "channel": "repository", + } + + +def test_hub_inventory_is_credential_blind_and_co_located(): + snapshot = HubInventory( + paired_targets=FakePairedTargets(), + host_factory=fake_host, + build_factory=fake_build, + ).snapshot() + + assert snapshot["hub"] == { + "role": "hub", + "mode": "local-read-only", + "co_located_agent_host": True, + "authority": "inventory-only", + } + assert snapshot["agent_host"]["name"] == "agent-host-a" + assert snapshot["agent_host"]["relmote"]["short_commit"] == "abcdef12" + assert snapshot["paired_targets"][0]["role"] == "paired_target" + + text = repr(snapshot) + assert "token" not in text + assert "base_url" not in text + + +def test_hub_live_inventory_reports_active_target(): + service = FakePairedTargets() + snapshot = HubInventory( + paired_targets=service, + host_factory=fake_host, + build_factory=fake_build, + ).snapshot(live=True) + + live = snapshot["paired_targets"][0]["live"] + assert service.status_calls == ["target-a"] + assert live["reachable"] is True + assert live["authority"] == "active" + assert live["state"] == "active" + + +def test_hub_live_inventory_distinguishes_revoked_from_unreachable(): + denied = HubInventory( + paired_targets=FakePairedTargets( + status_error=PermissionError("agent session is not active") + ), + host_factory=fake_host, + build_factory=fake_build, + ).snapshot(live=True)["paired_targets"][0]["live"] + + unavailable = HubInventory( + paired_targets=FakePairedTargets( + status_error=ConnectionError("connection refused") + ), + host_factory=fake_host, + build_factory=fake_build, + ).snapshot(live=True)["paired_targets"][0]["live"] + + assert denied["reachable"] is True + assert denied["authority"] == "denied" + assert "not active" in denied["detail"] + + assert unavailable["reachable"] is False + assert unavailable["authority"] == "unknown" + assert "connection refused" in unavailable["detail"]