From 5c362efa5721bf7b3a28652c4be89bb3ae8ebbe4 Mon Sep 17 00:00:00 2001 From: Rae Lovejoy Date: Sun, 27 Sep 2026 11:29:24 -0700 Subject: [PATCH] hub: add read-only web dashboard --- docs/HUB.md | 12 ++ docs/ROADMAP.md | 1 + docs/STATUS.md | 2 +- src/relmote/cli.py | 36 ++++++ src/relmote/hub_web.py | 242 ++++++++++++++++++++++++++++++++++++++ tests/test_hub_web.py | 99 ++++++++++++++++ tests/test_hub_web_cli.py | 13 ++ 7 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 src/relmote/hub_web.py create mode 100644 tests/test_hub_web.py create mode 100644 tests/test_hub_web_cli.py diff --git a/docs/HUB.md b/docs/HUB.md index 396205c..e6ffc2a 100644 --- a/docs/HUB.md +++ b/docs/HUB.md @@ -126,6 +126,18 @@ relmote hub inventory --live relmote hub inventory --json ``` +A read-only web dashboard renders the same live inventory: + +```bash +# localhost only +relmote hub serve + +# exact Tailscale interface + temporary browser token +relmote hub serve --tailscale +``` + +The Tailscale mode does not bind to `0.0.0.0`. It binds only to the detected Tailscale IPv4 address and requires a temporary browser token. The initial dashboard has no write/action API. + It reports: - the co-located Agent Host identity/platform/capabilities/tools; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 26417b2..27226fe 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -120,6 +120,7 @@ Potential scope: - [x] credential-blind local Agent Host and paired-target inventory; - [x] optional live paired-target reachability/authority probes; +- [x] read-only local/private Hub web dashboard; - 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 e7ca48d..e36d50f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -23,7 +23,7 @@ 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 live reachability/authority probes, cached-vs-current state reconciliation, and non-destructive attention guidance. +- First Hub MVP slice: credential-blind local Agent Host/paired-target inventory with live reachability/authority probes, cached-vs-current state reconciliation, non-destructive attention guidance, and a read-only localhost/Tailscale web dashboard. - Cross-platform/private-transport portability beyond the exercised Linux direct-Tailscale path. - Wayland ScreenCast portal integration. diff --git a/src/relmote/cli.py b/src/relmote/cli.py index ee76efa..e5f31ad 100644 --- a/src/relmote/cli.py +++ b/src/relmote/cli.py @@ -23,6 +23,7 @@ from .agent_host import detect_agent_host from .paired_targets import PairedTargetService from .hub_inventory import HubInventory +from .hub_web import serve_hub from .app import run_app from .version import build_info from .updater import update_repo_preview @@ -775,6 +776,41 @@ def run_hub_inventory(args): hub_inventory.set_defaults(func=run_hub_inventory) + hub_serve = hub_sub.add_parser( + "serve", + help="run the read-only Hub web dashboard", + ) + hub_serve.add_argument( + "--port", + type=int, + default=8790, + help="Hub web port (default: 8790)", + ) + hub_serve.add_argument( + "--tailscale", + action="store_true", + help=( + "bind only to the detected Tailscale IPv4 address and require " + "a temporary browser token" + ), + ) + hub_serve.add_argument( + "--lifetime-minutes", + type=int, + default=60, + help="temporary Tailscale browser-token lifetime (default: 60)", + ) + hub_serve.set_defaults( + func=lambda args: ( + serve_hub( + port=args.port, + tailscale=args.tailscale, + lifetime_minutes=args.lifetime_minutes, + ) + or 0 + ) + ) + status_parser = sub.add_parser( "status", help="show read-only software-node status (useful locally or over SSH)", diff --git a/src/relmote/hub_web.py b/src/relmote/hub_web.py new file mode 100644 index 0000000..c978539 --- /dev/null +++ b/src/relmote/hub_web.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import json +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +from .hub_inventory import HubInventory +from .lan_auth import TemporaryLANAccess +from .network_exposure import choose_exposure + + +HUB_INDEX = r""" + + + + + +Relmote Hub + + + +
+
+

RELMOTE HUB

+
Read-only local inventory
+
+ +
+ +
+

Summary

+
Loading…
+
+
+

Agent Host

+
Loading…
+
+
+

Paired Targets

+
Loading…
+
+

+This Hub preview is inventory-only. It cannot create Target grants, execute Target operations, update nodes, or relay traffic. +

+ + + +""" + + +def _json(handler: BaseHTTPRequestHandler, status: HTTPStatus, value: dict) -> None: + body = json.dumps(value, indent=2, default=str).encode() + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Cache-Control", "no-store") + handler.end_headers() + handler.wfile.write(body) + + +def _authorized(access, path: str) -> bool: + if access is None: + return True + query = parse_qs(urlparse(path).query) + candidate = (query.get("token") or [None])[0] + return access.valid(candidate) + + +def make_hub_handler( + inventory: HubInventory, + access=None, +): + class HubHandler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + if not _authorized(access, self.path): + _json(self, HTTPStatus.FORBIDDEN, {"error": "invalid or expired Hub token"}) + return + + if parsed.path == "/": + body = HUB_INDEX.encode() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + + if parsed.path == "/api/inventory": + _json(self, HTTPStatus.OK, inventory.snapshot(live=True)) + return + + _json(self, HTTPStatus.NOT_FOUND, {"error": "not found"}) + + def do_POST(self): + _json(self, HTTPStatus.METHOD_NOT_ALLOWED, { + "error": "Hub preview is read-only", + }) + + def log_message(self, format, *args): + return + + return HubHandler + + +def serve_hub( + *, + port: int = 8790, + tailscale: bool = False, + lifetime_minutes: int = 60, + inventory: HubInventory | None = None, +) -> None: + if tailscale: + exposure = choose_exposure("tailscale") + access = TemporaryLANAccess.create( + lifetime_seconds=lifetime_minutes * 60 + ) + else: + exposure = choose_exposure("localhost") + access = None + + inventory = inventory or HubInventory() + server = ThreadingHTTPServer( + (exposure.bind_host, port), + make_hub_handler(inventory, access), + ) + + if access: + print("RELMOTE HUB — private read-only preview") + print( + f"http://{exposure.bind_host}:{port}/?token={access.token}" + ) + print(f"expires in {lifetime_minutes} minutes") + print("Bound only to the detected Tailscale IPv4 address.") + else: + print("RELMOTE HUB — local read-only preview") + print(f"http://{exposure.bind_host}:{port}/") + print("Bound to localhost only.") + + print("Inventory refreshes every 15 seconds. Ctrl-C to stop.") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/tests/test_hub_web.py b/tests/test_hub_web.py new file mode 100644 index 0000000..b2b535c --- /dev/null +++ b/tests/test_hub_web.py @@ -0,0 +1,99 @@ +from io import BytesIO +from unittest.mock import patch + +from relmote.hub_web import HUB_INDEX, _authorized, make_hub_handler + + +class FakeAccess: + def __init__(self, token="secret"): + self.token = token + + def valid(self, candidate): + return candidate == self.token + + +class FakeInventory: + def snapshot(self, *, live=False): + assert live is True + return { + "hub": { + "role": "hub", + "mode": "local-read-only", + "authority": "inventory-only", + }, + "agent_host": { + "name": "agent-host-a", + "platform": "linux", + "architecture": "x86_64", + "capabilities": [], + "tools": [], + "relmote": { + "display_version": "0.1.0-dev.12", + "short_commit": "abcdef12", + }, + }, + "paired_targets": [], + "summary": { + "total": 0, + "active": 0, + "attention": 0, + "revoked": 0, + "stale_credential": 0, + "unreachable": 0, + }, + } + + +def test_hub_html_is_read_only_inventory_ui(): + assert "RELMOTE HUB" in HUB_INDEX + assert "Read-only local inventory" in HUB_INDEX + assert "/api/inventory" in HUB_INDEX + assert "create Target grants" in HUB_INDEX + assert "fetch('/api/inventory" in HUB_INDEX + + +def test_hub_token_auth_is_required_only_when_access_exists(): + assert _authorized(None, "/") is True + access = FakeAccess() + assert _authorized(access, "/?token=secret") is True + assert _authorized(access, "/?token=wrong") is False + assert _authorized(access, "/") is False + + +def test_hub_handler_exposes_no_write_api(): + handler = make_hub_handler(FakeInventory(), None) + + class Request: + request_version = "HTTP/1.1" + command = "POST" + requestline = "POST /api/inventory HTTP/1.1" + path = "/api/inventory" + client_address = ("127.0.0.1", 12345) + server = object() + rfile = BytesIO() + wfile = BytesIO() + + def send_response(self, code, message=None): + self.status = code + + def send_header(self, key, value): + pass + + def end_headers(self): + pass + + def log_request(self, code="-", size="-"): + pass + + # Instantiate without BaseHTTPRequestHandler.__init__ so no socket is needed. + instance = object.__new__(handler) + instance.path = Request.path + instance.wfile = Request.wfile + instance.send_response = Request.send_response.__get__(instance) + instance.send_header = Request.send_header.__get__(instance) + instance.end_headers = Request.end_headers.__get__(instance) + instance.do_POST() + + body = instance.wfile.getvalue().decode() + assert instance.status == 405 + assert "read-only" in body diff --git a/tests/test_hub_web_cli.py b/tests/test_hub_web_cli.py new file mode 100644 index 0000000..f1f4cb5 --- /dev/null +++ b/tests/test_hub_web_cli.py @@ -0,0 +1,13 @@ +from relmote.cli import build_parser + + +def test_parser_accepts_hub_serve_tailscale(): + args = build_parser().parse_args( + ["hub", "serve", "--tailscale", "--port", "9000", "--lifetime-minutes", "15"] + ) + + assert args.command == "hub" + assert args.hub_command == "serve" + assert args.tailscale is True + assert args.port == 9000 + assert args.lifetime_minutes == 15