220 lines
6.2 KiB
Python
220 lines
6.2 KiB
Python
|
|
"""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
|