feat(restart): route reverse tunnels through stale-forward cleanup

bridge restart now means blank-slate recovery: reverse tunnels run
should_cleanup_tunnel and clear orphan remote listeners before reconnecting;
healthy forwards are left running. Local-direction tunnels keep stop/start
only. CLI and MCP report per-tunnel actions (healthy, cleaned_and_restarted,
restarted, error) and exit non-zero on cleanup failure.

Closes BRIDGE-WP-0005.
This commit is contained in:
2026-06-21 20:12:13 +02:00
parent 8c11acc00c
commit 10c6fdaec9
8 changed files with 220 additions and 60 deletions

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import subprocess
import time
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse, urlunparse
@@ -215,6 +214,27 @@ def cleanup_tunnel(
return CleanupAction(name, "error", str(exc))
def restart_tunnel(
cfg: TunnelConfig,
state_mgr: StateManager,
) -> CleanupAction:
"""Restart one tunnel with blank-slate recovery for reverse tunnels."""
if cfg.direction == "local":
mgr = TunnelManager(cfg, state_dir=state_mgr._dir)
mgr.stop()
mgr.start()
return CleanupAction(cfg.name, "restarted", "local tunnel stop/start")
return cleanup_tunnel(cfg, state_mgr, restart=True)
def restart_all_tunnels(
cfg,
state_mgr: StateManager,
) -> list[CleanupAction]:
"""Restart every inline tunnel (reverse via cleanup path, local via stop/start)."""
return [restart_tunnel(tcfg, state_mgr) for tcfg in cfg.tunnels.values()]
def cleanup_all_tunnels(
cfg,
state_mgr: StateManager,

View File

@@ -13,10 +13,13 @@ 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
@@ -153,27 +156,37 @@ def down(
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."""
"""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)
mgr = TunnelManager(tcfg, state_dir=sd)
mgr.stop()
mgr.start()
typer.echo(f"Restarted tunnel '{tunnel}'.")
actions = [restart_tunnel(tcfg, state_mgr)]
else:
for name in _all_tunnel_names(cfg):
tcfg = cfg.tunnels[name]
mgr = TunnelManager(tcfg, state_dir=sd)
mgr.stop()
mgr.start()
typer.echo(f"Restarted tunnel '{name}'.")
actions = restart_all_tunnels(cfg, state_mgr)
_emit_restart_actions(actions)
@app.command()

View File

@@ -169,19 +169,22 @@ def bridge_down(tunnel: Optional[str] = None) -> dict:
def bridge_restart(tunnel: Optional[str] = None) -> dict:
"""Restart one or all configured tunnels.
Reverse tunnels run conditional remote stale-forward cleanup before
reconnecting; healthy forwards are left running.
Args:
tunnel: Tunnel name to restart. If omitted, restarts all inline tunnels.
Returns:
{"restarted": [...]} or {"error": "..."}
{"actions": [{"tunnel", "action", "detail"}, ...]} or {"error": "..."}
"""
cfg, err = _load_cfg_or_error()
if err:
return err
from bridge.manager import TunnelManager
from bridge.cleanup import restart_all_tunnels, restart_tunnel
sd = _state_dir()
restarted = []
state_mgr = StateManager(state_dir=sd)
if tunnel:
from bridge.catalog.loader import load_catalog
@@ -196,18 +199,19 @@ def bridge_restart(tunnel: Optional[str] = None) -> dict:
tcfg = resolve(tunnel, catalog=catalog, inline_tunnels=cfg.tunnels)
except BridgeNotFound:
return {"error": f"Tunnel '{tunnel}' not found in config or catalog"}
mgr = TunnelManager(tcfg, state_dir=sd)
mgr.stop()
mgr.start()
restarted.append(tunnel)
actions = [restart_tunnel(tcfg, state_mgr)]
else:
for name, tcfg in cfg.tunnels.items():
mgr = TunnelManager(tcfg, state_dir=sd)
mgr.stop()
mgr.start()
restarted.append(name)
actions = restart_all_tunnels(cfg, state_mgr)
return {"restarted": restarted}
payload = {
"actions": [
{"tunnel": a.tunnel, "action": a.action, "detail": a.detail}
for a in actions
],
}
if any(a.action == "error" for a in actions):
payload["error"] = "one or more tunnels failed to restart"
return payload
@mcp.tool()