generated from coulomb/repo-seed
feat(directive): implement BRIDGE-WP-0004 AccessManagementDirective alignment
- ActorType enum (adm/agt/atm) replaces actor_class string; config validates naming convention (adm-*/agt-*/atm-*) with hard ConfigError on mismatch; legacy 'human'/'automation' values accepted with DeprecationWarning - cert_command: pluggable shell string run before each SSH launch; cert written to state dir; -i cert appended to SSH command alongside -i key - TTL-aware cert refresh: parses Valid-to via ssh-keygen -L; pre-emptive restart 5 min before expiry (no backoff, no attempt increment); CERT_EXPIRING logged - CertAcquisitionError: cert failures trigger normal backoff/retry loop - cert_identity: Key ID parsed from cert and recorded in BRIDGE_CONNECTED event - bridge cert-status: new CLI command; exit 1 on expired cert; --json flag - 233 tests passing, ruff clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -357,6 +359,84 @@ def _print_check_table(results):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user