"""CLI for OpsBridge — bridge command.""" from __future__ import annotations import dataclasses import json import os import subprocess from datetime import datetime from pathlib import Path from typing import Optional import typer from bridge.audit import AuditLogger from bridge.cleanup import ( CleanupAction, build_cron_line, cleanup_all_tunnels, install_cleanup_cron, read_installed_cron, restart_all_tunnels, restart_tunnel, uninstall_cleanup_cron, ) from bridge.config import ConfigError, load_config from bridge.diagnostics import check_all_tunnels, check_tunnel from bridge.manager import TunnelManager from bridge.state import StateManager, _pid_alive app = typer.Typer( name="bridge", help="OpsBridge — SSH reverse tunnel lifecycle manager.", no_args_is_help=True, ) targets_app = typer.Typer(help="Inspect infrastructure targets from the OpsCatalog.") catalog_app = typer.Typer(help="Inspect and validate the OpsCatalog.") maintenance_app = typer.Typer(help="Scheduled maintenance for tunnel hygiene.") app.add_typer(targets_app, name="targets") app.add_typer(catalog_app, name="catalog") app.add_typer(maintenance_app, name="maintenance") def _state_dir() -> Path: return Path(os.environ.get("BRIDGE_STATE_DIR", str(Path.home() / ".local" / "state" / "bridge"))) def _load_or_exit(): try: return load_config() except ConfigError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) def _load_catalog_or_exit(cfg): from bridge.catalog.loader import load_catalog if cfg.catalog_path is None: typer.echo("Error: catalog_path not configured in tunnels.yaml", err=True) raise typer.Exit(1) try: return load_catalog(cfg.catalog_path) except Exception as e: typer.echo(f"Error loading catalog: {e}", err=True) raise typer.Exit(1) def _resolve_tunnel(cfg, name: str): """Resolve tunnel name: inline first, then catalog, then error.""" from bridge.catalog.loader import load_catalog from bridge.catalog.resolver import BridgeNotFound, resolve catalog = None if cfg.catalog_path is not None: try: catalog = load_catalog(cfg.catalog_path) except Exception: pass try: return resolve(name, catalog=catalog, inline_tunnels=cfg.tunnels) except BridgeNotFound: typer.echo(f"Error: tunnel '{name}' not found in config or catalog", err=True) raise typer.Exit(1) def _all_tunnel_names(cfg): """Return names from inline config (all-tunnels operations use inline only).""" return list(cfg.tunnels.keys()) # ─── Tunnel lifecycle commands ──────────────────────────────────────────────── @app.command() def up( tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"), ): """Start one or all tunnels.""" cfg = _load_or_exit() sd = _state_dir() if tunnel: tcfg = _resolve_tunnel(cfg, tunnel) mgr = TunnelManager(tcfg, state_dir=sd) if mgr.is_running(): typer.echo(f"Tunnel '{tunnel}' is already running.") raise typer.Exit(2) mgr.start() typer.echo(f"Started tunnel '{tunnel}'.") else: names = _all_tunnel_names(cfg) any_already_running = False for name in names: tcfg = cfg.tunnels[name] mgr = TunnelManager(tcfg, state_dir=sd) if mgr.is_running(): typer.echo(f"Tunnel '{name}' is already running.") any_already_running = True else: mgr.start() typer.echo(f"Started tunnel '{name}'.") if any_already_running and len(names) == 1: raise typer.Exit(2) @app.command() def down( tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"), ): """Stop one or all tunnels.""" cfg = _load_or_exit() sd = _state_dir() if tunnel: tcfg = _resolve_tunnel(cfg, tunnel) mgr = TunnelManager(tcfg, state_dir=sd) if not mgr.is_running(): typer.echo(f"Tunnel '{tunnel}' is not running.") raise typer.Exit(2) mgr.stop() typer.echo(f"Stopped tunnel '{tunnel}'.") else: names = _all_tunnel_names(cfg) any_not_running = False for name in names: tcfg = cfg.tunnels[name] mgr = TunnelManager(tcfg, state_dir=sd) if not mgr.is_running(): typer.echo(f"Tunnel '{name}' is not running.") any_not_running = True else: mgr.stop() typer.echo(f"Stopped tunnel '{name}'.") if any_not_running and len(names) == 1: raise typer.Exit(2) def _emit_restart_actions(actions: list[CleanupAction]) -> None: any_error = False for action in actions: typer.echo(f"{action.tunnel}: {action.action} — {action.detail}") if action.action == "error": any_error = True if any_error: raise typer.Exit(1) @app.command() def restart( tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"), ): """Restart one or all tunnels. Reverse tunnels run conditional remote stale-forward cleanup before reconnecting; healthy forwards are left running. Local-direction tunnels use local stop/start only. """ cfg = _load_or_exit() sd = _state_dir() state_mgr = StateManager(state_dir=sd) if tunnel: tcfg = _resolve_tunnel(cfg, tunnel) actions = [restart_tunnel(tcfg, state_mgr)] else: actions = restart_all_tunnels(cfg, state_mgr) _emit_restart_actions(actions) @app.command() def status( as_json: bool = typer.Option(False, "--json", help="Output as JSON"), ): """Show status of all tunnels.""" cfg = _load_or_exit() sd = _state_dir() state_mgr = StateManager(state_dir=sd) rows = [] for name, tcfg in cfg.tunnels.items(): state = state_mgr.read_state(name) raw_pid = state_mgr.read_raw_pid(name) pid_alive_val = _pid_alive(raw_pid) if raw_pid is not None else None stale = ( state.value in ("connected", "degraded") and pid_alive_val is not True ) rows.append({ "tunnel": name, "state": state.value, "actor": tcfg.actor, "host": tcfg.host, "pid": raw_pid, "pid_alive": pid_alive_val, "stale": stale, "uptime": None, "health": None, }) if as_json: typer.echo(json.dumps(rows, indent=2)) else: _print_status_table(rows) def _print_status_table(rows): if not rows: typer.echo("No tunnels configured.") return def _state_display(row): s = row["state"] if row.get("stale"): s += " [STALE]" return s def _live_display(row): alive = row.get("pid_alive") if alive is True: return "yes" elif alive is False: return "no" return "\u2014" headers = ["TUNNEL", "STATE", "ACTOR", "HOST", "PID", "LIVE"] col_widths = [ max(len("TUNNEL"), max((len(row["tunnel"]) for row in rows), default=0)), max(len("STATE"), max((len(_state_display(row)) for row in rows), default=0)), max(len("ACTOR"), max((len(str(row.get("actor", "") or "")) for row in rows), default=0)), max(len("HOST"), max((len(str(row.get("host", "") or "")) for row in rows), default=0)), max(len("PID"), max((len(str(row["pid"] or "")) for row in rows), default=0)), max(len("LIVE"), max((len(_live_display(row)) for row in rows), default=0)), ] def _fmt_row(vals): return " ".join(str(v).ljust(w) for v, w in zip(vals, col_widths)) typer.echo(_fmt_row(headers)) typer.echo(_fmt_row(["-" * w for w in col_widths])) for row in rows: typer.echo(_fmt_row([ row["tunnel"], _state_display(row), row["actor"], row["host"], str(row["pid"] or ""), _live_display(row), ])) @app.command() def logs( tunnel: str = typer.Argument(..., help="Tunnel name"), lines: int = typer.Option(50, "--lines", "-n", help="Number of lines to show"), follow: bool = typer.Option(False, "--follow", "-f", help="Follow the log"), ): """Show audit log for a tunnel.""" cfg = _load_or_exit() _resolve_tunnel(cfg, tunnel) # validate name sd = _state_dir() logger = AuditLogger(state_dir=sd) events = logger.read_events(tunnel) if not events: typer.echo(f"No log entries for tunnel '{tunnel}'.") return for entry in events[-lines:]: ts = entry.get("timestamp", "") event = entry.get("event", "") actor = entry.get("actor", "") detail = entry.get("detail", "") parts = [ts, event, f"actor={actor}"] if detail: parts.append(detail) typer.echo(" ".join(parts)) if follow: import time log_path = sd / f"{tunnel}.log" try: with log_path.open() as f: f.seek(0, 2) while True: line = f.readline() if line: try: entry = json.loads(line) ts = entry.get("timestamp", "") event = entry.get("event", "") actor = entry.get("actor", "") detail = entry.get("detail", "") parts = [ts, event, f"actor={actor}"] if detail: parts.append(detail) typer.echo(" ".join(parts)) except json.JSONDecodeError: pass else: time.sleep(0.5) except KeyboardInterrupt: pass @app.command() def check( tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"), as_json: bool = typer.Option(False, "--json", help="Output as JSON"), ): """End-to-end diagnostics: verify SSH PID alive and remote port listening.""" cfg = _load_or_exit() sd = _state_dir() state_mgr = StateManager(state_dir=sd) if tunnel: results = [check_tunnel(_resolve_tunnel(cfg, tunnel), state_mgr)] else: results = check_all_tunnels(cfg, state_mgr) if as_json: typer.echo(json.dumps( [{**dataclasses.asdict(r), "ok": r.ok} for r in results], indent=2, )) else: _print_check_table(results) if any(not r.ok for r in results): raise typer.Exit(1) def _print_check_table(results): if not results: typer.echo("No tunnels configured.") return headers = ["TUNNEL", "SSH", "PID", "PORT", "API", "OK"] rows_data = [] for r in results: rows_data.append([ r.tunnel, r.ssh_process, str(r.pid or ""), r.remote_port, r.local_api or "\u2014", "yes" if r.ok else "no", ]) col_widths = [ max(len(h), max((len(row[i]) for row in rows_data), default=0)) for i, h in enumerate(headers) ] def _fmt(vals): return " ".join(str(v).ljust(w) for v, w in zip(vals, col_widths)) typer.echo(_fmt(headers)) typer.echo(_fmt(["-" * w for w in col_widths])) for row in rows_data: typer.echo(_fmt(row)) @app.command("cert-status") def cert_status( tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"), as_json: bool = typer.Option(False, "--json", help="Output as JSON"), ): """Show certificate status for tunnels using cert_command mode.""" cfg = _load_or_exit() sd = _state_dir() names = [tunnel] if tunnel else list(cfg.tunnels.keys()) rows = [] any_expired = False for name in names: cert_file = sd / f"{name}-cert.pub" if not cert_file.exists(): rows.append({"tunnel": name, "mode": "static-key", "cert_file": None}) continue try: result = subprocess.run( ["ssh-keygen", "-L", "-f", str(cert_file)], capture_output=True, text=True, check=False, ) info = {"tunnel": name, "mode": "cert", "cert_file": str(cert_file)} for line in result.stdout.splitlines(): line = line.strip() if line.startswith("Key ID:"): info["key_id"] = line.split(":", 1)[1].strip().strip('"') elif line.startswith("Valid:"): parts = line.split() if len(parts) >= 5 and parts[1] == "from" and parts[3] == "to": info["valid_from"] = parts[2] info["valid_until"] = parts[4] try: expires = datetime.fromisoformat(parts[4]) now = datetime.now() remaining = expires - now if remaining.total_seconds() <= 0: info["expired"] = True any_expired = True else: info["expired"] = False mins = int(remaining.total_seconds() // 60) info["ttl_remaining"] = f"{mins}m" except ValueError: pass rows.append(info) except FileNotFoundError: rows.append({"tunnel": name, "mode": "cert", "error": "ssh-keygen not found"}) if as_json: typer.echo(json.dumps(rows, indent=2)) else: for row in rows: mode = row.get("mode", "unknown") if mode == "static-key": typer.echo(f"{row['tunnel']} static-key / no cert") elif "error" in row: typer.echo(f"{row['tunnel']} ERROR: {row['error']}") else: parts = [row["tunnel"]] if "key_id" in row: parts.append(f"id={row['key_id']}") if "valid_from" in row: parts.append(f"from={row['valid_from']}") if "valid_until" in row: parts.append(f"until={row['valid_until']}") if row.get("expired"): parts.append("EXPIRED") elif "ttl_remaining" in row: parts.append(f"ttl={row['ttl_remaining']}") typer.echo(" ".join(parts)) if any_expired: raise typer.Exit(1) # ─── targets commands ───────────────────────────────────────────────────────── @targets_app.callback(invoke_without_command=True) def targets_default( ctx: typer.Context, domain: Optional[str] = typer.Option(None, "--domain", help="Filter by domain"), as_json: bool = typer.Option(False, "--json", help="Output as JSON"), ): """List infrastructure targets from the OpsCatalog.""" if ctx.invoked_subcommand is not None: return cfg = _load_or_exit() cat = _load_catalog_or_exit(cfg) rows = [] for t in cat.targets.values(): if domain and t.domain != domain: continue rows.append({ "domain": t.domain, "target": t.id, "kind": t.kind, "description": t.description, "bridges": t.reachable_via, }) if as_json: typer.echo(json.dumps(rows, indent=2)) else: if not rows: typer.echo("No targets found.") return headers = ["DOMAIN", "TARGET", "KIND", "BRIDGES"] col_widths = [ max(len(h), max((len(str(r.get(h.lower(), "") or "")) for r in rows), default=0)) for h in headers ] def _fmt(vals): return " ".join(str(v).ljust(w) for v, w in zip(vals, col_widths)) typer.echo(_fmt(headers)) typer.echo(_fmt(["-" * w for w in col_widths])) for row in rows: typer.echo(_fmt([ row["domain"], row["target"], row["kind"], ", ".join(row["bridges"]), ])) @targets_app.command("show") def targets_show( target: str = typer.Argument(..., help="Target ID"), ): """Show full metadata for a target.""" cfg = _load_or_exit() cat = _load_catalog_or_exit(cfg) if target not in cat.targets: typer.echo(f"Error: target '{target}' not found in catalog", err=True) raise typer.Exit(1) t = cat.targets[target] typer.echo(f"Target: {t.id}") typer.echo(f"Domain: {t.domain}") typer.echo(f"Kind: {t.kind}") if t.description: typer.echo(f"Description: {t.description}") if t.reachable_via: typer.echo(f"Bridges: {', '.join(t.reachable_via)}") # Show ops notes from docs/ if available if cfg.catalog_path: docs_dir = cfg.catalog_path / "domains" / t.domain / "docs" if docs_dir.exists(): for md_file in sorted(docs_dir.glob("*.md")): typer.echo(f"\n--- {md_file.name} ---") typer.echo(md_file.read_text()) # ─── catalog commands ───────────────────────────────────────────────────────── @catalog_app.callback(invoke_without_command=True) def catalog_default(ctx: typer.Context): """Inspect and validate the OpsCatalog.""" if ctx.invoked_subcommand is None: typer.echo(ctx.get_help()) @catalog_app.command("list") def catalog_list( as_json: bool = typer.Option(False, "--json", help="Output as JSON"), ): """List all domains with target and bridge counts.""" cfg = _load_or_exit() cat = _load_catalog_or_exit(cfg) rows = [] for domain in cat.domains.values(): target_count = sum(1 for t in cat.targets.values() if t.domain == domain.id) bridge_count = sum(1 for b in cat.bridges.values() if b.domain == domain.id) rows.append({ "domain": domain.id, "name": domain.name, "environment": domain.environment, "targets": target_count, "bridges": bridge_count, }) if as_json: typer.echo(json.dumps(rows, indent=2)) else: if not rows: typer.echo("Catalog is empty.") return headers = ["DOMAIN", "NAME", "ENV", "TARGETS", "BRIDGES"] col_widths = [ max(len(h), max((len(str(r.get(h.lower()[:3] if h == "ENV" else h.lower(), "") or "")) for r in rows), default=0)) for h in headers ] # Manual col widths for cleaner output col_widths = [ max(len("DOMAIN"), max((len(r["domain"]) for r in rows), default=0)), max(len("NAME"), max((len(r["name"]) for r in rows), default=0)), max(len("ENV"), max((len(r["environment"]) for r in rows), default=0)), max(len("TARGETS"), max((len(str(r["targets"])) for r in rows), default=0)), max(len("BRIDGES"), max((len(str(r["bridges"])) for r in rows), default=0)), ] def _fmt(vals): return " ".join(str(v).ljust(w) for v, w in zip(vals, col_widths)) typer.echo(_fmt(headers)) typer.echo(_fmt(["-" * w for w in col_widths])) for row in rows: typer.echo(_fmt([ row["domain"], row["name"], row["environment"], str(row["targets"]), str(row["bridges"]), ])) @catalog_app.command("validate") def catalog_validate(): """Validate catalog for consistency errors.""" from bridge.catalog.validator import validate_catalog cfg = _load_or_exit() cat = _load_catalog_or_exit(cfg) errors = validate_catalog(cat) if errors: typer.echo(f"Catalog has {len(errors)} violation(s):") for err in errors: typer.echo(f" - {err}") raise typer.Exit(1) else: typer.echo(f"Catalog OK — {len(cat.domains)} domain(s), {len(cat.targets)} target(s), {len(cat.bridges)} bridge(s).") @catalog_app.command("show") def catalog_show( bridge_id: str = typer.Argument(..., help="Bridge ID"), ): """Show full metadata for a bridge.""" cfg = _load_or_exit() cat = _load_catalog_or_exit(cfg) if bridge_id not in cat.bridges: typer.echo(f"Error: bridge '{bridge_id}' not found in catalog", err=True) raise typer.Exit(1) b = cat.bridges[bridge_id] typer.echo(f"Bridge: {b.id}") typer.echo(f"Domain: {b.domain}") typer.echo(f"Target: {b.target}") typer.echo(f"Host: {b.host}") typer.echo(f"Ports: {b.remote_port} -> {b.local_port}") typer.echo(f"SSH user: {b.ssh_user}") typer.echo(f"Actor: {b.actor}") typer.echo(f"Method: {b.access_method}") if b.description: typer.echo(f"Description: {b.description}") if b.health_check: typer.echo(f"Health: {b.health_check.url} (every {b.health_check.interval_seconds}s)") # Domain context if b.domain in cat.domains: d = cat.domains[b.domain] typer.echo(f"\nDomain context: {d.name} [{d.environment}]") # Target context if b.target in cat.targets: t = cat.targets[b.target] typer.echo(f"Target: {t.description or t.id} ({t.kind})") _CONVENTIONS_TEXT = """\ Actor Naming Conventions (from AccessManagementDirective.md §2) Every actor declared under `actors:` in ~/.config/bridge/tunnels.yaml must have a `class` field, and the actor name must start with the class-specific prefix: class prefix purpose ----- ------ ------------------------------------------------------------ adm adm- Human operator (interactive shell when needed) agt agt- LLM-powered autonomous agent (Claude Code, etc.) atm atm- Deterministic script / cron job / pipeline Legacy class aliases (deprecated, still accepted with a warning): human -> adm automation -> atm Examples: adm-bernd: { class: adm, description: Bernd Worsch } agt-claude-coulombcore: { class: agt, description: Claude Code on CoulombCore } atm-backup-daily: { class: atm, description: Nightly DB backup } Full specification: /wiki/AccessManagementDirective.md """ @maintenance_app.command("cleanup") def maintenance_cleanup( tunnel: Optional[str] = typer.Argument( None, help="Tunnel name (omit for all reverse tunnels)", ), restart: bool = typer.Option( False, "--restart", help="Restart tunnels after clearing stale remote bindings", ), as_json: bool = typer.Option(False, "--json", help="Output as JSON"), ): """Clear stale SSH remote port forwards that block tunnel reconnects.""" cfg = _load_or_exit() sd = _state_dir() state_mgr = StateManager(state_dir=sd) try: report = cleanup_all_tunnels( cfg, state_mgr, restart=restart, tunnel_name=tunnel, ) except KeyError: typer.echo(f"Error: tunnel '{tunnel}' not found in config", err=True) raise typer.Exit(1) if as_json: payload = { "cleaned_count": report.cleaned_count, "actions": [ {"tunnel": a.tunnel, "action": a.action, "detail": a.detail} for a in report.actions ], } typer.echo(json.dumps(payload, indent=2)) return if not report.actions: typer.echo("No reverse tunnels configured.") return for action in report.actions: typer.echo(f"{action.tunnel}: {action.action} — {action.detail}") typer.echo(f"done ({report.cleaned_count} cleaned)") @maintenance_app.command("install-cron") def maintenance_install_cron(): """Install a 03:00 daily cron job for `bridge maintenance cleanup --restart`.""" installed, message = install_cleanup_cron() if installed: typer.echo("Installed nightly cleanup cron:") typer.echo(f" {message}") else: typer.echo(message) raise typer.Exit(2) @maintenance_app.command("uninstall-cron") def maintenance_uninstall_cron(): """Remove the nightly cleanup cron job.""" removed, message = uninstall_cleanup_cron() if removed: typer.echo(message) else: typer.echo(message) raise typer.Exit(2) @maintenance_app.command("show-cron") def maintenance_show_cron(): """Show the configured nightly cleanup cron line.""" existing = read_installed_cron() if existing: typer.echo(existing) else: typer.echo("Nightly cleanup cron is not installed.") typer.echo("Would install:") typer.echo(f" {build_cron_line()}") @app.command() def conventions(): """Show the actor naming conventions enforced by tunnels.yaml.""" typer.echo(_CONVENTIONS_TEXT)