from __future__ import annotations import argparse import ipaddress import os import re import secrets import subprocess from pathlib import Path from typing import Final ROOT: Final = Path(__file__).resolve().parent ENV_PATH: Final = ROOT / ".env" RUNTIME: Final = ROOT / "runtime" / "mosquitto" IMAGE: Final = "eclipse-mosquitto:2.1.2-alpine" PLACEHOLDER: Final = "replace-with-" SAFE_IDENTIFIER: Final = re.compile( r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$" ) def _initialize_environment(bind_address: str, *, overwrite: bool = False) -> None: if ENV_PATH.exists() and not overwrite: raise RuntimeError(".env already exists; refusing to overwrite local credentials") normalized_bind_address = str(ipaddress.ip_address(bind_address)) values = { "MISSIONCORE_MQTT_BIND_ADDRESS": normalized_bind_address, "MISSIONCORE_MQTT_PORT": "1883", "MISSIONCORE_TELEMETRY_QUERY_PORT": "18030", "MISSIONCORE_DB_NAME": "missioncore_telemetry", "MISSIONCORE_DB_USER": "missioncore_ingest", "MISSIONCORE_DB_PASSWORD": secrets.token_urlsafe(36), "MISSIONCORE_DB_INGEST_PASSWORD": secrets.token_urlsafe(36), "MISSIONCORE_MQTT_INGEST_USER": "missioncore-ingest", "MISSIONCORE_MQTT_INGEST_PASSWORD": secrets.token_urlsafe(36), "MISSIONCORE_MQTT_WORKER_006_USER": "worker-006", "MISSIONCORE_MQTT_WORKER_006_CONTOUR": "worker-006", "MISSIONCORE_MQTT_WORKER_006_PASSWORD": secrets.token_urlsafe(36), } ENV_PATH.write_text( "".join(f"{name}={value}\n" for name, value in values.items()), encoding="utf-8", ) os.chmod(ENV_PATH, 0o600) def _environment() -> dict[str, str]: if not ENV_PATH.is_file(): raise RuntimeError("copy .env.example to .env and set unique secrets first") values: dict[str, str] = {} for raw_line in ENV_PATH.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue name, value = line.split("=", 1) values[name.strip()] = value.strip() return values def _migrate_environment() -> None: values = _environment() additions: dict[str, str] = {} if "MISSIONCORE_DB_INGEST_PASSWORD" not in values: additions["MISSIONCORE_DB_INGEST_PASSWORD"] = secrets.token_urlsafe(36) if "MISSIONCORE_MQTT_WORKER_006_CONTOUR" not in values: additions["MISSIONCORE_MQTT_WORKER_006_CONTOUR"] = "worker-006" if not additions: return with ENV_PATH.open("a", encoding="utf-8", newline="\n") as stream: for name, value in additions.items(): stream.write(f"{name}={value}\n") stream.flush() os.fsync(stream.fileno()) os.chmod(ENV_PATH, 0o600) def _required(values: dict[str, str], name: str) -> str: value = values.get(name, "") if not value or value.startswith(PLACEHOLDER): raise RuntimeError(f"{name} must contain a non-placeholder value") return value def _identifier(values: dict[str, str], name: str) -> str: value = _required(values, name) if SAFE_IDENTIFIER.fullmatch(value) is None: raise RuntimeError(f"{name} must contain a DNS-safe lowercase identifier") return value def _password_entry(path: Path, username: str, password: str, *, create: bool) -> None: command = [ "docker", "run", "--rm", "-i", "-v", f"{RUNTIME}:/out", IMAGE, "mosquitto_passwd", ] if create: command.append("-c") command.extend([f"/out/{path.name}", username]) subprocess.run( command, check=True, input=f"{password}\n{password}\n", text=True, ) def _prepare_password_entries( path: Path, ingest_user: str, ingest_password: str, worker_user: str, worker_password: str, ) -> None: _password_entry( path, ingest_user, ingest_password, create=not path.exists(), ) _password_entry(path, worker_user, worker_password, create=False) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument( "--initialize", action="store_true", help="create a private .env with generated local credentials", ) parser.add_argument( "--migrate", action="store_true", help="add newly required generated secrets without replacing existing values", ) parser.add_argument( "--mqtt-bind-address", default="127.0.0.1", help="host IP exposed to telemetry agents", ) arguments = parser.parse_args() if arguments.initialize: _initialize_environment(arguments.mqtt_bind_address) if arguments.migrate: _migrate_environment() values = _environment() ingest_user = _required(values, "MISSIONCORE_MQTT_INGEST_USER") ingest_password = _required(values, "MISSIONCORE_MQTT_INGEST_PASSWORD") worker_user = _identifier(values, "MISSIONCORE_MQTT_WORKER_006_USER") worker_contour = _identifier( values, "MISSIONCORE_MQTT_WORKER_006_CONTOUR", ) worker_password = _required(values, "MISSIONCORE_MQTT_WORKER_006_PASSWORD") _required(values, "MISSIONCORE_DB_PASSWORD") RUNTIME.mkdir(parents=True, exist_ok=True) password_path = RUNTIME / "passwords" _prepare_password_entries( password_path, ingest_user, ingest_password, worker_user, worker_password, ) acl_path = RUNTIME / "acl" acl_path.write_text( "\n".join( [ f"user {ingest_user}", "topic read mission-core/v1/contours/+/agents/+/+", "topic read $SYS/broker/uptime", "", "# Every credential is scoped to one contour and one stable agent.", f"user {worker_user}", ( "topic write mission-core/v1/contours/" f"{worker_contour}/agents/{worker_user}/+" ), "", ] ), encoding="utf-8", ) os.chmod(password_path, 0o600) os.chmod(acl_path, 0o600) print("Mosquitto password and ACL files prepared.", flush=True) if __name__ == "__main__": main()