from __future__ import annotations import click from rich.console import Console from rich.table import Table from browser_cli.errors import BrowserNotConnected from browser_cli import BrowserCLI from browser_cli.commands import handle_errors from browser_cli.commands.rendering import print_browser_grouped_table_rows from browser_cli.remote.known_hosts import fingerprint, load_known_hosts, remove_known_host, save_known_host from browser_cli.remote.registry import load_remotes, remove_remote, save_remote, save_remote_key console = Console() def _print_remotes() -> None: remotes = load_remotes() if not remotes: console.print("[yellow]No remembered remotes[/yellow]") return table = Table(show_header=True, header_style="bold cyan") table.add_column("Endpoint") table.add_column("Key") for endpoint, cfg in sorted(remotes.items()): table.add_row(endpoint, str(cfg.get("key", ""))) console.print(table) def _remove_remote(endpoint: str, *, verb: str) -> None: if not remove_remote(endpoint): console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]") return console.print(f"[green]{verb} {endpoint}[/green]") def _fetch_server_pubkey(endpoint: str) -> str: from browser_cli.auth.server_identity import verify_challenge_signature from browser_cli.remote.auth import parse_challenge from browser_cli.remote.socket import connect_socket, recv_all sock = connect_socket(endpoint) try: challenge, _nonce = parse_challenge(recv_all(sock) or b"") finally: sock.close() if not isinstance(challenge, dict) or not isinstance(challenge.get("server_pubkey"), str): raise BrowserNotConnected("remote server did not advertise a server identity key") if not verify_challenge_signature(challenge): raise BrowserNotConnected("remote server identity signature is invalid") return str(challenge["server_pubkey"]) @click.group("remote") def remote_group(): """Manage remembered browser-cli remote endpoints.""" @remote_group.command("status") @click.argument("endpoint") @click.option("--key", default=None, help="Key spec/path to use for this probe") @handle_errors def remote_status(endpoint, key): """Probe a remote endpoint and show server/client status.""" client = BrowserCLI(remote=endpoint, key=key) clients = [ {**item, "profileLabel": item.get("profile", ""), "profileGroup": endpoint} for item in client.clients() ] columns = [ ("Browser", lambda item: item.get("name", "")), ("Extension", lambda item: item.get("extensionVersion", "")), ] print_browser_grouped_table_rows( clients, columns, console=console, empty_message="[yellow]No browser clients found[/yellow]", browser_getter=lambda item: item.get("profileLabel", ""), group_getter=lambda item: item.get("profileGroup", ""), browser_header="Profile", ) @remote_group.command("add") @click.argument("endpoint") @click.option("--key", "key_spec", default=None, help="Key spec/path to remember for this endpoint") def remote_add(endpoint, key_spec): """Remember a remote endpoint for global multi-browser commands.""" save_remote(endpoint, key_spec) if key_spec: console.print(f"[green]Added remote {endpoint} with key {key_spec}[/green]") else: console.print(f"[green]Added remote {endpoint}[/green]") @remote_group.command("trust") @click.argument("endpoint") @click.argument("key_spec") def remote_trust(endpoint, key_spec): """Remember which key spec to use for ENDPOINT.""" save_remote_key(endpoint, key_spec) console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]") @remote_group.command("list") def remote_list(): """List remembered remote endpoints.""" _print_remotes() @remote_group.command("trust-host") @click.argument("endpoint") @click.option("--pubkey", default=None, help="Pin this server public key instead of probing the endpoint") @handle_errors def remote_trust_host(endpoint, pubkey): """Pin a remote server identity key, SSH known_hosts style.""" server_pubkey = pubkey or _fetch_server_pubkey(endpoint) save_known_host(endpoint, server_pubkey) console.print(f"[green]Trusted server {endpoint}[/green] [dim]{fingerprint(server_pubkey)}[/dim]") @remote_group.command("known-hosts") def remote_known_hosts(): """List pinned remote server identity keys.""" known = load_known_hosts() if not known: console.print("[yellow]No known remote server identities[/yellow]") return table = Table(show_header=True, header_style="bold cyan") table.add_column("Endpoint") table.add_column("Fingerprint") table.add_column("Public Key") for endpoint, pubkey in sorted(known.items()): table.add_row(endpoint, fingerprint(pubkey), pubkey) console.print(table) @remote_group.command("untrust-host") @click.argument("endpoint") def remote_untrust_host(endpoint): """Remove a pinned remote server identity key.""" if not remove_known_host(endpoint): console.print(f"[yellow]Remote server {endpoint} is not in known hosts[/yellow]") return console.print(f"[green]Removed server identity for {endpoint}[/green]") @remote_group.command("keys") def remote_keys(): """List remembered remote key specs.""" _print_remotes() @remote_group.command("remove") @click.argument("endpoint") def remote_remove(endpoint): """Remove a remembered remote endpoint.""" _remove_remote(endpoint, verb="Removed") @remote_group.command("revoke") @click.argument("endpoint") def remote_revoke(endpoint): """Remove remembered key/config for ENDPOINT.""" _remove_remote(endpoint, verb="Revoked")