99 lines
4.1 KiB
Python
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
|