From 3542913689e6f576d1e8f6cd41683251cf805efa Mon Sep 17 00:00:00 2001 From: n0mad1k Date: Tue, 10 Mar 2026 13:27:12 -0400 Subject: [PATCH] LAN/local deployment support, OS compat, Cloudflare Tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Smart TLS: skip certbot for IPs/.local/.lan, self-signed with SAN, HSTS max-age=0 for self-signed certs, split LAN vs public messages - Dynamic PHP: versionless meta-packages, runtime detection via php_ver fact - Vaultwarden: fail-fast on armv7l (32-bit ARM not supported upstream) - Module prompts: accept IPs for matrix/cloud/vault/media, hard error on email with IP, all_in_one skips certbot email for LAN - Matrix: skip matrix. prefix strip for IPs, warn about immutable server_name - OS family guards: ansible_os_family == Debian on all apt tasks - SSH key path: expanduser().resolve() on user-provided key paths - Cloudflare Tunnel: post-deploy script (setup-tunnel.sh) using CF API token — no browser auth needed, creates tunnel + credentials + DNS + systemd Co-Authored-By: Claude Opus 4.6 --- phantom/modules/all_in_one.py | 62 ++-- phantom/modules/cloud.py | 2 +- phantom/modules/email.py | 9 + phantom/modules/matrix.py | 21 +- phantom/modules/media.py | 2 +- phantom/modules/vault.py | 2 +- phantom/phantom.py | 2 +- phantom/playbooks/all_in_one/main.yml | 3 +- phantom/playbooks/cloud/main.yml | 2 +- phantom/playbooks/cloud/tasks/configure.yml | 4 +- phantom/playbooks/cloud/tasks/install.yml | 38 ++- phantom/playbooks/cloud/tasks/nginx.yml | 6 +- .../common/templates/setup-tunnel.sh.j2 | 282 ++++++++++++++++++ phantom/playbooks/common/tls_setup.yml | 60 +++- phantom/playbooks/dns/tasks/install.yml | 1 + phantom/playbooks/matrix/tasks/element.yml | 1 + phantom/playbooks/matrix/tasks/nginx.yml | 4 + phantom/playbooks/matrix/tasks/synapse.yml | 1 + phantom/playbooks/media/tasks/install.yml | 2 + phantom/playbooks/media/tasks/nginx.yml | 4 + phantom/playbooks/vault/tasks/install.yml | 6 + phantom/playbooks/vault/tasks/nginx.yml | 4 + 22 files changed, 466 insertions(+), 52 deletions(-) create mode 100644 phantom/playbooks/common/templates/setup-tunnel.sh.j2 diff --git a/phantom/modules/all_in_one.py b/phantom/modules/all_in_one.py index e1e8541..f75b8f7 100644 --- a/phantom/modules/all_in_one.py +++ b/phantom/modules/all_in_one.py @@ -4,6 +4,8 @@ Composes multiple services onto a single server with nginx reverse proxy and TLS via Certbot. Includes clear warnings about single-server risks. """ +import re + CYAN = "\033[38;5;51m" WHITE = "\033[38;5;255m" GREY = "\033[38;5;244m" @@ -11,6 +13,9 @@ YELLOW = "\033[38;5;214m" RED = "\033[38;5;196m" RESET = "\033[0m" +_IP_RE = re.compile(r'^\d+\.\d+\.\d+\.\d+$') +_LOCAL_RE = re.compile(r'\.(local|lan|home|internal|test)$') + SERVICES = [ ("matrix", "Matrix + Element", "Encrypted messaging"), ("vpn", "WireGuard VPN", "Private VPN server"), @@ -69,37 +74,54 @@ def gather_config(config): config["services"] = selected_services config["all_in_one"] = True - # Base domain for nginx vhosts + # Base domain or IP for nginx vhosts config["domain"] = input( - f"\n {CYAN}Base domain (e.g. example.com):{RESET} " + f"\n {CYAN}Base domain or IP (e.g. example.com or 192.168.1.100):{RESET} " ).strip() if not config["domain"]: - print(f" {RED}Domain is required for reverse proxy.{RESET}") + print(f" {RED}Domain or IP is required for reverse proxy.{RESET}") return None - config["certbot_email"] = input( - f" {CYAN}Email for Let's Encrypt [{GREY}optional{RESET}]: " - ).strip() + base_domain = config["domain"] + is_ip = bool(_IP_RE.match(base_domain)) + is_local = bool(_LOCAL_RE.search(base_domain)) + + if is_ip: + # Warn about multiple web services on a single IP + web_services = [s for s in selected_services if s not in ("vpn", "dns")] + if len(web_services) > 1: + print(f"\n {YELLOW}WARNING: Multiple web services on a single IP.{RESET}") + print(f" {YELLOW}Only the last nginx config on port 443 wins.{RESET}") + print(f" {YELLOW}Consider using different ports or a reverse proxy with path-based routing.{RESET}") + + # Skip certbot email for IPs / .local domains + if is_ip or is_local: + config["certbot_email"] = "" + else: + config["certbot_email"] = input( + f" {CYAN}Email for Let's Encrypt [{GREY}optional{RESET}]: " + ).strip() # Gather per-service configs - # Save base domain — each service stores config under its own prefixed keys - base_domain = config["domain"] for svc in selected_services: try: mod = __import__(f"modules.{svc}", fromlist=[svc]) if hasattr(mod, "gather_config"): - # Set per-service subdomain default - svc_subdomains = { - "matrix": f"matrix.{base_domain}", - "cloud": f"cloud.{base_domain}", - "vault": f"vault.{base_domain}", - "media": f"media.{base_domain}", - "email": f"mail.{base_domain}", - "dns": f"dns.{base_domain}", - "vpn": base_domain, - } - if svc in svc_subdomains: - config["domain"] = svc_subdomains[svc] + # Set per-service domain: use same IP for all when base is IP + if is_ip: + config["domain"] = base_domain + else: + svc_subdomains = { + "matrix": f"matrix.{base_domain}", + "cloud": f"cloud.{base_domain}", + "vault": f"vault.{base_domain}", + "media": f"media.{base_domain}", + "email": f"mail.{base_domain}", + "dns": f"dns.{base_domain}", + "vpn": base_domain, + } + if svc in svc_subdomains: + config["domain"] = svc_subdomains[svc] config = mod.gather_config(config) if config is None: return None diff --git a/phantom/modules/cloud.py b/phantom/modules/cloud.py index 07ca936..0f1dd0f 100644 --- a/phantom/modules/cloud.py +++ b/phantom/modules/cloud.py @@ -12,7 +12,7 @@ def gather_config(config): print(f" {CYAN}│{RESET} {GREY}PHP-FPM + MariaDB + Redis + nginx{RESET}") config["domain"] = config.get("domain") or input( - f" {CYAN}│{RESET} Domain (e.g. cloud.example.com): " + f" {CYAN}│{RESET} Domain or IP (e.g. cloud.example.com or 192.168.1.100): " ).strip() config["cloud_admin_user"] = input( diff --git a/phantom/modules/email.py b/phantom/modules/email.py index f6a9cb1..d145cf7 100644 --- a/phantom/modules/email.py +++ b/phantom/modules/email.py @@ -1,10 +1,15 @@ """Mail-in-a-Box deployment module — native installer script.""" +import re + CYAN = "\033[38;5;51m" WHITE = "\033[38;5;255m" GREY = "\033[38;5;244m" +RED = "\033[38;5;196m" RESET = "\033[0m" +_IP_RE = re.compile(r'^\d+\.\d+\.\d+\.\d+$') + def gather_config(config): """Gather Mail-in-a-Box configuration.""" @@ -15,6 +20,10 @@ def gather_config(config): f" {CYAN}│{RESET} Mail domain (e.g. mail.example.com): " ).strip() + if _IP_RE.match(config.get("domain", "")): + print(f" {CYAN}│{RESET} {RED}ERROR: Email requires a real domain — bare IPs cannot receive mail (no MX records).{RESET}") + return None + config["email_first_user"] = input( f" {CYAN}│{RESET} First email user (e.g. admin@example.com): " ).strip() diff --git a/phantom/modules/matrix.py b/phantom/modules/matrix.py index 4762f50..08d4df5 100644 --- a/phantom/modules/matrix.py +++ b/phantom/modules/matrix.py @@ -1,25 +1,36 @@ """Matrix + Element homeserver deployment module.""" import getpass +import re import secrets CYAN = "\033[38;5;51m" WHITE = "\033[38;5;255m" GREY = "\033[38;5;244m" +YELLOW = "\033[38;5;214m" RESET = "\033[0m" +_IP_RE = re.compile(r'^\d+\.\d+\.\d+\.\d+$') + def gather_config(config): """Gather Matrix/Synapse + Element configuration.""" print(f"\n{CYAN} ┌─ Matrix Homeserver Configuration ─────────────────┐{RESET}") config["domain"] = config.get("domain") or input( - f" {CYAN}│{RESET} Domain (e.g. matrix.example.com): " + f" {CYAN}│{RESET} Domain or IP (e.g. matrix.example.com or 192.168.1.100): " ).strip() if not config["domain"]: print(f" {CYAN}│{RESET} Domain is required.") return None + is_ip = bool(_IP_RE.match(config["domain"])) + + if is_ip: + print(f" {CYAN}│{RESET} {YELLOW}WARNING: Deploying with an IP as server_name.{RESET}") + print(f" {CYAN}│{RESET} {YELLOW}Synapse server_name is immutable after first federation.{RESET}") + print(f" {CYAN}│{RESET} {YELLOW}Migrating to a domain later requires a fresh database.{RESET}") + config["matrix_admin_user"] = input( f" {CYAN}│{RESET} Admin username [{WHITE}admin{RESET}]: " ).strip() or "admin" @@ -38,8 +49,12 @@ def gather_config(config): ).strip().lower() config["matrix_element_web"] = config["matrix_element_web"] not in ("no", "n", "false") - config["matrix_server_name"] = config["domain"].replace("matrix.", "", 1) \ - if config["domain"].startswith("matrix.") else config["domain"] + # Skip matrix. prefix stripping for IPs + if is_ip: + config["matrix_server_name"] = config["domain"] + else: + config["matrix_server_name"] = config["domain"].replace("matrix.", "", 1) \ + if config["domain"].startswith("matrix.") else config["domain"] config["matrix_signing_key"] = secrets.token_hex(32) config["matrix_form_secret"] = secrets.token_hex(32) diff --git a/phantom/modules/media.py b/phantom/modules/media.py index d73f522..cd0b3aa 100644 --- a/phantom/modules/media.py +++ b/phantom/modules/media.py @@ -12,7 +12,7 @@ def gather_config(config): print(f" {CYAN}│{RESET} {GREY}Official apt repo + nginx reverse proxy{RESET}") config["domain"] = config.get("domain") or input( - f" {CYAN}│{RESET} Domain (e.g. media.example.com): " + f" {CYAN}│{RESET} Domain or IP (e.g. media.example.com or 192.168.1.100): " ).strip() config["media_library_path"] = input( diff --git a/phantom/modules/vault.py b/phantom/modules/vault.py index dadb575..c53b9da 100644 --- a/phantom/modules/vault.py +++ b/phantom/modules/vault.py @@ -14,7 +14,7 @@ def gather_config(config): print(f" {CYAN}│{RESET} {GREY}Pre-built binary + systemd + nginx{RESET}") config["domain"] = config.get("domain") or input( - f" {CYAN}│{RESET} Domain (e.g. vault.example.com): " + f" {CYAN}│{RESET} Domain or IP (e.g. vault.example.com or 192.168.1.100): " ).strip() config["vault_admin_token"] = input( diff --git a/phantom/phantom.py b/phantom/phantom.py index 60793ce..a2712b2 100755 --- a/phantom/phantom.py +++ b/phantom/phantom.py @@ -662,7 +662,7 @@ def gather_credentials(provider, config, service_type=None): config["ssh_user"] = input(f" {CYAN}SSH user [{WHITE}root{RESET}]: ").strip() or "root" existing_key = input(f" {CYAN}SSH key path (blank to generate):{RESET} ").strip() if existing_key: - config["ssh_key"] = existing_key + config["ssh_key"] = str(Path(existing_key).expanduser().resolve()) if config["ssh_user"] != "root": import getpass sudo_pass = getpass.getpass(f" {CYAN}Sudo password (blank if NOPASSWD):{RESET} ") diff --git a/phantom/playbooks/all_in_one/main.yml b/phantom/playbooks/all_in_one/main.yml index dba170d..6b6dfaa 100644 --- a/phantom/playbooks/all_in_one/main.yml +++ b/phantom/playbooks/all_in_one/main.yml @@ -25,6 +25,7 @@ - certbot - python3-certbot-nginx state: present + when: ansible_os_family == "Debian" - name: Remove default nginx site file: @@ -133,7 +134,7 @@ - name: restart php-fpm service: - name: php8.2-fpm + name: "php{{ php_ver | default('8.2') }}-fpm" state: restarted - name: restart mariadb diff --git a/phantom/playbooks/cloud/main.yml b/phantom/playbooks/cloud/main.yml index a4db806..9124952 100644 --- a/phantom/playbooks/cloud/main.yml +++ b/phantom/playbooks/cloud/main.yml @@ -28,7 +28,7 @@ - name: restart php-fpm service: - name: php8.2-fpm + name: "php{{ php_ver }}-fpm" state: restarted - name: restart mariadb diff --git a/phantom/playbooks/cloud/tasks/configure.yml b/phantom/playbooks/cloud/tasks/configure.yml index 338e90c..ccb9a5a 100644 --- a/phantom/playbooks/cloud/tasks/configure.yml +++ b/phantom/playbooks/cloud/tasks/configure.yml @@ -64,12 +64,12 @@ - name: Configure PHP-FPM pool for Nextcloud copy: - dest: /etc/php/8.2/fpm/pool.d/nextcloud.conf + dest: "/etc/php/{{ php_ver }}/fpm/pool.d/nextcloud.conf" content: | [nextcloud] user = www-data group = www-data - listen = /run/php/php8.2-fpm-nextcloud.sock + listen = /run/php/php{{ php_ver }}-fpm-nextcloud.sock listen.owner = www-data listen.group = www-data pm = dynamic diff --git a/phantom/playbooks/cloud/tasks/install.yml b/phantom/playbooks/cloud/tasks/install.yml index 849f19a..23c2d8b 100644 --- a/phantom/playbooks/cloud/tasks/install.yml +++ b/phantom/playbooks/cloud/tasks/install.yml @@ -4,19 +4,19 @@ - name: Install Nextcloud dependencies apt: name: - - php8.2-fpm - - php8.2-xml - - php8.2-mbstring - - php8.2-gd - - php8.2-curl - - php8.2-zip - - php8.2-intl - - php8.2-mysql - - php8.2-redis - - php8.2-apcu - - php8.2-imagick - - php8.2-bcmath - - php8.2-gmp + - php-fpm + - php-xml + - php-mbstring + - php-gd + - php-curl + - php-zip + - php-intl + - php-mysql + - php-redis + - php-apcu + - php-imagick + - php-bcmath + - php-gmp - mariadb-server - redis-server - nginx @@ -27,6 +27,16 @@ - ca-certificates state: present update_cache: true + when: ansible_os_family == "Debian" + +- name: Detect installed PHP version + command: php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' + register: _php_ver_result + changed_when: false + +- name: Set PHP version fact + set_fact: + php_ver: "{{ _php_ver_result.stdout }}" - name: Enable and start MariaDB service: @@ -42,7 +52,7 @@ - name: Enable and start PHP-FPM service: - name: php8.2-fpm + name: "php{{ php_ver }}-fpm" state: started enabled: true diff --git a/phantom/playbooks/cloud/tasks/nginx.yml b/phantom/playbooks/cloud/tasks/nginx.yml index 555eaaa..24ffe5b 100644 --- a/phantom/playbooks/cloud/tasks/nginx.yml +++ b/phantom/playbooks/cloud/tasks/nginx.yml @@ -42,7 +42,7 @@ dest: /etc/nginx/sites-available/nextcloud content: | upstream php-handler { - server unix:/run/php/php8.2-fpm-nextcloud.sock; + server unix:/run/php/php{{ php_ver }}-fpm-nextcloud.sock; } server { @@ -63,7 +63,11 @@ ssl_prefer_server_ciphers on; server_tokens off; + {% if _is_selfsigned | default(false) | bool %} + add_header Strict-Transport-Security "max-age=0" always; + {% else %} add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + {% endif %} add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; add_header X-Robots-Tag "noindex, nofollow" always; diff --git a/phantom/playbooks/common/templates/setup-tunnel.sh.j2 b/phantom/playbooks/common/templates/setup-tunnel.sh.j2 new file mode 100644 index 0000000..71ec2e5 --- /dev/null +++ b/phantom/playbooks/common/templates/setup-tunnel.sh.j2 @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# Phantom — Cloudflare Tunnel Setup +# Deployed by phantom to {{ ansible_host | default('this server') }} +# Run this script to expose {{ _tls_domain }} via Cloudflare Tunnel. +# Requires: a Cloudflare account managing the domain's DNS zone. +# +# Usage: +# ./setup-tunnel.sh # Interactive — prompts for API token +# ./setup-tunnel.sh # Non-interactive — token as argument +set -euo pipefail + +DOMAIN="{{ _tls_domain }}" +TUNNEL_NAME="phantom-${DOMAIN%%.*}" + +echo "============================================" +echo " Phantom — Cloudflare Tunnel Setup" +echo "============================================" +echo " Domain: ${DOMAIN}" +echo " Tunnel Name: ${TUNNEL_NAME}" +echo "" + +# ── Get Cloudflare API token ────────────────────────────────────────── +CF_TOKEN="${1:-}" +if [ -z "${CF_TOKEN}" ]; then + echo "[*] Cloudflare API token required." + echo " Create one at: https://dash.cloudflare.com/profile/api-tokens" + echo " Permissions needed: Zone:DNS:Edit + Account:Cloudflare Tunnel:Edit" + echo "" + read -rp " Cloudflare API token: " CF_TOKEN + echo "" +fi + +if [ -z "${CF_TOKEN}" ]; then + echo "[-] No token provided." + exit 1 +fi + +# ── Install cloudflared ─────────────────────────────────────────────── +if command -v cloudflared &>/dev/null; then + echo "[+] cloudflared already installed: $(cloudflared --version 2>&1 | head -1)" +else + echo "[*] Installing cloudflared..." + if [ -f /usr/share/keyrings/cloudflare-main.gpg ]; then + echo " Cloudflare apt repo already configured" + else + curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ + | tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null + echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \ + | tee /etc/apt/sources.list.d/cloudflared.list >/dev/null + apt-get update -qq + fi + apt-get install -y -qq cloudflared + echo "[+] cloudflared installed" +fi + +# ── Resolve Cloudflare Account ID ───────────────────────────────────── +echo "[*] Verifying API token..." +ACCOUNT_INFO=$(curl -sf -X GET "https://api.cloudflare.com/client/v4/accounts?page=1&per_page=1" \ + -H "Authorization: Bearer ${CF_TOKEN}" \ + -H "Content-Type: application/json" 2>/dev/null || true) + +ACCOUNT_ID=$(echo "${ACCOUNT_INFO}" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + if data.get('success') and data.get('result'): + print(data['result'][0]['id']) +except: pass +" 2>/dev/null || true) + +if [ -z "${ACCOUNT_ID}" ]; then + echo "[-] Failed to verify API token. Check permissions:" + echo " - Account:Cloudflare Tunnel:Edit" + echo " - Zone:DNS:Edit" + exit 1 +fi +echo "[+] Account verified: ${ACCOUNT_ID}" + +# ── Create or find existing tunnel via API ──────────────────────────── +echo "[*] Checking for existing tunnel '${TUNNEL_NAME}'..." +TUNNEL_LIST=$(curl -sf -X GET \ + "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel?name=${TUNNEL_NAME}&is_deleted=false" \ + -H "Authorization: Bearer ${CF_TOKEN}" \ + -H "Content-Type: application/json" 2>/dev/null || true) + +TUNNEL_ID=$(echo "${TUNNEL_LIST}" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + if data.get('success') and data.get('result'): + print(data['result'][0]['id']) +except: pass +" 2>/dev/null || true) + +if [ -n "${TUNNEL_ID}" ]; then + echo "[+] Tunnel '${TUNNEL_NAME}' already exists: ${TUNNEL_ID}" +else + echo "[*] Creating tunnel '${TUNNEL_NAME}'..." + TUNNEL_SECRET=$(python3 -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())") + + CREATE_RESP=$(curl -sf -X POST \ + "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel" \ + -H "Authorization: Bearer ${CF_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"name\": \"${TUNNEL_NAME}\", \"tunnel_secret\": \"${TUNNEL_SECRET}\", \"config_src\": \"local\"}" \ + 2>/dev/null || true) + + TUNNEL_ID=$(echo "${CREATE_RESP}" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + if data.get('success') and data.get('result'): + print(data['result']['id']) + else: + errors = data.get('errors', []) + if errors: + print('ERROR:' + errors[0].get('message', 'unknown'), file=sys.stderr) +except: pass +" 2>/dev/null || true) + + if [ -z "${TUNNEL_ID}" ] || [[ "${TUNNEL_ID}" == ERROR:* ]]; then + echo "[-] Failed to create tunnel." + echo "${CREATE_RESP}" | python3 -m json.tool 2>/dev/null || echo "${CREATE_RESP}" + exit 1 + fi + echo "[+] Tunnel created: ${TUNNEL_ID}" +fi + +# ── Write credentials + config ──────────────────────────────────────── +echo "[*] Writing tunnel configuration..." +mkdir -p /etc/cloudflared + +# Write credentials file — required by cloudflared service install +python3 -c " +import json +creds = {'AccountTag': '${ACCOUNT_ID}', 'TunnelID': '${TUNNEL_ID}', 'TunnelSecret': '${TUNNEL_SECRET:-}'} +with open('/etc/cloudflared/${TUNNEL_ID}.json', 'w') as f: + json.dump(creds, f) +" +chmod 600 "/etc/cloudflared/${TUNNEL_ID}.json" + +cat > /etc/cloudflared/config.yml << CFEOF +tunnel: ${TUNNEL_ID} +credentials-file: /etc/cloudflared/${TUNNEL_ID}.json + +ingress: + - hostname: ${DOMAIN} + service: https://localhost:443 + originRequest: + noTLSVerify: true + - service: http_status:404 +CFEOF +chmod 600 /etc/cloudflared/config.yml +echo "[+] Config written" + +# ── Route DNS ───────────────────────────────────────────────────────── +echo "[*] Setting up DNS route for ${DOMAIN}..." + +# Get zone ID for the domain +BASE_DOMAIN=$(echo "${DOMAIN}" | rev | cut -d. -f1-2 | rev) +ZONE_INFO=$(curl -sf -X GET \ + "https://api.cloudflare.com/client/v4/zones?name=${BASE_DOMAIN}" \ + -H "Authorization: Bearer ${CF_TOKEN}" \ + -H "Content-Type: application/json" 2>/dev/null || true) + +ZONE_ID=$(echo "${ZONE_INFO}" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + if data.get('success') and data.get('result'): + print(data['result'][0]['id']) +except: pass +" 2>/dev/null || true) + +if [ -z "${ZONE_ID}" ]; then + echo "[-] Could not find zone for ${BASE_DOMAIN}" + echo " Add the CNAME manually: ${DOMAIN} → ${TUNNEL_ID}.cfargotunnel.com" +else + # Check for existing record + EXISTING_RECORD=$(curl -sf -X GET \ + "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${DOMAIN}" \ + -H "Authorization: Bearer ${CF_TOKEN}" \ + -H "Content-Type: application/json" 2>/dev/null || true) + + RECORD_ID=$(echo "${EXISTING_RECORD}" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + if data.get('success') and data.get('result'): + print(data['result'][0]['id']) +except: pass +" 2>/dev/null || true) + + TUNNEL_CNAME="${TUNNEL_ID}.cfargotunnel.com" + + if [ -n "${RECORD_ID}" ]; then + # Update existing record to point to tunnel + echo " Updating existing DNS record..." + curl -sf -X PUT \ + "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \ + -H "Authorization: Bearer ${CF_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"CNAME\",\"name\":\"${DOMAIN}\",\"content\":\"${TUNNEL_CNAME}\",\"proxied\":true}" \ + >/dev/null 2>&1 + echo "[+] DNS record updated: ${DOMAIN} → tunnel (proxied)" + else + # Create new CNAME + curl -sf -X POST \ + "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \ + -H "Authorization: Bearer ${CF_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"CNAME\",\"name\":\"${DOMAIN}\",\"content\":\"${TUNNEL_CNAME}\",\"proxied\":true}" \ + >/dev/null 2>&1 + echo "[+] DNS CNAME created: ${DOMAIN} → tunnel (proxied)" + fi +fi + +# ── Install systemd service ────────────────────────────────────────── +echo "[*] Installing cloudflared systemd service..." +# Stop existing service if running +systemctl stop cloudflared 2>/dev/null || true + +# Try cloudflared's built-in installer first +if ! cloudflared service install 2>/dev/null; then + # Fallback: write unit file manually + if [ ! -f /etc/systemd/system/cloudflared.service ]; then + cat > /etc/systemd/system/cloudflared.service << SVCEOF +[Unit] +Description=cloudflared tunnel +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +TimeoutStartSec=0 +ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +SVCEOF + fi +fi + +systemctl daemon-reload +systemctl enable cloudflared +systemctl start cloudflared +echo "[+] cloudflared service started" + +# ── Verify ──────────────────────────────────────────────────────────── +echo "" +echo "[*] Waiting for tunnel to connect..." +sleep 5 + +if systemctl is-active --quiet cloudflared; then + echo "[+] cloudflared service is running" +else + echo "[-] cloudflared service failed to start" + echo " Check: journalctl -u cloudflared --no-pager -n 20" + exit 1 +fi + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://${DOMAIN}/" --max-time 10 2>/dev/null || echo "000") +if [ "${HTTP_CODE}" != "000" ] && [ "${HTTP_CODE}" != "502" ] && [ "${HTTP_CODE}" != "503" ]; then + echo "[+] Tunnel verified — https://${DOMAIN}/ returned HTTP ${HTTP_CODE}" +else + echo "[!] Tunnel may need a moment to propagate." + echo " DNS changes can take 1-5 minutes." + echo " Test: curl -I https://${DOMAIN}/" +fi + +echo "" +echo "============================================" +echo " Cloudflare Tunnel setup complete!" +echo "" +echo " Domain: https://${DOMAIN}" +echo " Tunnel: ${TUNNEL_NAME} (${TUNNEL_ID})" +echo " Service: systemctl status cloudflared" +echo "" +echo " To remove: cloudflared tunnel delete ${TUNNEL_NAME}" +echo "============================================" diff --git a/phantom/playbooks/common/tls_setup.yml b/phantom/playbooks/common/tls_setup.yml index 1ce02d2..67b32ea 100644 --- a/phantom/playbooks/common/tls_setup.yml +++ b/phantom/playbooks/common/tls_setup.yml @@ -1,7 +1,16 @@ --- -# Shared TLS setup: tries certbot, falls back to self-signed +# Shared TLS setup: tries certbot for public domains, self-signed for LAN # Include with: include_tasks: ../common/tls_setup.yml # Required vars: _tls_domain (the domain to get a cert for) +# Outputs: _ssl_cert, _ssl_key, _is_selfsigned + +- name: Detect if domain is public (not IP, not .local/.lan/.home/.internal/.test) + set_fact: + _is_public_domain: >- + {{ _tls_domain is not regex('^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$') + and _tls_domain is not regex('\.(local|lan|home|internal|test)$') + and _tls_domain is not regex('^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)') + and _tls_domain is not regex('^(localhost|127\.)') }} - name: Attempt TLS certificate from Let's Encrypt command: > @@ -18,33 +27,66 @@ creates: "/etc/letsencrypt/live/{{ _tls_domain }}/fullchain.pem" register: _certbot_result ignore_errors: true + when: _is_public_domain | bool -- name: Generate self-signed certificate (fallback) +- name: Init certbot result as failed for LAN deployments + set_fact: + _certbot_result: { "failed": true, "skipped": true } + when: not (_is_public_domain | bool) + +- name: Generate self-signed certificate with SAN (IP) command: > openssl req -x509 -nodes -days 365 -newkey rsa:4096 -keyout /etc/ssl/private/{{ _tls_domain }}-selfsigned.key -out /etc/ssl/certs/{{ _tls_domain }}-selfsigned.crt -subj "/CN={{ _tls_domain }}" + -addext "subjectAltName=IP:{{ _tls_domain }}" args: creates: "/etc/ssl/certs/{{ _tls_domain }}-selfsigned.crt" - when: _certbot_result is failed + when: + - _certbot_result is failed + - _tls_domain is regex('^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$') + +- name: Generate self-signed certificate with SAN (hostname) + command: > + openssl req -x509 -nodes -days 365 -newkey rsa:4096 + -keyout /etc/ssl/private/{{ _tls_domain }}-selfsigned.key + -out /etc/ssl/certs/{{ _tls_domain }}-selfsigned.crt + -subj "/CN={{ _tls_domain }}" + -addext "subjectAltName=DNS:{{ _tls_domain }}" + args: + creates: "/etc/ssl/certs/{{ _tls_domain }}-selfsigned.crt" + when: + - _certbot_result is failed + - _tls_domain is not regex('^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$') - name: Set cert paths (certbot) set_fact: _ssl_cert: "/etc/letsencrypt/live/{{ _tls_domain }}/fullchain.pem" _ssl_key: "/etc/letsencrypt/live/{{ _tls_domain }}/privkey.pem" + _is_selfsigned: false when: _certbot_result is not failed - name: Set cert paths (self-signed) set_fact: _ssl_cert: "/etc/ssl/certs/{{ _tls_domain }}-selfsigned.crt" _ssl_key: "/etc/ssl/private/{{ _tls_domain }}-selfsigned.key" + _is_selfsigned: true when: _certbot_result is failed -- name: Warn about self-signed certificate +- name: LAN deployment — using self-signed certificate debug: - msg: "Using self-signed TLS cert. Point DNS to this server and run: certbot certonly --webroot -w /var/www/certbot -d {{ _tls_domain }}" - when: _certbot_result is failed + msg: "LAN deployment detected — using self-signed TLS cert for {{ _tls_domain }}" + when: + - _certbot_result is failed + - not (_is_public_domain | bool) + +- name: Certbot failed for public domain — falling back to self-signed + debug: + msg: "Certbot failed for {{ _tls_domain }} — using self-signed cert. Point DNS to this server and run: certbot certonly --webroot -w /var/www/certbot -d {{ _tls_domain }}" + when: + - _certbot_result is failed + - _is_public_domain | bool - name: Create Tools directory file: @@ -57,3 +99,9 @@ src: ../common/templates/setup-cert.sh.j2 dest: /root/Tools/setup-cert.sh mode: "0700" + +- name: Deploy Cloudflare Tunnel setup script + template: + src: ../common/templates/setup-tunnel.sh.j2 + dest: /root/Tools/setup-tunnel.sh + mode: "0700" diff --git a/phantom/playbooks/dns/tasks/install.yml b/phantom/playbooks/dns/tasks/install.yml index 02c20df..57f33f2 100644 --- a/phantom/playbooks/dns/tasks/install.yml +++ b/phantom/playbooks/dns/tasks/install.yml @@ -12,6 +12,7 @@ - cron state: present update_cache: true + when: ansible_os_family == "Debian" - name: Stop systemd-resolved (conflicts with Pi-hole on port 53) service: diff --git a/phantom/playbooks/matrix/tasks/element.yml b/phantom/playbooks/matrix/tasks/element.yml index 112bdd5..1e5510d 100644 --- a/phantom/playbooks/matrix/tasks/element.yml +++ b/phantom/playbooks/matrix/tasks/element.yml @@ -5,6 +5,7 @@ apt: name: nginx state: present + when: ansible_os_family == "Debian" - name: Create Element Web directory file: diff --git a/phantom/playbooks/matrix/tasks/nginx.yml b/phantom/playbooks/matrix/tasks/nginx.yml index 6caaee7..0ed069c 100644 --- a/phantom/playbooks/matrix/tasks/nginx.yml +++ b/phantom/playbooks/matrix/tasks/nginx.yml @@ -82,7 +82,11 @@ ssl_session_tickets off; # Security headers + {% if _is_selfsigned | default(false) | bool %} + add_header Strict-Transport-Security "max-age=0" always; + {% else %} add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + {% endif %} add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; diff --git a/phantom/playbooks/matrix/tasks/synapse.yml b/phantom/playbooks/matrix/tasks/synapse.yml index adc8ffa..b2de83f 100644 --- a/phantom/playbooks/matrix/tasks/synapse.yml +++ b/phantom/playbooks/matrix/tasks/synapse.yml @@ -16,6 +16,7 @@ - gnupg - lsb-release state: present + when: ansible_os_family == "Debian" - name: Add Matrix Synapse GPG key and repository shell: | diff --git a/phantom/playbooks/media/tasks/install.yml b/phantom/playbooks/media/tasks/install.yml index cf30985..66debcb 100644 --- a/phantom/playbooks/media/tasks/install.yml +++ b/phantom/playbooks/media/tasks/install.yml @@ -12,6 +12,7 @@ - python3-certbot-nginx state: present update_cache: true + when: ansible_os_family == "Debian" - name: Add Jellyfin GPG signing key ansible.builtin.get_url: @@ -30,6 +31,7 @@ name: jellyfin state: present update_cache: true + when: ansible_os_family == "Debian" - name: Allow HTTP/HTTPS through UFW ufw: diff --git a/phantom/playbooks/media/tasks/nginx.yml b/phantom/playbooks/media/tasks/nginx.yml index eca727a..c5104e6 100644 --- a/phantom/playbooks/media/tasks/nginx.yml +++ b/phantom/playbooks/media/tasks/nginx.yml @@ -59,7 +59,11 @@ ssl_prefer_server_ciphers on; server_tokens off; + {% if _is_selfsigned | default(false) | bool %} + add_header Strict-Transport-Security "max-age=0" always; + {% else %} add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + {% endif %} add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; diff --git a/phantom/playbooks/vault/tasks/install.yml b/phantom/playbooks/vault/tasks/install.yml index 73360a3..863a87f 100644 --- a/phantom/playbooks/vault/tasks/install.yml +++ b/phantom/playbooks/vault/tasks/install.yml @@ -1,6 +1,11 @@ --- # Vaultwarden binary installation from GitHub releases +- name: Check for unsupported 32-bit ARM architecture + fail: + msg: "Vaultwarden does not support 32-bit ARM (armv7l). Use a 64-bit OS (aarch64) or x86_64 system." + when: ansible_architecture == "armv7l" + - name: Install prerequisites apt: name: @@ -13,6 +18,7 @@ - ca-certificates state: present update_cache: true + when: ansible_os_family == "Debian" - name: Create vaultwarden system user user: diff --git a/phantom/playbooks/vault/tasks/nginx.yml b/phantom/playbooks/vault/tasks/nginx.yml index 29aa9ee..da50d2a 100644 --- a/phantom/playbooks/vault/tasks/nginx.yml +++ b/phantom/playbooks/vault/tasks/nginx.yml @@ -59,7 +59,11 @@ ssl_prefer_server_ciphers on; server_tokens off; + {% if _is_selfsigned | default(false) | bool %} + add_header Strict-Transport-Security "max-age=0" always; + {% else %} add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + {% endif %} add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always;