Files
UmbraAudio/infra/remote_credential_broker/config.py
2026-10-18 19:01:04 -07:00

99 lines
4.1 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
_HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$")
class ConfigError(ValueError):
"""Raised when the broker configuration is invalid."""
@dataclass(frozen=True)
class RemoteProfile:
name: str
host: str
username: str
port: int = 22
ssh_credential: str | None = None
sudo_credential: str | None = None
known_hosts: str = "~/.ssh/known_hosts"
allowed_sudo_commands: tuple[tuple[str, ...], ...] = ()
def validate(self) -> None:
if not _PROFILE_RE.fullmatch(self.name):
raise ConfigError(f"Invalid remote profile name: {self.name!r}")
if not _HOST_RE.fullmatch(self.host):
raise ConfigError(f"Invalid host in profile {self.name!r}")
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]{0,31}", self.username):
raise ConfigError(f"Invalid SSH username in profile {self.name!r}")
if not 1 <= self.port <= 65535:
raise ConfigError(f"Invalid SSH port in profile {self.name!r}")
for credential in (self.ssh_credential, self.sudo_credential):
if credential is not None and not _PROFILE_RE.fullmatch(credential):
raise ConfigError(f"Invalid credential profile in {self.name!r}")
for command in self.allowed_sudo_commands:
if not command or any(not isinstance(part, str) or not part for part in command):
raise ConfigError(f"Invalid sudo command allowlist in profile {self.name!r}")
if any("\x00" in part for part in command):
raise ConfigError(f"NUL byte in sudo command allowlist in {self.name!r}")
def sudo_allowed(self, command: tuple[str, ...]) -> bool:
return command in self.allowed_sudo_commands
@dataclass(frozen=True)
class BrokerConfig:
profiles: dict[str, RemoteProfile]
@classmethod
def load(cls, path: Path) -> BrokerConfig:
if not path.exists():
return cls(profiles={})
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ConfigError(f"Unable to read broker config: {path}") from exc
if not isinstance(raw, dict) or not isinstance(raw.get("profiles", {}), dict):
raise ConfigError("Broker config must contain a profiles object")
profiles: dict[str, RemoteProfile] = {}
for name, item in raw.get("profiles", {}).items():
if not isinstance(item, dict):
raise ConfigError(f"Profile {name!r} must be an object")
commands_raw = item.get("allowed_sudo_commands", [])
if not isinstance(commands_raw, list):
raise ConfigError(f"allowed_sudo_commands for {name!r} must be a list")
commands: list[tuple[str, ...]] = []
for command in commands_raw:
if not isinstance(command, list) or not all(isinstance(x, str) for x in command):
raise ConfigError(f"Invalid sudo command entry for {name!r}")
commands.append(tuple(command))
profile = RemoteProfile(
name=name,
host=str(item.get("host", "")),
username=str(item.get("username", "")),
port=int(item.get("port", 22)),
ssh_credential=item.get("ssh_credential"),
sudo_credential=item.get("sudo_credential"),
known_hosts=str(item.get("known_hosts", "~/.ssh/known_hosts")),
allowed_sudo_commands=tuple(commands),
)
profile.validate()
profiles[name] = profile
return cls(profiles=profiles)
def get(self, name: str) -> RemoteProfile:
try:
return self.profiles[name]
except KeyError as exc:
raise ConfigError(f"Unknown remote profile: {name!r}") from exc