From 18c7e6ad5765ee30c33567617317b764d8d36fb2 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:28:26 -0400 Subject: [PATCH] Adding configuration script --- .claude/settings.local.json | 7 +- Justfile | 10 +- configure.py | 789 ++++++++++++++++++++++++++++++++++++ 3 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 configure.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 54895cd..ad92ac3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -30,7 +30,12 @@ "Bash(ansible-lint roles/dns/)", "Bash(ansible-lint roles/graylog/)", "Bash(ansible-lint .)", - "Bash(grep -v \"^WARNING\\\\|^$\\\\|^A new\\\\|^Upgrade\\\\|^Read\")" + "Bash(grep -v \"^WARNING\\\\|^$\\\\|^A new\\\\|^Upgrade\\\\|^Read\")", + "Bash(python3 configure.py --stdout)", + "Bash(grep -E \"^\\(---$|base_domain:|traefik_domain:|graylog_root_password_sha2:|dns_server_ip:|dmarc_policy:|forgejo_ssh_port:|stalwart_dkim_selector:\\)\")", + "Bash(python3 -c \"import sys, yaml; data = yaml.safe_load\\(sys.stdin\\); print\\(f'Valid YAML: {len\\(data\\)} keys'\\); print\\('changeme keys:', [k for k,v in data.items\\(\\) if isinstance\\(v,str\\) and 'changeme' in v]\\)\")", + "Bash(python3 -c \"import sys, yaml; d = yaml.safe_load\\(sys.stdin\\); print\\('base_domain:', d['base_domain']\\); print\\('graylog_root_password_sha2 len:', len\\(d['graylog_root_password_sha2']\\)\\); print\\('authentik_secret_key len:', len\\(d['authentik_secret_key']\\)\\)\")", + "Bash(python3 -c \"import sys, yaml; d = yaml.safe_load\\(sys.stdin\\); print\\('base_domain type:', type\\(d['base_domain']\\).__name__, '=', repr\\(d['base_domain']\\)\\); print\\('All 93 keys present:', len\\(d\\) == 93\\)\")" ] } } diff --git a/Justfile b/Justfile index 7bab8af..976785b 100644 --- a/Justfile +++ b/Justfile @@ -16,7 +16,7 @@ setup-host: test: #!/usr/bin/env bash set -euo pipefail - roles=(authentik common forgejo graylog headscale jitsi matrix minio nextcloud roundcube stalwart vaultwarden wazuh website) + roles=(authentik common dns forgejo graylog headscale jitsi matrix minio nextcloud roundcube stalwart vaultwarden wazuh website) failed=() for role in "${roles[@]}"; do echo "==> Testing role: $role" @@ -49,3 +49,11 @@ check: # Lint the project lint: ansible-lint + +# Interactively generate a new group_vars/all.yml for a deployment +configure: + python3 configure.py + +# Generate group_vars to a custom path: just configure-to /tmp/all.yml +configure-to path: + python3 configure.py -o {{ path }} diff --git a/configure.py b/configure.py new file mode 100644 index 0000000..595a863 --- /dev/null +++ b/configure.py @@ -0,0 +1,789 @@ +#!/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()