#!/usr/bin/env python3 """ sovereign-configure — interactive configurator for Sovereign group_vars/all.yml Prompts for the handful of deployment-specific values (domain, org name, server IP, admin email), auto-generates every password and cryptographic secret, and writes a ready-to-use group_vars/all.yml. Usage: python3 configure.py # writes to inventories/production/group_vars/all.yml python3 configure.py -o /path/to/all.yml # custom output path python3 configure.py --stdout # print to stdout (no file written) """ from __future__ import annotations import argparse import base64 import hashlib import os import secrets import string import sys from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path # ── ANSI colours ────────────────────────────────────────────────────────────── BOLD = "\033[1m" DIM = "\033[2m" GREEN = "\033[32m" CYAN = "\033[36m" YELLOW = "\033[33m" RED = "\033[31m" RESET = "\033[0m" def _strip_colour() -> None: """Disable all ANSI codes when stdout is not a TTY.""" global BOLD, DIM, GREEN, CYAN, YELLOW, RED, RESET BOLD = DIM = GREEN = CYAN = YELLOW = RED = RESET = "" # ── Crypto helpers ───────────────────────────────────────────────────────────── _ALPHA = string.ascii_letters + string.digits def gen_password(length: int = 24) -> str: """Alphanumeric password — safe to embed in YAML without escaping.""" return "".join(secrets.choice(_ALPHA) for _ in range(length)) def gen_secret(length: int = 50) -> str: """Alphanumeric + hyphens/underscores — for tokens and secret keys.""" return "".join(secrets.choice(_ALPHA + "-_") for _ in range(length)) def sha256(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() def htpasswd_sha1(username: str, password: str) -> str: """Return an htpasswd entry with SHA-1 digest (accepted by Traefik basicauth).""" digest = base64.b64encode(hashlib.sha1(password.encode()).digest()).decode() return f"{username}:{{SHA}}{digest}" def yaml_str(value: str) -> str: """Escape a value for safe embedding inside a YAML double-quoted scalar.""" return value.replace("\\", "\\\\").replace('"', '\\"') # ── UI helpers ───────────────────────────────────────────────────────────────── def _ui(*args, **kwargs) -> None: """Print to stderr so interactive UI is separate from --stdout YAML output.""" print(*args, file=sys.stderr, **kwargs) def header(title: str) -> None: width = 64 _ui(f"\n{CYAN}{'─' * width}{RESET}") _ui(f"{CYAN} {BOLD}{title}{RESET}") _ui(f"{CYAN}{'─' * width}{RESET}") def info(msg: str) -> None: _ui(f" {DIM}{msg}{RESET}") def generated(label: str, value: str, sensitive: bool = False) -> None: display = "···" if sensitive else f"{GREEN}{value}{RESET}" _ui(f" {DIM}↳ auto-generated {label}:{RESET} {display}") def prompt( label: str, default: str = "", required: bool = False, choices: list[str] | None = None, ) -> str: """Prompt for input; return default on empty Enter.""" if choices: choice_hint = f"{DIM}({'/'.join(choices)}){RESET} " else: choice_hint = "" if default: default_hint = f" {DIM}[{default}]{RESET}" elif required: default_hint = f" {RED}(required){RESET}" else: default_hint = "" sys.stderr.write(f" {choice_hint}{label}{default_hint}: ") sys.stderr.flush() try: value = input().strip() except (KeyboardInterrupt, EOFError): _ui(f"\n{YELLOW}Aborted.{RESET}") sys.exit(1) if not value and required and not default: _ui(f" {RED}✖ This field cannot be empty.{RESET}") return prompt(label, default, required, choices) if choices and value and value not in choices: _ui(f" {RED}✖ Must be one of: {', '.join(choices)}{RESET}") return prompt(label, default, required, choices) return value or default # ── Configuration dataclass ─────────────────────────────────────────────────── @dataclass class Config: # Core base_domain: str tenant_name: str tenant_logo_local_path: str tenant_primary_color: str tenant_accent_color: str sovereign_base_dir: str # Traefik traefik_acme_email: str traefik_dashboard_user: str traefik_dashboard_password_plain: str traefik_dashboard_htpasswd: str # Authentik authentik_version: str authentik_secret_key: str authentik_db_password: str authentik_admin_email: str authentik_admin_password: str # Graylog graylog_version: str graylog_password_secret: str graylog_admin_password: str graylog_root_password_sha2: str graylog_host: str # Stalwart stalwart_admin_password: str # Roundcube roundcube_db_password: str roundcube_des_key: str # Wazuh wazuh_admin_password: str wazuh_api_password: str # Headscale / WireGuard wireguard_port: int # Matrix matrix_version: str matrix_registration_secret: str matrix_db_password: str # Jitsi jitsi_version: str jitsi_jicofo_auth_password: str jitsi_jvb_auth_password: str jitsi_jibri_recorder_password: str jitsi_jibri_xmpp_password: str jitsi_turn_secret: str # MinIO minio_root_user: str minio_root_password: str minio_nextcloud_access_key: str minio_nextcloud_secret_key: str # Nextcloud nextcloud_version: str nextcloud_admin_user: str nextcloud_admin_password: str nextcloud_db_password: str nextcloud_db_root_password: str # Vaultwarden vaultwarden_admin_token: str vaultwarden_db_password: str # Forgejo forgejo_admin_user: str forgejo_admin_email: str forgejo_admin_password: str forgejo_db_password: str forgejo_secret_key: str forgejo_internal_token: str forgejo_lfs_jwt_secret: str forgejo_ssh_port: int # SMTP smtp_host: str smtp_port: int smtp_password: str smtp_tls: str # DNS bind_version: str dns_server_ip: str stalwart_dkim_selector: str dmarc_policy: str # ── Collection ───────────────────────────────────────────────────────────────── def collect() -> Config: """Walk the user through each configuration section.""" _ui(f"\n{BOLD}{'═' * 64}{RESET}") _ui(f"{BOLD} Sovereign Deployment Configurator{RESET}") _ui(f"{BOLD}{'═' * 64}{RESET}") _ui(f""" This wizard generates a customised {DIM}group_vars/all.yml{RESET} for a new Sovereign deployment. You will be prompted for a handful of deployment-specific values. All passwords and cryptographic secrets are auto-generated using a cryptographically secure random source. {YELLOW}⚠ Keep the output file secure — it contains all service credentials.{RESET} """) # ── Core ────────────────────────────────────────────────────────────────── header("Core") base_domain = prompt("Base domain (e.g. acme.com)", required=True) tenant_name = prompt("Organisation name", "My Organisation") tenant_logo_local_path = prompt( "Logo path on Ansible controller (blank to skip)", "" ) tenant_primary_color = prompt("Primary brand colour (hex)", "#2563eb") tenant_accent_color = prompt("Accent colour (hex)", "#1e40af") sovereign_base_dir = prompt("Data directory on server", "/opt/sovereign") # Derive a shared admin email used as default for several services admin_email = prompt("Admin email address", f"admin@{base_domain}") # ── Traefik ─────────────────────────────────────────────────────────────── header("Traefik (reverse proxy + TLS)") traefik_acme_email = prompt("ACME / Let's Encrypt email", admin_email) traefik_dashboard_user = prompt("Dashboard username", "admin") traefik_dashboard_plain = gen_password(20) traefik_dashboard_htpasswd = htpasswd_sha1(traefik_dashboard_user, traefik_dashboard_plain) generated("Traefik dashboard password", traefik_dashboard_plain) # ── Authentik ───────────────────────────────────────────────────────────── header("Authentik (identity provider)") authentik_version = prompt("Version", "2024.10.5") authentik_secret_key = gen_secret(50) authentik_db_password = gen_password(24) authentik_admin_email = admin_email authentik_admin_password = gen_password(20) generated("Authentik admin password", authentik_admin_password) # ── Graylog ─────────────────────────────────────────────────────────────── header("Graylog (centralised logging)") graylog_version = prompt("Version", "6.0") graylog_password_secret = gen_secret(32) graylog_admin_password = gen_password(20) graylog_root_password_sha2 = sha256(graylog_admin_password) graylog_host = prompt( "Host IP reachable from Docker containers (for GELF UDP)", "127.0.0.1", ) generated("Graylog admin password", graylog_admin_password) # ── Stalwart ────────────────────────────────────────────────────────────── header("Stalwart (mail server)") stalwart_admin_password = gen_password(20) generated("Stalwart admin password", stalwart_admin_password) # ── Roundcube ───────────────────────────────────────────────────────────── # (no user-facing prompts; all auto-generated) roundcube_db_password = gen_password(24) roundcube_des_key = gen_password(24) # ── Wazuh ───────────────────────────────────────────────────────────────── header("Wazuh (security monitoring)") wazuh_admin_password = gen_password(20) wazuh_api_password = gen_password(20) generated("Wazuh admin password", wazuh_admin_password) # ── Headscale / WireGuard ───────────────────────────────────────────────── header("Headscale (WireGuard VPN)") wireguard_port = int(prompt("WireGuard UDP port", "51820")) # ── Matrix / Element ────────────────────────────────────────────────────── header("Matrix / Element (team chat)") matrix_version = prompt("Synapse version", "v1.118.0") matrix_registration_secret = gen_secret(32) matrix_db_password = gen_password(24) # ── Jitsi ───────────────────────────────────────────────────────────────── header("Jitsi Meet (video conferencing)") jitsi_version = prompt("Version", "stable-9753") jitsi_jicofo_auth_password = gen_password(20) jitsi_jvb_auth_password = gen_password(20) jitsi_jibri_recorder_password = gen_password(20) jitsi_jibri_xmpp_password = gen_password(20) jitsi_turn_secret = gen_secret(32) # ── MinIO ───────────────────────────────────────────────────────────────── header("MinIO (object storage)") minio_root_user = prompt("Root username", "minioadmin") minio_root_password = gen_password(24) minio_nextcloud_access_key = prompt("Nextcloud bucket access key", "nextcloud") minio_nextcloud_secret_key = gen_password(32) generated("MinIO root password", minio_root_password) # ── Nextcloud ───────────────────────────────────────────────────────────── header("Nextcloud (file sync)") nextcloud_version = prompt("Version", "29") nextcloud_admin_user = prompt("Admin username", "admin") nextcloud_admin_password = gen_password(20) nextcloud_db_password = gen_password(24) nextcloud_db_root_password = gen_password(24) generated("Nextcloud admin password", nextcloud_admin_password) # ── Vaultwarden ─────────────────────────────────────────────────────────── header("Vaultwarden (password manager)") vaultwarden_admin_token = gen_secret(40) vaultwarden_db_password = gen_password(24) generated("Vaultwarden admin token", vaultwarden_admin_token, sensitive=True) # ── Forgejo ─────────────────────────────────────────────────────────────── header("Forgejo (Git hosting)") forgejo_admin_user = prompt("Admin username", "admin") forgejo_admin_email = admin_email forgejo_admin_password = gen_password(20) forgejo_db_password = gen_password(24) forgejo_secret_key = gen_secret(50) forgejo_internal_token = gen_secret(50) forgejo_lfs_jwt_secret = gen_secret(32) forgejo_ssh_port = int(prompt("SSH port", "2222")) generated("Forgejo admin password", forgejo_admin_password) # ── SMTP ────────────────────────────────────────────────────────────────── header("SMTP (outbound email)") info("Defaults to the bundled Stalwart server.") info("Change smtp_host in the output file to use an external relay.") smtp_host = prompt("SMTP host", "stalwart") smtp_port = int(prompt("SMTP port", "587")) smtp_tls = prompt("TLS mode", "starttls", choices=["starttls", "tls", "none"]) smtp_password = gen_password(20) # ── DNS / BIND9 ─────────────────────────────────────────────────────────── header("DNS / BIND9 (authoritative nameserver)") info(f"After deployment, register ns1.{base_domain} as a glue record at") info(f"your domain registrar pointing to this server's IP, then set the") info(f"domain nameservers to ns1.{base_domain}.") dns_server_ip = prompt("Server public IPv4 address", required=True) bind_version = prompt("BIND9 image version", "9.18-22.04_beta") info("") info("DKIM: retrieve the public key from the Stalwart admin UI after") info("first deployment. Add it to this file to publish the DKIM record.") stalwart_dkim_selector = prompt("DKIM selector", "default") dmarc_policy = prompt( "DMARC policy", "quarantine", choices=["none", "quarantine", "reject"], ) return Config( base_domain=base_domain, tenant_name=tenant_name, tenant_logo_local_path=tenant_logo_local_path, tenant_primary_color=tenant_primary_color, tenant_accent_color=tenant_accent_color, sovereign_base_dir=sovereign_base_dir, traefik_acme_email=traefik_acme_email, traefik_dashboard_user=traefik_dashboard_user, traefik_dashboard_password_plain=traefik_dashboard_plain, traefik_dashboard_htpasswd=traefik_dashboard_htpasswd, authentik_version=authentik_version, authentik_secret_key=authentik_secret_key, authentik_db_password=authentik_db_password, authentik_admin_email=authentik_admin_email, authentik_admin_password=authentik_admin_password, graylog_version=graylog_version, graylog_password_secret=graylog_password_secret, graylog_admin_password=graylog_admin_password, graylog_root_password_sha2=graylog_root_password_sha2, graylog_host=graylog_host, stalwart_admin_password=stalwart_admin_password, roundcube_db_password=roundcube_db_password, roundcube_des_key=roundcube_des_key, wazuh_admin_password=wazuh_admin_password, wazuh_api_password=wazuh_api_password, wireguard_port=wireguard_port, matrix_version=matrix_version, matrix_registration_secret=matrix_registration_secret, matrix_db_password=matrix_db_password, jitsi_version=jitsi_version, jitsi_jicofo_auth_password=jitsi_jicofo_auth_password, jitsi_jvb_auth_password=jitsi_jvb_auth_password, jitsi_jibri_recorder_password=jitsi_jibri_recorder_password, jitsi_jibri_xmpp_password=jitsi_jibri_xmpp_password, jitsi_turn_secret=jitsi_turn_secret, minio_root_user=minio_root_user, minio_root_password=minio_root_password, minio_nextcloud_access_key=minio_nextcloud_access_key, minio_nextcloud_secret_key=minio_nextcloud_secret_key, nextcloud_version=nextcloud_version, nextcloud_admin_user=nextcloud_admin_user, nextcloud_admin_password=nextcloud_admin_password, nextcloud_db_password=nextcloud_db_password, nextcloud_db_root_password=nextcloud_db_root_password, vaultwarden_admin_token=vaultwarden_admin_token, vaultwarden_db_password=vaultwarden_db_password, forgejo_admin_user=forgejo_admin_user, forgejo_admin_email=forgejo_admin_email, forgejo_admin_password=forgejo_admin_password, forgejo_db_password=forgejo_db_password, forgejo_secret_key=forgejo_secret_key, forgejo_internal_token=forgejo_internal_token, forgejo_lfs_jwt_secret=forgejo_lfs_jwt_secret, forgejo_ssh_port=forgejo_ssh_port, smtp_host=smtp_host, smtp_port=smtp_port, smtp_password=smtp_password, smtp_tls=smtp_tls, bind_version=bind_version, dns_server_ip=dns_server_ip, stalwart_dkim_selector=stalwart_dkim_selector, dmarc_policy=dmarc_policy, ) # ── YAML renderer ────────────────────────────────────────────────────────────── def render(c: Config, generated_at: str) -> str: """Return the full content of group_vars/all.yml for this Config.""" # Shorthand so f-string lines stay readable d = yaml_str(c.base_domain) def s(value: str) -> str: """Double-quoted YAML scalar.""" return f'"{yaml_str(value)}"' return f"""\ --- # ============================================================================= # SOVEREIGN DEPLOYMENT CONFIGURATION # Generated by configure.py on {generated_at} # Domain: {c.base_domain} # ============================================================================= # Base domain — all service subdomains are derived from this. base_domain: "{d}" # ============================================================================= # BRANDING # Applied across all services that support custom branding. # ============================================================================= # Display name shown in service UIs and email subjects. tenant_name: {s(c.tenant_name)} # Path to a logo image on the Ansible control machine (PNG or SVG recommended). # Leave empty to use each service's default logo. tenant_logo_local_path: {s(c.tenant_logo_local_path)} # Primary brand colour (hex). Used for backgrounds, buttons, and highlights. tenant_primary_color: {s(c.tenant_primary_color)} # Accent / secondary colour (hex). tenant_accent_color: {s(c.tenant_accent_color)} # Base directory for all service data on the target host. sovereign_base_dir: {s(c.sovereign_base_dir)} # ============================================================================= # TRAEFIK (reverse proxy + TLS termination) # ============================================================================= traefik_acme_email: {s(c.traefik_acme_email)} traefik_domain: "traefik.{yaml_str(c.base_domain)}" # htpasswd hash — user: {c.traefik_dashboard_user} plain: {c.traefik_dashboard_password_plain} traefik_dashboard_password: {s(c.traefik_dashboard_htpasswd)} # ============================================================================= # AUTHENTIK (identity provider + SSO) # ============================================================================= authentik_domain: "auth.{yaml_str(c.base_domain)}" authentik_version: {s(c.authentik_version)} authentik_secret_key: {s(c.authentik_secret_key)} authentik_db_password: {s(c.authentik_db_password)} authentik_admin_email: {s(c.authentik_admin_email)} authentik_admin_password: {s(c.authentik_admin_password)} # ============================================================================= # GRAYLOG (centralised logging) # ============================================================================= graylog_domain: "logs.{yaml_str(c.base_domain)}" graylog_version: {s(c.graylog_version)} graylog_password_secret: {s(c.graylog_password_secret)} # SHA-256 of the Graylog admin password (admin password is in the credentials summary) graylog_root_password_sha2: "{c.graylog_root_password_sha2}" graylog_host: {s(c.graylog_host)} # host IP reachable from containers graylog_gelf_port: 12201 # ============================================================================= # STALWART MAIL (SMTP / IMAP) # ============================================================================= stalwart_domain: "mail.{yaml_str(c.base_domain)}" stalwart_admin_password: {s(c.stalwart_admin_password)} stalwart_version: "latest" # ============================================================================= # ROUNDCUBE (webmail) # ============================================================================= roundcube_domain: "webmail.{yaml_str(c.base_domain)}" roundcube_version: "latest" roundcube_db_password: {s(c.roundcube_db_password)} roundcube_des_key: {s(c.roundcube_des_key)} # ============================================================================= # WAZUH (endpoint security + SIEM) # ============================================================================= wazuh_domain: "wazuh.{yaml_str(c.base_domain)}" wazuh_version: "4.9.0" wazuh_admin_password: {s(c.wazuh_admin_password)} wazuh_api_password: {s(c.wazuh_api_password)} # ============================================================================= # HEADSCALE (WireGuard mesh VPN) # ============================================================================= wireguard_domain: "vpn.{yaml_str(c.base_domain)}" headscale_domain: "headscale.{yaml_str(c.base_domain)}" headscale_version: "0.23.0" wireguard_port: {c.wireguard_port} headscale_noise_private_key: "" # generated automatically on first run # ============================================================================= # MATRIX / ELEMENT (team chat) # ============================================================================= matrix_domain: "matrix.{yaml_str(c.base_domain)}" element_domain: "chat.{yaml_str(c.base_domain)}" matrix_version: {s(c.matrix_version)} matrix_registration_secret: {s(c.matrix_registration_secret)} matrix_db_password: {s(c.matrix_db_password)} # ============================================================================= # JITSI MEET (video conferencing) # ============================================================================= jitsi_domain: "meet.{yaml_str(c.base_domain)}" jitsi_version: {s(c.jitsi_version)} jitsi_jicofo_auth_password: {s(c.jitsi_jicofo_auth_password)} jitsi_jvb_auth_password: {s(c.jitsi_jvb_auth_password)} jitsi_jibri_recorder_password: {s(c.jitsi_jibri_recorder_password)} jitsi_jibri_xmpp_password: {s(c.jitsi_jibri_xmpp_password)} jitsi_turn_secret: {s(c.jitsi_turn_secret)} # ============================================================================= # MINIO (S3-compatible object storage) # ============================================================================= minio_domain: "s3.{yaml_str(c.base_domain)}" minio_console_domain: "minio.{yaml_str(c.base_domain)}" minio_version: "latest" minio_root_user: {s(c.minio_root_user)} minio_root_password: {s(c.minio_root_password)} minio_nextcloud_bucket: "nextcloud" minio_nextcloud_access_key: {s(c.minio_nextcloud_access_key)} minio_nextcloud_secret_key: {s(c.minio_nextcloud_secret_key)} # ============================================================================= # NEXTCLOUD (file sync + collaboration) # ============================================================================= nextcloud_domain: "cloud.{yaml_str(c.base_domain)}" nextcloud_version: {s(c.nextcloud_version)} nextcloud_admin_user: {s(c.nextcloud_admin_user)} nextcloud_admin_password: {s(c.nextcloud_admin_password)} nextcloud_db_password: {s(c.nextcloud_db_password)} nextcloud_db_root_password: {s(c.nextcloud_db_root_password)} # ============================================================================= # VAULTWARDEN (password manager) # ============================================================================= vaultwarden_domain: "vault.{yaml_str(c.base_domain)}" vaultwarden_version: "latest" vaultwarden_admin_token: {s(c.vaultwarden_admin_token)} vaultwarden_db_password: {s(c.vaultwarden_db_password)} # ============================================================================= # FORGEJO (Git hosting) # ============================================================================= forgejo_domain: "git.{yaml_str(c.base_domain)}" forgejo_version: "latest" forgejo_db_password: {s(c.forgejo_db_password)} forgejo_secret_key: {s(c.forgejo_secret_key)} forgejo_internal_token: {s(c.forgejo_internal_token)} forgejo_lfs_jwt_secret: {s(c.forgejo_lfs_jwt_secret)} forgejo_admin_user: {s(c.forgejo_admin_user)} forgejo_admin_password: {s(c.forgejo_admin_password)} forgejo_admin_email: {s(c.forgejo_admin_email)} forgejo_ssh_port: {c.forgejo_ssh_port} # ============================================================================= # WEBSITE (static landing page) # ============================================================================= website_nginx_version: "alpine" # ============================================================================= # SMTP (outbound email) # ============================================================================= smtp_host: {s(c.smtp_host)} smtp_port: {c.smtp_port} smtp_from: "noreply@{yaml_str(c.base_domain)}" smtp_user: "noreply@{yaml_str(c.base_domain)}" smtp_password: {s(c.smtp_password)} smtp_tls: {s(c.smtp_tls)} # ============================================================================= # DNS / BIND9 (authoritative nameserver) # ============================================================================= # BIND9 Docker image version tag (ubuntu/bind9). bind_version: {s(c.bind_version)} # Public IPv4 of this server. # Register ns1.{c.base_domain} as a glue record at your domain registrar, # then set the domain nameservers to ns1.{c.base_domain}. dns_server_ip: {s(c.dns_server_ip)} dns_ns_hostname: "ns1.{yaml_str(c.base_domain)}" dns_ttl: 3600 # DKIM public key — retrieve from the Stalwart admin UI at # mail.{c.base_domain} → Settings → DKIM keys after the first deployment, # then re-run: ansible-playbook playbooks/site.yml --tags dns stalwart_dkim_selector: {s(c.stalwart_dkim_selector)} stalwart_dkim_public_key: "" # fill in after first Stalwart deployment # DMARC email authentication policy. dmarc_policy: {s(c.dmarc_policy)} # none | quarantine | reject dmarc_rua: "mailto:dmarc-reports@{yaml_str(c.base_domain)}" dmarc_ruf: "mailto:dmarc-forensics@{yaml_str(c.base_domain)}" """ # ── Credentials summary ──────────────────────────────────────────────────────── def print_credentials(c: Config) -> None: """Print a human-readable table of all admin credentials.""" col_w = 28 val_w = 40 def row(service: str, detail: str, credential: str) -> None: svc = f"{BOLD}{service:<{col_w}}{RESET}" det = f"{DIM}{detail:<{col_w}}{RESET}" val = f"{GREEN}{credential}{RESET}" _ui(f" {svc} {det} {val}") def divider() -> None: _ui(f" {DIM}{'─' * (col_w * 2 + val_w + 6)}{RESET}") header("Credential Summary — save this somewhere secure") _ui( f"\n {DIM}{'Service':<{col_w}} {'Detail':<{col_w}} Credential{RESET}" ) divider() row("Traefik dashboard", f"user: {c.traefik_dashboard_user}", c.traefik_dashboard_password_plain) row("Authentik", f"user: {c.authentik_admin_email}", c.authentik_admin_password) row("Graylog", "user: admin", c.graylog_admin_password) row("Stalwart mail", "admin UI", c.stalwart_admin_password) row("Wazuh", "user: admin", c.wazuh_admin_password) row("MinIO", f"user: {c.minio_root_user}", c.minio_root_password) row("Nextcloud", f"user: {c.nextcloud_admin_user}", c.nextcloud_admin_password) row("Vaultwarden", "admin token", c.vaultwarden_admin_token) row("Forgejo", f"user: {c.forgejo_admin_user}", c.forgejo_admin_password) divider() _ui(f""" {YELLOW}Post-deployment checklist:{RESET} 1. Point your registrar's nameservers to ns1.{c.base_domain} and add a glue record ns1.{c.base_domain} → {c.dns_server_ip} 2. After Stalwart is running, retrieve its DKIM public key from https://mail.{c.base_domain} and add it to stalwart_dkim_public_key in all.yml, then re-run: just update-service dns 3. Wazuh requires TLS certs before first run — see the Wazuh Docker documentation for the cert-generation step. """) # ── Entry point ──────────────────────────────────────────────────────────────── def main() -> None: here = Path(__file__).parent default_output = here / "inventories" / "production" / "group_vars" / "all.yml" parser = argparse.ArgumentParser( description="Interactive configurator for Sovereign group_vars/all.yml", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument( "-o", "--output", metavar="PATH", default=str(default_output), help=f"Output file path (default: {default_output})", ) parser.add_argument( "--stdout", action="store_true", help="Print the generated YAML to stdout instead of writing a file", ) args = parser.parse_args() if not sys.stdout.isatty(): _strip_colour() config = collect() generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") yaml_content = render(config, generated_at) if args.stdout: # YAML to stdout; all UI (prompts, summary) already goes to stderr sys.stdout.write(yaml_content) else: output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) # Refuse to silently overwrite an existing configured file if output_path.exists(): header("Output file already exists") _ui(f"\n {YELLOW}⚠ {output_path}{RESET}") answer = prompt("Overwrite?", choices=["yes", "no"], default="no") if answer != "yes": _ui(f"\n {DIM}Aborted. Nothing was written.{RESET}\n") sys.exit(0) output_path.write_text(yaml_content, encoding="utf-8") _ui(f"\n {GREEN}✔ Written to {output_path}{RESET}") print_credentials(config) if __name__ == "__main__": main()