"""CLI for OpsBridge — bridge command.""" from __future__ import annotations import json import os import sys from pathlib import Path from typing import Optional import typer from bridge.audit import AuditLogger from bridge.config import ConfigError, load_config from bridge.manager import TunnelManager from bridge.models import BridgeState from bridge.state import StateManager app = typer.Typer( name="bridge", help="OpsBridge — SSH reverse tunnel lifecycle manager.", no_args_is_help=True, ) 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 _require_tunnel(cfg, name: str): if name not in cfg.tunnels: typer.echo(f"Error: tunnel '{name}' not found in config", err=True) raise typer.Exit(1) return cfg.tunnels[name] @app.command() def up( tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all)"), ): """Start one or all tunnels.""" cfg = _load_or_exit() sd = _state_dir() names = [tunnel] if tunnel else list(cfg.tunnels.keys()) if tunnel: _require_tunnel(cfg, tunnel) 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)"), ): """Stop one or all tunnels.""" cfg = _load_or_exit() sd = _state_dir() names = [tunnel] if tunnel else list(cfg.tunnels.keys()) if tunnel: _require_tunnel(cfg, tunnel) 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) @app.command() def restart( tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all)"), ): """Restart one or all tunnels.""" cfg = _load_or_exit() sd = _state_dir() names = [tunnel] if tunnel else list(cfg.tunnels.keys()) if tunnel: _require_tunnel(cfg, tunnel) for name in names: tcfg = cfg.tunnels[name] mgr = TunnelManager(tcfg, state_dir=sd) mgr.stop() mgr.start() typer.echo(f"Restarted tunnel '{name}'.") @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) pid = state_mgr.read_pid(name) rows.append({ "tunnel": name, "state": state.value, "actor": tcfg.actor, "host": tcfg.host, "pid": pid, "uptime": None, # future: track start time "health": None, # future: last health check result }) if as_json: typer.echo(json.dumps(rows, indent=2)) else: _print_status_table(rows) def _print_status_table(rows): headers = ["TUNNEL", "STATE", "ACTOR", "HOST", "PID"] 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_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"], row["state"], row["actor"], row["host"], str(row["pid"] or ""), ])) @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() _require_tunnel(cfg, tunnel) 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 # Show last N lines 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) # seek to end 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