#!/usr/bin/env python3 """Prepare a new installation using only Python's standard library. Starts nothing.""" import ipaddress import os from pathlib import Path import re import secrets import sys ROOT = Path(__file__).resolve().parent def main(): settings = {} for line in (ROOT / "settings.env").read_text().splitlines(): line = line.strip() if not line or line.startswith("#"): continue key, sep, value = line.partition("=") if not sep or key in settings: raise ValueError("Use unique KEY=value lines in settings.env") settings[key] = value.strip() if set(settings) != {"DOMAIN", "PUBLIC_IPV4", "ACME_EMAIL"}: raise ValueError("settings.env needs DOMAIN, PUBLIC_IPV4 and ACME_EMAIL only") domain = settings["DOMAIN"].lower() label = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" if len(domain) > 240 or not re.fullmatch(label + r"(?:\." + label + r")+", domain): raise ValueError("DOMAIN must be a DNS domain, without a URL or quotes") address = ipaddress.IPv4Address(settings["PUBLIC_IPV4"]) if address.is_multicast or address.is_unspecified or address.is_loopback: raise ValueError("PUBLIC_IPV4 must identify the server's public IPv4 address") if not re.fullmatch(r"[A-Za-z0-9._+%-]+@[A-Za-z0-9.-]+", settings["ACME_EMAIL"]): raise ValueError("ACME_EMAIL must be an email address, without quotes") if domain == "example.com" or str(address) == "203.0.113.10": raise ValueError("Replace the example domain and IP in settings.env first") # Refuse to rotate secrets or overwrite an existing server's identity/data. for name in (".env", "generated", "data"): if (ROOT / name).exists() or (ROOT / name).is_symlink(): raise ValueError(f"{name} already exists; configure.py is for a NEW installation only") if os.getuid() == 0: raise ValueError("Run as the non-root Linux user who will operate Docker, not with sudo") values = { "COMPOSE_PROJECT_NAME": "matrix-guide", "MATRIX_HOST": "matrix." + domain, "ELEMENT_HOST": "chat." + domain, "RTC_HOST": "rtc." + domain, "TURN_HOST": "turn." + domain, "PUBLIC_IPV4": str(address), "ACME_EMAIL": settings["ACME_EMAIL"], "SYNAPSE_UID": str(os.getuid()), "SYNAPSE_GID": str(os.getgid()), } for name in ("POSTGRES_PASSWORD", "LIVEKIT_SECRET", "TURN_SECRET", "REGISTRATION_SECRET", "MACAROON_SECRET", "FORM_SECRET"): values[name] = secrets.token_hex(32) rendered = {} for path in sorted((ROOT / "templates").glob("*.template")): rendered[path.name.removesuffix(".template")] = re.sub( r"@@([A-Z0-9_]+)@@", lambda match: values[match[1]], path.read_text() ) if len(rendered) != 5: raise ValueError("Expected five config templates; keep the bundle intact") if any("@@" in content for content in rendered.values()): raise ValueError("An unresolved template marker remains") os.umask(0o077) (ROOT / "generated").mkdir(mode=0o700) for name, content in rendered.items(): path = ROOT / "generated" / name path.write_text(content) # Different image users must read their individual bind-mounted file. # The private host directory (0700) protects these files on the host. path.chmod(0o644) (ROOT / ".env").write_text("".join(f"{key}={value}\n" for key, value in values.items())) for name in ("synapse", "postgres", "traefik"): (ROOT / "data" / name).mkdir(parents=True, mode=0o700) (ROOT / "data/traefik/acme.json").touch(mode=0o600) print("Prepared .env, generated/ and data/ with fresh secrets. No containers started.") print(f"Server name: {values['MATRIX_HOST']} (choose permanently before first start)") print("Continue with the signing-key and startup commands in README.md.") if __name__ == "__main__": try: main() except (OSError, ValueError, KeyError) as exc: sys.exit(f"Configuration stopped: {exc}")