Two groups of changes, committed together as the follow-up to the initial push: 1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub, so a clone actually gets the UmbraAudio branding/integration commit 012e452 rather than vanilla upstream. 2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge, remote_credential_broker (Keychain-backed, secrets read at runtime and generated via read_secret/random_secret — no hardcoded credentials), the enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the discovery/attribution/profiling modules, config, chapter_builder, emotion, pronunciation, qwen_tts and routes. Also adds *.db to .gitignore: SQLite project databases are runtime state and no .db file was tracked before. Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and the committed copy has zero secret-looking values; the broker ships only config.example.json.
96 lines
4.0 KiB
Python
96 lines
4.0 KiB
Python
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
|