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:
2026-05-15 09:38:29 +02:00
parent 22601ef3e6
commit bd169a07e2
17 changed files with 730 additions and 145 deletions

View File

@@ -2,13 +2,14 @@
from __future__ import annotations
import os
import warnings
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional
import yaml
from bridge.models import ActorInfo, HealthCheckConfig, ReconnectPolicy, TunnelConfig
from bridge.models import ActorInfo, ActorType, HealthCheckConfig, ReconnectPolicy, TunnelConfig
class ConfigError(Exception):
@@ -91,6 +92,10 @@ def _parse_tunnel(name: str, data: dict) -> TunnelConfig:
if direction not in ("reverse", "local"):
raise ConfigError(f"Tunnel '{name}' direction must be 'reverse' or 'local', got: {direction!r}")
cert_command = data.get("cert_command") or None
if cert_command is not None:
cert_command = str(cert_command)
return TunnelConfig(
name=name,
host=str(data["host"]),
@@ -102,9 +107,40 @@ def _parse_tunnel(name: str, data: dict) -> TunnelConfig:
reconnect=reconnect,
health_check=health_check,
direction=direction,
cert_command=cert_command,
)
_LEGACY_CLASS_MAP = {
"human": ActorType.ADM,
"automation": ActorType.ATM,
}
_ACTOR_TYPE_PREFIXES = {
ActorType.ADM: "adm-",
ActorType.AGT: "agt-",
ActorType.ATM: "atm-",
}
def _parse_actor_type(name: str, raw_class: str) -> ActorType:
if raw_class in _LEGACY_CLASS_MAP:
warnings.warn(
f"Actor '{name}': class '{raw_class}' is deprecated; "
f"use '{_LEGACY_CLASS_MAP[raw_class].value}' instead.",
DeprecationWarning,
stacklevel=4,
)
return _LEGACY_CLASS_MAP[raw_class]
try:
return ActorType(raw_class)
except ValueError:
raise ConfigError(
f"Actor '{name}' has unknown class '{raw_class}'; "
f"must be one of: adm, agt, atm (or legacy: human, automation)"
)
def _parse_actors(raw: dict) -> Dict[str, ActorInfo]:
actors = {}
for name, data in raw.items():
@@ -112,9 +148,16 @@ def _parse_actors(raw: dict) -> Dict[str, ActorInfo]:
raise ConfigError(f"Actor '{name}' must be a mapping")
if "class" not in data:
raise ConfigError(f"Actor '{name}' missing required field: class")
actor_type = _parse_actor_type(name, str(data["class"]))
required_prefix = _ACTOR_TYPE_PREFIXES[actor_type]
if not name.startswith(required_prefix):
raise ConfigError(
f"Actor '{name}' has type '{actor_type.value}' but name must start "
f"with '{required_prefix}' (got '{name}')"
)
actors[name] = ActorInfo(
name=name,
actor_class=str(data["class"]),
actor_type=actor_type,
description=str(data.get("description", "")),
)
return actors