Add three submodules (central-context, mboxer, notification-bridge), devbox-connect as tracked files, and top-level project docs.
163 lines
4.8 KiB
Python
163 lines
4.8 KiB
Python
"""Configuration loading and validation."""
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
|
|
@dataclass
|
|
class TunnelConfig:
|
|
"""Configuration for a single SSH tunnel."""
|
|
|
|
name: str
|
|
host: str
|
|
remote_port: int
|
|
local_port: int | None = None # Defaults to remote_port if not specified
|
|
user: str | None = None # Defaults to global user
|
|
key_file: str | None = None # Defaults to global key_file
|
|
remote_host: str = "localhost" # The host on the remote side to connect to
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.local_port is None:
|
|
self.local_port = self.remote_port
|
|
|
|
|
|
@dataclass
|
|
class HostConfig:
|
|
"""Configuration for a host with multiple tunnels."""
|
|
|
|
host: str
|
|
user: str
|
|
key_file: str | None = None
|
|
port: int = 22
|
|
tunnels: list[TunnelConfig] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
"""Root configuration."""
|
|
|
|
hosts: list[HostConfig] = field(default_factory=list)
|
|
reconnect_delay: int = 5 # Seconds between reconnection attempts
|
|
max_reconnect_delay: int = 60 # Max delay with exponential backoff
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Configuration error."""
|
|
|
|
pass
|
|
|
|
|
|
def load_config(path: Path) -> Config:
|
|
"""Load configuration from a YAML file."""
|
|
if not path.exists():
|
|
raise ConfigError(f"Config file not found: {path}")
|
|
|
|
with open(path) as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
if not data:
|
|
raise ConfigError("Empty configuration file")
|
|
|
|
return _parse_config(data)
|
|
|
|
|
|
def _parse_config(data: dict) -> Config:
|
|
"""Parse configuration dictionary into Config object."""
|
|
config = Config(
|
|
reconnect_delay=data.get("reconnect_delay", 5),
|
|
max_reconnect_delay=data.get("max_reconnect_delay", 60),
|
|
)
|
|
|
|
# Handle simple format: list of tunnels with host info per tunnel
|
|
if "tunnels" in data:
|
|
config.hosts = _parse_simple_format(data["tunnels"], data)
|
|
# Handle grouped format: hosts with nested tunnels
|
|
elif "hosts" in data:
|
|
config.hosts = _parse_grouped_format(data["hosts"])
|
|
else:
|
|
raise ConfigError("Config must contain either 'tunnels' or 'hosts' key")
|
|
|
|
return config
|
|
|
|
|
|
def _parse_simple_format(tunnels: list[dict], global_config: dict) -> list[HostConfig]:
|
|
"""Parse simple format where each tunnel specifies its host."""
|
|
# Group tunnels by host
|
|
hosts_map: dict[str, HostConfig] = {}
|
|
|
|
global_user = global_config.get("user")
|
|
global_key_file = global_config.get("key_file")
|
|
|
|
for t in tunnels:
|
|
if "host" not in t:
|
|
raise ConfigError(f"Tunnel '{t.get('name', 'unnamed')}' missing 'host'")
|
|
if "remote_port" not in t:
|
|
raise ConfigError(f"Tunnel '{t.get('name', 'unnamed')}' missing 'remote_port'")
|
|
|
|
host = t["host"]
|
|
user = t.get("user", global_user)
|
|
if not user:
|
|
raise ConfigError(f"Tunnel '{t.get('name', 'unnamed')}' missing 'user'")
|
|
|
|
key_file = t.get("key_file", global_key_file)
|
|
ssh_port = t.get("ssh_port", 22)
|
|
|
|
# Create host key for grouping
|
|
host_key = f"{user}@{host}:{ssh_port}"
|
|
|
|
if host_key not in hosts_map:
|
|
hosts_map[host_key] = HostConfig(
|
|
host=host,
|
|
user=user,
|
|
key_file=key_file,
|
|
port=ssh_port,
|
|
)
|
|
|
|
tunnel = TunnelConfig(
|
|
name=t.get("name", f"{host}:{t['remote_port']}"),
|
|
host=host,
|
|
remote_port=t["remote_port"],
|
|
local_port=t.get("local_port"),
|
|
remote_host=t.get("remote_host", "localhost"),
|
|
)
|
|
hosts_map[host_key].tunnels.append(tunnel)
|
|
|
|
return list(hosts_map.values())
|
|
|
|
|
|
def _parse_grouped_format(hosts: list[dict]) -> list[HostConfig]:
|
|
"""Parse grouped format with hosts containing nested tunnels."""
|
|
result = []
|
|
|
|
for h in hosts:
|
|
if "host" not in h:
|
|
raise ConfigError("Host entry missing 'host' field")
|
|
if "user" not in h:
|
|
raise ConfigError(f"Host '{h['host']}' missing 'user' field")
|
|
|
|
host_config = HostConfig(
|
|
host=h["host"],
|
|
user=h["user"],
|
|
key_file=h.get("key_file"),
|
|
port=h.get("port", 22),
|
|
)
|
|
|
|
for t in h.get("tunnels", []):
|
|
if "remote_port" not in t:
|
|
raise ConfigError(f"Tunnel in host '{h['host']}' missing 'remote_port'")
|
|
|
|
tunnel = TunnelConfig(
|
|
name=t.get("name", f"{h['host']}:{t['remote_port']}"),
|
|
host=h["host"],
|
|
remote_port=t["remote_port"],
|
|
local_port=t.get("local_port"),
|
|
remote_host=t.get("remote_host", "localhost"),
|
|
)
|
|
host_config.tunnels.append(tunnel)
|
|
|
|
result.append(host_config)
|
|
|
|
return result
|