LAN/local deployment support, OS compat, Cloudflare Tunnel

- 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 <noreply@anthropic.com>
This commit is contained in:
n0mad1k
2026-03-10 13:27:12 -04:00
parent 040fa12705
commit 3542913689
22 changed files with 466 additions and 52 deletions
+42 -20
View File
@@ -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
+1 -1
View File
@@ -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(
+9
View File
@@ -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()
+18 -3
View File
@@ -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)
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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} ")
+2 -1
View File
@@ -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
+1 -1
View File
@@ -28,7 +28,7 @@
- name: restart php-fpm
service:
name: php8.2-fpm
name: "php{{ php_ver }}-fpm"
state: restarted
- name: restart mariadb
+2 -2
View File
@@ -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
+24 -14
View File
@@ -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
+5 -1
View File
@@ -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;
@@ -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 <CF_API_TOKEN> # 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 "============================================"
+54 -6
View File
@@ -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"
+1
View File
@@ -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:
@@ -5,6 +5,7 @@
apt:
name: nginx
state: present
when: ansible_os_family == "Debian"
- name: Create Element Web directory
file:
+4
View File
@@ -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;
@@ -16,6 +16,7 @@
- gnupg
- lsb-release
state: present
when: ansible_os_family == "Debian"
- name: Add Matrix Synapse GPG key and repository
shell: |
@@ -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:
+4
View File
@@ -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;
@@ -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:
+4
View File
@@ -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;