Phantom v2: dual-mode architecture, security hardening, deployment management
- Dual-mode operation: standalone + c2itall integrated (env var detection)
- SSH keys moved to ~/.ssh/c2deploy_ph-{id} with per-deployment known_hosts
- Ansible output streaming with filtered console + full log capture
- Deployment management menu: discover, SSH, teardown existing deployments
- Cert setup script (setup-cert.sh) deployed to servers for post-DNS LE certs
- Matrix hardening: unique secrets, SSRF protection, rate limits, nginx security headers
- Base hardening: fail2ban systemd backend (Debian 12), SSH limits, nginx jails
- Add-matrix-user helper script deployed to all Matrix servers
- .env support for standalone credential storage
- Config key rename: deploy_id → deployment_id (with backward compat)
- Provider cleanup playbooks for teardown
- Test suite with 50 tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ghost_protocol contributors
|
||||
Copyright (c) 2026 n0mad1k
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ The **Covert SD Card Tool** is a Python script designed to automate the process
|
||||
1. **Clone the Repository or Download the Script:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/ghost_protocol.git
|
||||
git clone https://github.com/n0mad1k/ghost_protocol.git
|
||||
cd ghost_protocol/covert_sd
|
||||
```
|
||||
|
||||
|
||||
+11
-10
@@ -8,12 +8,13 @@ PROFILE_NAME="default"
|
||||
|
||||
# ─── TOR SETTINGS ──────────────────────────────────────────────────────────────
|
||||
TOR_CIRCUIT_ROTATION="30"
|
||||
TOR_BLACKLIST=""
|
||||
TOR_STRICT_NODES="1"
|
||||
TOR_BLACKLIST="us,gb,ca,au,nz"
|
||||
TOR_STRICT_NODES="0"
|
||||
TOR_ISOLATION="1"
|
||||
TOR_PADDING="1"
|
||||
TOR_SOCKS_PORT="9050"
|
||||
TOR_DNS_PORT="5353"
|
||||
TOR_TRANS_PORT="9040"
|
||||
TOR_NUM_GUARDS="3"
|
||||
TOR_SAFE_LOGGING="1"
|
||||
|
||||
@@ -36,14 +37,14 @@ MAC_VENDOR_SPOOF=""
|
||||
|
||||
# ─── HOSTNAME ──────────────────────────────────────────────────────────────────
|
||||
# Pattern: desktop | random | custom
|
||||
HOSTNAME_PATTERN="desktop"
|
||||
HOSTNAME_CUSTOM_PREFIX=""
|
||||
HOSTNAME_PATTERN="random"
|
||||
HOSTNAME_CUSTOM_PREFIX="workstation"
|
||||
|
||||
# ─── SYSTEM HARDENING ──────────────────────────────────────────────────────────
|
||||
HARDEN_IPV6="1"
|
||||
HARDEN_SWAP="1"
|
||||
HARDEN_CORE_DUMPS="1"
|
||||
HARDEN_CLIPBOARD_CLEAR="0"
|
||||
HARDEN_CLIPBOARD_CLEAR="1"
|
||||
HARDEN_SCREEN_LOCK="1"
|
||||
HARDEN_SCREEN_LOCK_TIMEOUT="300"
|
||||
HARDEN_TIMEZONE_SPOOF="0"
|
||||
@@ -53,10 +54,10 @@ HARDEN_LOCALE_VALUE="en_US.UTF-8"
|
||||
|
||||
# ─── LEAK PREVENTION ──────────────────────────────────────────────────────────
|
||||
LEAK_WEBRTC_BLOCK="1"
|
||||
LEAK_USB_BLOCK="0"
|
||||
LEAK_USB_BLOCK="1"
|
||||
|
||||
# ─── MONITORING ────────────────────────────────────────────────────────────────
|
||||
MONITOR_PROCESSES="0"
|
||||
MONITOR_PROCESSES="1"
|
||||
MONITOR_LOG_ROTATION="1"
|
||||
LOG_ROTATION_HOURS="4"
|
||||
|
||||
@@ -85,12 +86,12 @@ WIPE_METHOD="auto"
|
||||
|
||||
# ─── DEPLOYMENT LEVEL ─────────────────────────────────────────────────────────
|
||||
# Level: bare-metal-standard | bare-metal-paranoid | cloud-normal | cloud-paranoid
|
||||
DEPLOYMENT_LEVEL="bare-metal-standard"
|
||||
DEPLOYMENT_LEVEL="bare-metal"
|
||||
|
||||
# ─── TERMINAL BANNER ──────────────────────────────────────────────────────────
|
||||
# Mode: compact | full | auto | off
|
||||
OPSEC_BANNER="compact"
|
||||
OPSEC_BANNER="full"
|
||||
|
||||
# ─── WIDGET THEME ────────────────────────────────────────────────────────────
|
||||
# Theme: default | aurora | ember | slate | cyberpunk | frost | terminal
|
||||
WIDGET_THEME="default"
|
||||
WIDGET_THEME="apt"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Phantom — Provider Credentials
|
||||
# Copy to .env and fill in your values.
|
||||
# These are loaded automatically in standalone mode.
|
||||
# Env vars take priority over values here.
|
||||
|
||||
# Linode
|
||||
LINODE_TOKEN=
|
||||
LINODE_REGION=us-east
|
||||
LINODE_PLAN=g6-nanode-1
|
||||
|
||||
# AWS
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_REGION=us-east-1
|
||||
AWS_INSTANCE_TYPE=t3.micro
|
||||
+9
-5
@@ -8,12 +8,12 @@ Deploy self-hosted privacy infrastructure with a single command. Supports cloud
|
||||
|---|---|---|
|
||||
| Matrix + Element | Ready | Encrypted messaging homeserver with web client |
|
||||
| WireGuard VPN | Ready | Private VPN server with client config generation |
|
||||
| Pi-hole DNS | Ready | Ad-blocking DNS server (Docker-based) |
|
||||
| Pi-hole DNS | Ready | Ad-blocking DNS server (native) |
|
||||
| Nextcloud | Ready | Self-hosted file sync and collaboration |
|
||||
| Vaultwarden | Ready | Self-hosted Bitwarden password manager |
|
||||
| Jellyfin | Ready | Self-hosted media server |
|
||||
| Mail-in-a-Box | Ready | Self-hosted email |
|
||||
| All-in-One | Ready | Multiple services on one server with nginx reverse proxy |
|
||||
| Nextcloud | Stub | Self-hosted file sync and collaboration |
|
||||
| Vaultwarden | Stub | Self-hosted Bitwarden password manager |
|
||||
| Jellyfin | Stub | Self-hosted media server |
|
||||
| Mail-in-a-Box | Stub | Self-hosted email |
|
||||
|
||||
## Deployment Targets
|
||||
|
||||
@@ -87,6 +87,10 @@ phantom/
|
||||
│ ├── matrix/ # Synapse + Element
|
||||
│ ├── vpn/ # WireGuard
|
||||
│ ├── dns/ # Pi-hole
|
||||
│ ├── cloud/ # Nextcloud
|
||||
│ ├── vault/ # Vaultwarden
|
||||
│ ├── media/ # Jellyfin
|
||||
│ ├── email/ # Mail-in-a-Box
|
||||
│ └── all_in_one/ # Multi-service composer
|
||||
├── providers/ # Cloud provisioning
|
||||
└── logs/ # Deployment artifacts
|
||||
|
||||
@@ -15,9 +15,9 @@ SERVICES = [
|
||||
("matrix", "Matrix + Element", "Encrypted messaging"),
|
||||
("vpn", "WireGuard VPN", "Private VPN server"),
|
||||
("dns", "Pi-hole DNS", "Ad-blocking DNS"),
|
||||
("cloud", "Nextcloud", "File sync (stub)"),
|
||||
("vault", "Vaultwarden", "Password manager (stub)"),
|
||||
("media", "Jellyfin", "Media server (stub)"),
|
||||
("cloud", "Nextcloud", "File sync"),
|
||||
("vault", "Vaultwarden", "Password manager"),
|
||||
("media", "Jellyfin", "Media server"),
|
||||
]
|
||||
|
||||
|
||||
@@ -66,18 +66,6 @@ def gather_config(config):
|
||||
print(f" {RED}No services selected.{RESET}")
|
||||
return None
|
||||
|
||||
# Warn about stub services
|
||||
STUB_SERVICES = {"cloud", "vault", "media", "email"}
|
||||
stubs_selected = [s for s in selected_services if s in STUB_SERVICES]
|
||||
if stubs_selected:
|
||||
stub_labels = {s[0]: s[1] for s in SERVICES}
|
||||
print(f"\n {YELLOW}Warning: The following services are stubs (playbook not yet implemented):{RESET}")
|
||||
for s in stubs_selected:
|
||||
print(f" {YELLOW}- {stub_labels.get(s, s)}{RESET}")
|
||||
proceed = input(f" {YELLOW}Continue anyway? [y/N]:{RESET} ").strip().lower()
|
||||
if proceed != "y":
|
||||
return None
|
||||
|
||||
config["services"] = selected_services
|
||||
config["all_in_one"] = True
|
||||
|
||||
@@ -116,7 +104,7 @@ def gather_config(config):
|
||||
if config is None:
|
||||
return None
|
||||
except ImportError:
|
||||
pass # Stub module, skip
|
||||
pass # Module not yet loaded, skip
|
||||
# Restore base domain
|
||||
config["domain"] = base_domain
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Nextcloud deployment module (stub — playbook TODO)."""
|
||||
"""Nextcloud deployment module — PHP-FPM + MariaDB + Redis + nginx."""
|
||||
|
||||
CYAN = "\033[38;5;51m"
|
||||
WHITE = "\033[38;5;255m"
|
||||
@@ -9,7 +9,7 @@ RESET = "\033[0m"
|
||||
def gather_config(config):
|
||||
"""Gather Nextcloud configuration."""
|
||||
print(f"\n{CYAN} ┌─ Nextcloud Configuration ─────────────────────────┐{RESET}")
|
||||
print(f" {CYAN}│{RESET} {GREY}Note: Playbook coming soon{RESET}")
|
||||
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): "
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Mail-in-a-Box deployment module (stub — playbook TODO)."""
|
||||
"""Mail-in-a-Box deployment module — native installer script."""
|
||||
|
||||
CYAN = "\033[38;5;51m"
|
||||
WHITE = "\033[38;5;255m"
|
||||
@@ -9,7 +9,7 @@ RESET = "\033[0m"
|
||||
def gather_config(config):
|
||||
"""Gather Mail-in-a-Box configuration."""
|
||||
print(f"\n{CYAN} ┌─ Mail-in-a-Box Configuration ─────────────────────┐{RESET}")
|
||||
print(f" {CYAN}│{RESET} {GREY}Note: Playbook coming soon{RESET}")
|
||||
print(f" {CYAN}│{RESET} {GREY}Complete mail stack — manages its own nginx/TLS/DNS{RESET}")
|
||||
|
||||
config["domain"] = config.get("domain") or input(
|
||||
f" {CYAN}│{RESET} Mail domain (e.g. mail.example.com): "
|
||||
|
||||
@@ -42,6 +42,8 @@ def gather_config(config):
|
||||
if config["domain"].startswith("matrix.") else config["domain"]
|
||||
|
||||
config["matrix_signing_key"] = secrets.token_hex(32)
|
||||
config["matrix_form_secret"] = secrets.token_hex(32)
|
||||
config["matrix_macaroon_secret"] = secrets.token_hex(32)
|
||||
|
||||
print(f" {CYAN}└─────────────────────────────────────────────────────┘{RESET}")
|
||||
return config
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Jellyfin media server deployment module (stub — playbook TODO)."""
|
||||
"""Jellyfin media server deployment module — official apt repo + nginx."""
|
||||
|
||||
CYAN = "\033[38;5;51m"
|
||||
WHITE = "\033[38;5;255m"
|
||||
@@ -9,7 +9,7 @@ RESET = "\033[0m"
|
||||
def gather_config(config):
|
||||
"""Gather Jellyfin configuration."""
|
||||
print(f"\n{CYAN} ┌─ Jellyfin Configuration ──────────────────────────┐{RESET}")
|
||||
print(f" {CYAN}│{RESET} {GREY}Note: Playbook coming soon{RESET}")
|
||||
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): "
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Vaultwarden (Bitwarden) deployment module (stub — playbook TODO)."""
|
||||
"""Vaultwarden (Bitwarden-compatible) deployment module — binary + systemd + nginx."""
|
||||
|
||||
import secrets
|
||||
|
||||
@@ -11,7 +11,7 @@ RESET = "\033[0m"
|
||||
def gather_config(config):
|
||||
"""Gather Vaultwarden configuration."""
|
||||
print(f"\n{CYAN} ┌─ Vaultwarden Configuration ───────────────────────┐{RESET}")
|
||||
print(f" {CYAN}│{RESET} {GREY}Note: Playbook coming soon{RESET}")
|
||||
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): "
|
||||
|
||||
+721
-118
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
#
|
||||
# NOTE: This playbook includes task files directly rather than
|
||||
# importing full playbooks, to allow conditional composition.
|
||||
# NOTE: Email (MIAB) is excluded — it manages its own nginx/DNS/TLS.
|
||||
|
||||
- name: All-in-One Privacy Server
|
||||
hosts: all
|
||||
@@ -41,6 +42,8 @@
|
||||
- "443"
|
||||
|
||||
# ─── Deploy Individual Services (task files, not full playbooks) ──
|
||||
|
||||
# Matrix
|
||||
- name: Deploy Matrix — Synapse
|
||||
include_tasks: "{{ playbook_dir }}/../matrix/tasks/synapse.yml"
|
||||
when: "'matrix' in selected_services"
|
||||
@@ -53,6 +56,14 @@
|
||||
include_tasks: "{{ playbook_dir }}/../matrix/tasks/nginx.yml"
|
||||
when: "'matrix' in selected_services"
|
||||
|
||||
- name: Deploy Matrix user creation script
|
||||
template:
|
||||
src: "{{ playbook_dir }}/../common/templates/add-matrix-user.sh.j2"
|
||||
dest: /root/Tools/add-matrix-user.sh
|
||||
mode: "0700"
|
||||
when: "'matrix' in selected_services"
|
||||
|
||||
# WireGuard VPN
|
||||
- name: Deploy WireGuard — Install
|
||||
include_tasks: "{{ playbook_dir }}/../vpn/tasks/install.yml"
|
||||
when: "'vpn' in selected_services"
|
||||
@@ -61,6 +72,7 @@
|
||||
include_tasks: "{{ playbook_dir }}/../vpn/tasks/configure.yml"
|
||||
when: "'vpn' in selected_services"
|
||||
|
||||
# Pi-hole DNS
|
||||
- name: Deploy Pi-hole — Install
|
||||
include_tasks: "{{ playbook_dir }}/../dns/tasks/install.yml"
|
||||
when: "'dns' in selected_services"
|
||||
@@ -69,11 +81,44 @@
|
||||
include_tasks: "{{ playbook_dir }}/../dns/tasks/configure.yml"
|
||||
when: "'dns' in selected_services"
|
||||
|
||||
# Stub services — print notice
|
||||
- name: Notice for stub services
|
||||
debug:
|
||||
msg: "Service '{{ item }}' playbook not yet implemented — skipping"
|
||||
loop: "{{ selected_services | select('in', ['cloud', 'vault', 'media', 'email']) | list }}"
|
||||
# Nextcloud
|
||||
- name: Deploy Nextcloud — Install
|
||||
include_tasks: "{{ playbook_dir }}/../cloud/tasks/install.yml"
|
||||
when: "'cloud' in selected_services"
|
||||
|
||||
- name: Deploy Nextcloud — Configure
|
||||
include_tasks: "{{ playbook_dir }}/../cloud/tasks/configure.yml"
|
||||
when: "'cloud' in selected_services"
|
||||
|
||||
- name: Deploy Nextcloud — Nginx vhost
|
||||
include_tasks: "{{ playbook_dir }}/../cloud/tasks/nginx.yml"
|
||||
when: "'cloud' in selected_services"
|
||||
|
||||
# Vaultwarden
|
||||
- name: Deploy Vaultwarden — Install
|
||||
include_tasks: "{{ playbook_dir }}/../vault/tasks/install.yml"
|
||||
when: "'vault' in selected_services"
|
||||
|
||||
- name: Deploy Vaultwarden — Configure
|
||||
include_tasks: "{{ playbook_dir }}/../vault/tasks/configure.yml"
|
||||
when: "'vault' in selected_services"
|
||||
|
||||
- name: Deploy Vaultwarden — Nginx vhost
|
||||
include_tasks: "{{ playbook_dir }}/../vault/tasks/nginx.yml"
|
||||
when: "'vault' in selected_services"
|
||||
|
||||
# Jellyfin
|
||||
- name: Deploy Jellyfin — Install
|
||||
include_tasks: "{{ playbook_dir }}/../media/tasks/install.yml"
|
||||
when: "'media' in selected_services"
|
||||
|
||||
- name: Deploy Jellyfin — Configure
|
||||
include_tasks: "{{ playbook_dir }}/../media/tasks/configure.yml"
|
||||
when: "'media' in selected_services"
|
||||
|
||||
- name: Deploy Jellyfin — Nginx vhost
|
||||
include_tasks: "{{ playbook_dir }}/../media/tasks/nginx.yml"
|
||||
when: "'media' in selected_services"
|
||||
|
||||
handlers:
|
||||
- name: reload nginx
|
||||
@@ -86,6 +131,36 @@
|
||||
name: matrix-synapse
|
||||
state: restarted
|
||||
|
||||
- name: restart php-fpm
|
||||
service:
|
||||
name: php8.2-fpm
|
||||
state: restarted
|
||||
|
||||
- name: restart mariadb
|
||||
service:
|
||||
name: mariadb
|
||||
state: restarted
|
||||
|
||||
- name: restart redis
|
||||
service:
|
||||
name: redis-server
|
||||
state: restarted
|
||||
|
||||
- name: restart vaultwarden
|
||||
service:
|
||||
name: vaultwarden
|
||||
state: restarted
|
||||
|
||||
- name: restart jellyfin
|
||||
service:
|
||||
name: jellyfin
|
||||
state: restarted
|
||||
|
||||
- name: restart pihole-FTL
|
||||
service:
|
||||
name: pihole-FTL
|
||||
state: restarted
|
||||
|
||||
- name: restart sshd
|
||||
service:
|
||||
name: sshd
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
---
|
||||
# Cloud deployment — Coming soon
|
||||
# This is a stub playbook. Full implementation planned.
|
||||
# Nextcloud deployment — PHP-FPM + MariaDB + Redis + nginx
|
||||
|
||||
- name: Deploy Cloud Server
|
||||
- name: Deploy Nextcloud Server
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
nextcloud_domain: "{{ domain }}"
|
||||
nextcloud_admin: "{{ cloud_admin_user | default('admin') }}"
|
||||
nextcloud_storage_gb: "{{ cloud_storage_gb | default('10') }}"
|
||||
target_host: "{{ target_host | default('localhost') }}"
|
||||
|
||||
tasks:
|
||||
- name: Placeholder
|
||||
debug:
|
||||
msg: "Cloud playbook not yet implemented. Check phantom/README.md for status."
|
||||
- name: Include Nextcloud installation
|
||||
include_tasks: tasks/install.yml
|
||||
|
||||
- name: Include Nextcloud configuration
|
||||
include_tasks: tasks/configure.yml
|
||||
|
||||
- name: Include Nextcloud nginx reverse proxy
|
||||
include_tasks: tasks/nginx.yml
|
||||
|
||||
handlers:
|
||||
- name: reload nginx
|
||||
service:
|
||||
name: nginx
|
||||
state: reloaded
|
||||
|
||||
- name: restart php-fpm
|
||||
service:
|
||||
name: php8.2-fpm
|
||||
state: restarted
|
||||
|
||||
- name: restart mariadb
|
||||
service:
|
||||
name: mariadb
|
||||
state: restarted
|
||||
|
||||
- name: restart redis
|
||||
service:
|
||||
name: redis-server
|
||||
state: restarted
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
# Nextcloud database, app, and service configuration
|
||||
|
||||
- name: Generate MariaDB password for Nextcloud
|
||||
command: openssl rand -base64 24
|
||||
register: nc_db_password_gen
|
||||
args:
|
||||
creates: /var/www/nextcloud/.db_password
|
||||
|
||||
- name: Save DB password
|
||||
copy:
|
||||
content: "{{ nc_db_password_gen.stdout }}"
|
||||
dest: /var/www/nextcloud/.db_password
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: "0600"
|
||||
when: nc_db_password_gen.changed
|
||||
|
||||
- name: Read saved DB password
|
||||
slurp:
|
||||
src: /var/www/nextcloud/.db_password
|
||||
register: nc_db_password_file
|
||||
|
||||
- name: Set DB password fact
|
||||
set_fact:
|
||||
nc_db_pass: "{{ (nc_db_password_file.content | b64decode).strip() }}"
|
||||
|
||||
- name: Create Nextcloud MariaDB database
|
||||
mysql_db:
|
||||
name: nextcloud
|
||||
state: present
|
||||
encoding: utf8mb4
|
||||
collation: utf8mb4_general_ci
|
||||
login_unix_socket: /var/run/mysqld/mysqld.sock
|
||||
|
||||
- name: Create Nextcloud MariaDB user
|
||||
mysql_user:
|
||||
name: nextcloud
|
||||
password: "{{ nc_db_pass }}"
|
||||
priv: "nextcloud.*:ALL"
|
||||
host: localhost
|
||||
state: present
|
||||
login_unix_socket: /var/run/mysqld/mysqld.sock
|
||||
|
||||
- name: Generate admin password
|
||||
command: openssl rand -base64 16
|
||||
register: nc_admin_password_gen
|
||||
args:
|
||||
creates: /var/www/nextcloud/.admin_password
|
||||
|
||||
- name: Save admin password
|
||||
copy:
|
||||
content: "{{ nc_admin_password_gen.stdout }}"
|
||||
dest: /var/www/nextcloud/.admin_password
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: "0600"
|
||||
when: nc_admin_password_gen.changed
|
||||
|
||||
- name: Read saved admin password
|
||||
slurp:
|
||||
src: /var/www/nextcloud/.admin_password
|
||||
register: nc_admin_password_file
|
||||
|
||||
- name: Configure PHP-FPM pool for Nextcloud
|
||||
copy:
|
||||
dest: /etc/php/8.2/fpm/pool.d/nextcloud.conf
|
||||
content: |
|
||||
[nextcloud]
|
||||
user = www-data
|
||||
group = www-data
|
||||
listen = /run/php/php8.2-fpm-nextcloud.sock
|
||||
listen.owner = www-data
|
||||
listen.group = www-data
|
||||
pm = dynamic
|
||||
pm.max_children = 16
|
||||
pm.start_servers = 4
|
||||
pm.min_spare_servers = 2
|
||||
pm.max_spare_servers = 8
|
||||
pm.max_requests = 500
|
||||
env[HOSTNAME] = $HOSTNAME
|
||||
env[TMP] = /tmp
|
||||
env[TMPDIR] = /tmp
|
||||
env[TEMP] = /tmp
|
||||
php_value[upload_max_filesize] = 10G
|
||||
php_value[post_max_size] = 10G
|
||||
php_value[memory_limit] = 512M
|
||||
php_value[max_execution_time] = 3600
|
||||
php_value[max_input_time] = 3600
|
||||
php_value[opcache.enable] = 1
|
||||
php_value[opcache.memory_consumption] = 128
|
||||
php_value[opcache.interned_strings_buffer] = 16
|
||||
php_value[opcache.max_accelerated_files] = 10000
|
||||
php_value[opcache.revalidate_freq] = 1
|
||||
mode: "0644"
|
||||
notify: restart php-fpm
|
||||
|
||||
- name: Flush handlers to restart PHP-FPM
|
||||
meta: flush_handlers
|
||||
|
||||
- name: Install Nextcloud via occ
|
||||
become_user: www-data
|
||||
command: >
|
||||
php /var/www/nextcloud/occ maintenance:install
|
||||
--database mysql
|
||||
--database-name nextcloud
|
||||
--database-user nextcloud
|
||||
--database-pass "{{ nc_db_pass }}"
|
||||
--admin-user "{{ nextcloud_admin }}"
|
||||
--admin-pass "{{ (nc_admin_password_file.content | b64decode).strip() }}"
|
||||
--data-dir /var/www/nextcloud/data
|
||||
args:
|
||||
creates: /var/www/nextcloud/config/config.php
|
||||
|
||||
- name: Set trusted domain
|
||||
become_user: www-data
|
||||
command: "php /var/www/nextcloud/occ config:system:set trusted_domains 0 --value={{ nextcloud_domain }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Set overwrite.cli.url
|
||||
become_user: www-data
|
||||
command: "php /var/www/nextcloud/occ config:system:set overwrite.cli.url --value=https://{{ nextcloud_domain }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Configure Redis caching
|
||||
become_user: www-data
|
||||
command: "php /var/www/nextcloud/occ config:system:set {{ item.key }} --value={{ item.value }} {{ item.type | default('') }}"
|
||||
loop:
|
||||
- { key: "memcache.local", value: "\\OC\\Memcache\\APCu" }
|
||||
- { key: "memcache.distributed", value: "\\OC\\Memcache\\Redis" }
|
||||
- { key: "memcache.locking", value: "\\OC\\Memcache\\Redis" }
|
||||
- { key: "redis host", value: "localhost" }
|
||||
- { key: "redis port", value: "6379", type: "--type=integer" }
|
||||
changed_when: true
|
||||
|
||||
- name: Set default phone region
|
||||
become_user: www-data
|
||||
command: "php /var/www/nextcloud/occ config:system:set default_phone_region --value=US"
|
||||
changed_when: true
|
||||
|
||||
- name: Deploy Nextcloud cron systemd timer
|
||||
copy:
|
||||
dest: /etc/systemd/system/nextcloud-cron.service
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Nextcloud cron.php
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
ExecStart=/usr/bin/php /var/www/nextcloud/cron.php
|
||||
Type=oneshot
|
||||
mode: "0644"
|
||||
|
||||
- name: Deploy Nextcloud cron timer
|
||||
copy:
|
||||
dest: /etc/systemd/system/nextcloud-cron.timer
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Run Nextcloud cron.php every 5 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=5min
|
||||
OnUnitActiveSec=5min
|
||||
Unit=nextcloud-cron.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
mode: "0644"
|
||||
|
||||
- name: Enable Nextcloud cron timer
|
||||
systemd:
|
||||
name: nextcloud-cron.timer
|
||||
state: started
|
||||
enabled: true
|
||||
daemon_reload: true
|
||||
|
||||
- name: Display admin credentials
|
||||
debug:
|
||||
msg: "Nextcloud admin credentials saved to /var/www/nextcloud/.admin_password — access at https://{{ nextcloud_domain }}"
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
# Nextcloud package and dependency installation
|
||||
|
||||
- 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
|
||||
- mariadb-server
|
||||
- redis-server
|
||||
- nginx
|
||||
- certbot
|
||||
- python3-certbot-nginx
|
||||
- unzip
|
||||
- curl
|
||||
- ca-certificates
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Enable and start MariaDB
|
||||
service:
|
||||
name: mariadb
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Enable and start Redis
|
||||
service:
|
||||
name: redis-server
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Enable and start PHP-FPM
|
||||
service:
|
||||
name: php8.2-fpm
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Get latest Nextcloud version
|
||||
uri:
|
||||
url: https://download.nextcloud.com/server/releases/latest.tar.bz2.sha256
|
||||
return_content: true
|
||||
register: nc_checksum_response
|
||||
|
||||
- name: Parse Nextcloud checksum
|
||||
set_fact:
|
||||
nc_checksum: "{{ nc_checksum_response.content.split()[0] }}"
|
||||
|
||||
- name: Download Nextcloud tarball
|
||||
get_url:
|
||||
url: https://download.nextcloud.com/server/releases/latest.tar.bz2
|
||||
dest: /tmp/nextcloud.tar.bz2
|
||||
checksum: "sha256:{{ nc_checksum }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Extract Nextcloud
|
||||
unarchive:
|
||||
src: /tmp/nextcloud.tar.bz2
|
||||
dest: /var/www/
|
||||
remote_src: true
|
||||
creates: /var/www/nextcloud/index.php
|
||||
|
||||
- name: Set Nextcloud ownership
|
||||
file:
|
||||
path: /var/www/nextcloud
|
||||
state: directory
|
||||
owner: www-data
|
||||
group: www-data
|
||||
recurse: true
|
||||
|
||||
- name: Create Nextcloud data directory
|
||||
file:
|
||||
path: /var/www/nextcloud/data
|
||||
state: directory
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: "0770"
|
||||
|
||||
- name: Allow HTTP/HTTPS through UFW
|
||||
ufw:
|
||||
rule: allow
|
||||
port: "{{ item }}"
|
||||
proto: tcp
|
||||
loop:
|
||||
- "80"
|
||||
- "443"
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
# Nginx reverse proxy for Nextcloud
|
||||
|
||||
- name: Create certbot webroot
|
||||
file:
|
||||
path: /var/www/certbot
|
||||
state: directory
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: "0755"
|
||||
|
||||
- name: Deploy HTTP-only nginx config (for certbot)
|
||||
copy:
|
||||
dest: /etc/nginx/sites-available/nextcloud
|
||||
content: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ nextcloud_domain }};
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 200 'phantom setup in progress'; add_header Content-Type text/plain; }
|
||||
}
|
||||
mode: "0644"
|
||||
notify: reload nginx
|
||||
|
||||
- name: Enable Nextcloud nginx site
|
||||
file:
|
||||
src: /etc/nginx/sites-available/nextcloud
|
||||
dest: /etc/nginx/sites-enabled/nextcloud
|
||||
state: link
|
||||
notify: reload nginx
|
||||
|
||||
- name: Flush handlers to apply HTTP config
|
||||
meta: flush_handlers
|
||||
|
||||
- name: Setup TLS (certbot with self-signed fallback)
|
||||
include_tasks: ../../common/tls_setup.yml
|
||||
vars:
|
||||
_tls_domain: "{{ nextcloud_domain }}"
|
||||
|
||||
- name: Deploy SSL nginx config
|
||||
copy:
|
||||
dest: /etc/nginx/sites-available/nextcloud
|
||||
content: |
|
||||
upstream php-handler {
|
||||
server unix:/run/php/php8.2-fpm-nextcloud.sock;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ nextcloud_domain }};
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name {{ nextcloud_domain }};
|
||||
|
||||
ssl_certificate {{ _ssl_cert }};
|
||||
ssl_certificate_key {{ _ssl_key }};
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
server_tokens off;
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
|
||||
client_max_body_size 10G;
|
||||
client_body_timeout 3600s;
|
||||
fastcgi_buffers 64 4K;
|
||||
|
||||
root /var/www/nextcloud;
|
||||
|
||||
location = /.well-known/carddav { return 301 $scheme://$host/remote.php/dav; }
|
||||
location = /.well-known/caldav { return 301 $scheme://$host/remote.php/dav; }
|
||||
location = /.well-known/webfinger { return 301 $scheme://$host/index.php/.well-known/webfinger; }
|
||||
location = /.well-known/nodeinfo { return 301 $scheme://$host/index.php/.well-known/nodeinfo; }
|
||||
|
||||
location / { rewrite ^ /index.php; }
|
||||
|
||||
location ~ ^\/(?:build|tests|config|lib|3rdparty|templates|data)\/ { deny all; }
|
||||
location ~ ^\/(?:\.|autotest|occ|issue|indie|db_|console) { deny all; }
|
||||
|
||||
location ~ ^\/(?:index|remote|public|cron|core\/ajax\/update|status|ocs\/v[12]|updater\/.+|ocs-provider\/.+)\.php(?:$|\/) {
|
||||
fastcgi_split_path_info ^(.+?\.php)(\/.*|)$;
|
||||
set $path_info $fastcgi_path_info;
|
||||
try_files $fastcgi_script_name =404;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $path_info;
|
||||
fastcgi_param HTTPS on;
|
||||
fastcgi_param modHeadersAvailable true;
|
||||
fastcgi_param front_controller_active true;
|
||||
fastcgi_pass php-handler;
|
||||
fastcgi_intercept_errors on;
|
||||
fastcgi_request_buffering off;
|
||||
fastcgi_read_timeout 3600;
|
||||
}
|
||||
|
||||
location ~ ^\/(?:updater|ocs-provider)(?:$|\/) { try_files $uri/ =404; index index.php; }
|
||||
location ~ \.(?:css|js|woff2?|svg|gif|map)$ { try_files $uri /index.php$request_uri; add_header Cache-Control "public, max-age=15778463"; access_log off; }
|
||||
location ~ \.(?:png|html|ttf|ico|jpg|jpeg|bcmap|mp4|webm)$ { try_files $uri /index.php$request_uri; access_log off; }
|
||||
}
|
||||
mode: "0644"
|
||||
notify: reload nginx
|
||||
@@ -6,7 +6,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
ssh_port: "{{ ssh_port | default(22) }}"
|
||||
_ssh_port: "{{ ssh_port | default('22') }}"
|
||||
ssh_allow_password: false
|
||||
|
||||
tasks:
|
||||
@@ -51,7 +51,7 @@
|
||||
- name: Allow SSH through UFW
|
||||
ufw:
|
||||
rule: allow
|
||||
port: "{{ ssh_port }}"
|
||||
port: "{{ _ssh_port }}"
|
||||
proto: tcp
|
||||
|
||||
- name: Enable UFW
|
||||
@@ -59,18 +59,45 @@
|
||||
state: enabled
|
||||
|
||||
# ─── Fail2ban ───────────────────────────────────────────────────────
|
||||
- name: Configure fail2ban SSH jail
|
||||
- name: Configure fail2ban jails
|
||||
copy:
|
||||
dest: /etc/fail2ban/jail.local
|
||||
content: |
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = {{ ssh_port }}
|
||||
filter = sshd
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 5
|
||||
[DEFAULT]
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
maxretry = 5
|
||||
banaction = ufw
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = {{ _ssh_port }}
|
||||
filter = sshd
|
||||
backend = systemd
|
||||
maxretry = 3
|
||||
bantime = 7200
|
||||
|
||||
[nginx-http-auth]
|
||||
enabled = true
|
||||
port = http,https
|
||||
filter = nginx-http-auth
|
||||
logpath = /var/log/nginx/error.log
|
||||
maxretry = 3
|
||||
|
||||
[nginx-botsearch]
|
||||
enabled = true
|
||||
port = http,https
|
||||
filter = nginx-botsearch
|
||||
logpath = /var/log/nginx/access.log
|
||||
maxretry = 5
|
||||
|
||||
[nginx-limit-req]
|
||||
enabled = true
|
||||
port = http,https
|
||||
filter = nginx-limit-req
|
||||
logpath = /var/log/nginx/error.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
mode: "0644"
|
||||
notify: restart fail2ban
|
||||
|
||||
@@ -103,6 +130,41 @@
|
||||
line: "X11Forwarding no"
|
||||
notify: restart sshd
|
||||
|
||||
- name: Set SSH MaxAuthTries
|
||||
lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: "^#?MaxAuthTries"
|
||||
line: "MaxAuthTries 3"
|
||||
notify: restart sshd
|
||||
|
||||
- name: Set SSH ClientAliveInterval
|
||||
lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: "^#?ClientAliveInterval"
|
||||
line: "ClientAliveInterval 300"
|
||||
notify: restart sshd
|
||||
|
||||
- name: Set SSH ClientAliveCountMax
|
||||
lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: "^#?ClientAliveCountMax"
|
||||
line: "ClientAliveCountMax 2"
|
||||
notify: restart sshd
|
||||
|
||||
- name: Set SSH MaxStartups
|
||||
lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: "^#?MaxStartups"
|
||||
line: "MaxStartups 10:30:100"
|
||||
notify: restart sshd
|
||||
|
||||
- name: Set SSH LoginGraceTime
|
||||
lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: "^#?LoginGraceTime"
|
||||
line: "LoginGraceTime 60"
|
||||
notify: restart sshd
|
||||
|
||||
# ─── Automatic Security Updates ─────────────────────────────────────
|
||||
- name: Enable unattended upgrades for security
|
||||
copy:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phantom — Matrix User Registration
|
||||
# Creates a new user on this Synapse homeserver
|
||||
set -euo pipefail
|
||||
|
||||
SERVER_NAME=$(grep '^server_name:' /etc/matrix-synapse/homeserver.yaml | awk '{print $2}' | tr -d '"')
|
||||
SECRET=$(grep 'registration_shared_secret:' /etc/matrix-synapse/homeserver.yaml | awk '{print $2}' | tr -d '"')
|
||||
|
||||
echo "============================================"
|
||||
echo " Matrix — Add User"
|
||||
echo " Server: ${SERVER_NAME}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
register_new_matrix_user \
|
||||
--shared-secret "${SECRET}" \
|
||||
http://localhost:8008
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phantom — Let's Encrypt Certificate Setup
|
||||
# Deployed by phantom to {{ ansible_host | default('this server') }}
|
||||
# Run this script after DNS has propagated to this server's IP.
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="{{ _tls_domain }}"
|
||||
SERVER_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
echo "============================================"
|
||||
echo " Phantom — Certificate Setup"
|
||||
echo "============================================"
|
||||
echo " Domain: ${DOMAIN}"
|
||||
echo " Server IP: ${SERVER_IP}"
|
||||
echo ""
|
||||
|
||||
# ── DNS Validation ──────────────────────────────────────────────────────
|
||||
echo "[*] Checking DNS resolution for ${DOMAIN}..."
|
||||
DNS_IP=$(dig +short "${DOMAIN}" @1.1.1.1 2>/dev/null | tail -1)
|
||||
|
||||
if [ -z "${DNS_IP}" ]; then
|
||||
echo "[-] DNS lookup failed — ${DOMAIN} does not resolve."
|
||||
echo " Point your DNS A record to: ${SERVER_IP}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${DNS_IP}" != "${SERVER_IP}" ]; then
|
||||
echo "[-] DNS mismatch!"
|
||||
echo " ${DOMAIN} resolves to: ${DNS_IP}"
|
||||
echo " This server's IP: ${SERVER_IP}"
|
||||
echo " Update your DNS A record to point to ${SERVER_IP}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[+] DNS OK — ${DOMAIN} → ${DNS_IP}"
|
||||
|
||||
# ── HTTP Reachability Check ─────────────────────────────────────────────
|
||||
echo "[*] Verifying HTTP reachability..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://${DOMAIN}/" --max-time 10 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${HTTP_CODE}" = "000" ]; then
|
||||
echo "[!] Warning: HTTP request to ${DOMAIN} failed (timeout or connection refused)"
|
||||
echo " Certbot may fail if port 80 is not reachable."
|
||||
read -rp " Continue anyway? [y/N] " CONT
|
||||
[ "${CONT}" = "y" ] || exit 1
|
||||
else
|
||||
echo "[+] HTTP reachable (status ${HTTP_CODE})"
|
||||
fi
|
||||
|
||||
# ── Certbot ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[*] Running certbot for ${DOMAIN}..."
|
||||
|
||||
# Check if nginx is running — use webroot if so, standalone otherwise
|
||||
if systemctl is-active --quiet nginx 2>/dev/null; then
|
||||
echo " Using webroot mode (nginx running)"
|
||||
mkdir -p /var/www/certbot
|
||||
certbot certonly --webroot -w /var/www/certbot \
|
||||
-d "${DOMAIN}" \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
{% if certbot_email is defined and certbot_email %}
|
||||
--email "{{ certbot_email }}" \
|
||||
{% else %}
|
||||
--register-unsafely-without-email \
|
||||
{% endif %}
|
||||
--force-renewal
|
||||
else
|
||||
echo " Using standalone mode (nginx not running)"
|
||||
certbot certonly --standalone \
|
||||
-d "${DOMAIN}" \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
{% if certbot_email is defined and certbot_email %}
|
||||
--email "{{ certbot_email }}" \
|
||||
{% else %}
|
||||
--register-unsafely-without-email \
|
||||
{% endif %}
|
||||
--force-renewal
|
||||
fi
|
||||
|
||||
if [ ! -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
|
||||
echo "[-] Certbot failed — certificate not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[+] Certificate obtained successfully."
|
||||
|
||||
# ── Update nginx to use LE cert ─────────────────────────────────────────
|
||||
echo "[*] Updating nginx configuration..."
|
||||
|
||||
SELF_CERT="/etc/ssl/certs/${DOMAIN}-selfsigned.crt"
|
||||
SELF_KEY="/etc/ssl/private/${DOMAIN}-selfsigned.key"
|
||||
LE_CERT="/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"
|
||||
LE_KEY="/etc/letsencrypt/live/${DOMAIN}/privkey.pem"
|
||||
|
||||
# Replace self-signed cert paths with LE cert paths in all nginx configs
|
||||
for CONF in /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*; do
|
||||
[ -f "${CONF}" ] || continue
|
||||
if grep -q "${SELF_CERT}" "${CONF}" 2>/dev/null; then
|
||||
sed -i "s|${SELF_CERT}|${LE_CERT}|g" "${CONF}"
|
||||
sed -i "s|${SELF_KEY}|${LE_KEY}|g" "${CONF}"
|
||||
echo " Updated: ${CONF}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Test and reload nginx
|
||||
if nginx -t 2>/dev/null; then
|
||||
systemctl reload nginx
|
||||
echo "[+] nginx reloaded with Let's Encrypt certificate."
|
||||
else
|
||||
echo "[-] nginx config test failed — check configuration manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Certificate setup complete!"
|
||||
echo " Domain: https://${DOMAIN}"
|
||||
echo "============================================"
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
# Shared TLS setup: tries certbot, falls back to self-signed
|
||||
# Include with: include_tasks: ../common/tls_setup.yml
|
||||
# Required vars: _tls_domain (the domain to get a cert for)
|
||||
|
||||
- name: Attempt TLS certificate from Let's Encrypt
|
||||
command: >
|
||||
certbot certonly --webroot -w /var/www/certbot
|
||||
-d {{ _tls_domain }}
|
||||
--non-interactive
|
||||
--agree-tos
|
||||
{% if certbot_email is defined and certbot_email %}
|
||||
--email {{ certbot_email }}
|
||||
{% else %}
|
||||
--register-unsafely-without-email
|
||||
{% endif %}
|
||||
args:
|
||||
creates: "/etc/letsencrypt/live/{{ _tls_domain }}/fullchain.pem"
|
||||
register: _certbot_result
|
||||
ignore_errors: true
|
||||
|
||||
- name: Generate self-signed certificate (fallback)
|
||||
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 }}"
|
||||
args:
|
||||
creates: "/etc/ssl/certs/{{ _tls_domain }}-selfsigned.crt"
|
||||
when: _certbot_result is failed
|
||||
|
||||
- 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"
|
||||
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"
|
||||
when: _certbot_result is failed
|
||||
|
||||
- name: Warn about 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
|
||||
|
||||
- name: Create Tools directory
|
||||
file:
|
||||
path: /root/Tools
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Deploy cert setup script
|
||||
template:
|
||||
src: ../common/templates/setup-cert.sh.j2
|
||||
dest: /root/Tools/setup-cert.sh
|
||||
mode: "0700"
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
# Pi-hole DNS server deployment (Docker-based)
|
||||
# Pi-hole DNS server deployment (native installer)
|
||||
|
||||
- name: Deploy Pi-hole DNS Server
|
||||
hosts: all
|
||||
@@ -16,3 +16,9 @@
|
||||
|
||||
- name: Include Pi-hole configuration
|
||||
include_tasks: tasks/configure.yml
|
||||
|
||||
handlers:
|
||||
- name: restart pihole-FTL
|
||||
service:
|
||||
name: pihole-FTL
|
||||
state: restarted
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
---
|
||||
# Pi-hole Docker configuration and launch
|
||||
|
||||
- name: Create Pi-hole directories
|
||||
file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- /opt/pihole
|
||||
- /opt/pihole/etc-pihole
|
||||
- /opt/pihole/etc-dnsmasq.d
|
||||
# Pi-hole native configuration
|
||||
|
||||
- name: Generate Pi-hole admin password
|
||||
shell: "openssl rand -base64 16"
|
||||
register: pihole_password
|
||||
command: openssl rand -base64 16
|
||||
register: pihole_password_gen
|
||||
args:
|
||||
creates: /opt/pihole/.password
|
||||
creates: /etc/pihole/.phantom_password
|
||||
|
||||
- name: Save admin password
|
||||
copy:
|
||||
content: "{{ pihole_password.stdout }}"
|
||||
dest: /opt/pihole/.password
|
||||
content: "{{ pihole_password_gen.stdout }}"
|
||||
dest: /etc/pihole/.phantom_password
|
||||
mode: "0600"
|
||||
when: pihole_password.changed
|
||||
when: pihole_password_gen.changed
|
||||
|
||||
- name: Deploy Pi-hole Docker Compose
|
||||
copy:
|
||||
dest: /opt/pihole/docker-compose.yml
|
||||
content: |
|
||||
services:
|
||||
pihole:
|
||||
container_name: pihole
|
||||
image: pihole/pihole:latest
|
||||
ports:
|
||||
- "53:53/tcp"
|
||||
- "53:53/udp"
|
||||
- "80:80/tcp"
|
||||
environment:
|
||||
TZ: UTC
|
||||
WEBPASSWORD_FILE: /run/secrets/webpassword
|
||||
PIHOLE_DNS_: "{{ pihole_upstream }}"
|
||||
DNSSEC: "true"
|
||||
QUERY_LOGGING: "false"
|
||||
volumes:
|
||||
- /opt/pihole/etc-pihole:/etc/pihole
|
||||
- /opt/pihole/etc-dnsmasq.d:/etc/dnsmasq.d
|
||||
secrets:
|
||||
- webpassword
|
||||
restart: unless-stopped
|
||||
dns:
|
||||
- 127.0.0.1
|
||||
- 9.9.9.9
|
||||
secrets:
|
||||
webpassword:
|
||||
file: /opt/pihole/.password
|
||||
mode: "0644"
|
||||
- name: Read saved password
|
||||
slurp:
|
||||
src: /etc/pihole/.phantom_password
|
||||
register: pihole_password_file
|
||||
|
||||
- name: Start Pi-hole
|
||||
shell: cd /opt/pihole && docker compose up -d
|
||||
args:
|
||||
creates: /opt/pihole/etc-pihole/pihole-FTL.db
|
||||
- name: Set Pi-hole admin password
|
||||
command: "pihole -a -p {{ (pihole_password_file.content | b64decode).strip() }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Display admin password
|
||||
- name: Set upstream DNS servers
|
||||
command: "pihole -a setdns {{ pihole_upstream | replace(';', ' ') }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Configure blocklist level
|
||||
block:
|
||||
- name: Update gravity (standard blocklist)
|
||||
command: pihole -g
|
||||
changed_when: true
|
||||
when: pihole_blocklist == "standard"
|
||||
|
||||
- name: Add aggressive blocklists
|
||||
lineinfile:
|
||||
path: /etc/pihole/adlists.list
|
||||
line: "{{ item }}"
|
||||
create: true
|
||||
mode: "0644"
|
||||
loop:
|
||||
- "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
|
||||
- "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt"
|
||||
when: pihole_blocklist == "aggressive"
|
||||
notify: update gravity
|
||||
|
||||
- name: Update gravity after blocklist changes
|
||||
command: pihole -g
|
||||
changed_when: true
|
||||
when: pihole_blocklist == "aggressive"
|
||||
|
||||
- name: Enable DNSSEC
|
||||
command: pihole -a dnssec on
|
||||
changed_when: true
|
||||
|
||||
- name: Ensure pihole-FTL is running
|
||||
service:
|
||||
name: pihole-FTL
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Display admin credentials
|
||||
debug:
|
||||
msg: "Pi-hole admin password: {{ pihole_password.stdout | default('(see /opt/pihole/.password)') }}"
|
||||
msg: "Pi-hole admin password saved to /etc/pihole/.phantom_password — access admin at http://{{ ansible_default_ipv4.address | default('server_ip') }}/admin"
|
||||
|
||||
@@ -1,40 +1,17 @@
|
||||
---
|
||||
# Pi-hole installation via Docker
|
||||
# Pi-hole native installation (no Docker)
|
||||
|
||||
- name: Install Docker prerequisites
|
||||
- name: Install Pi-hole prerequisites
|
||||
apt:
|
||||
name:
|
||||
- apt-transport-https
|
||||
- ca-certificates
|
||||
- curl
|
||||
- gnupg
|
||||
- lsb-release
|
||||
- git
|
||||
- iproute2
|
||||
- whiptail
|
||||
- ca-certificates
|
||||
- cron
|
||||
state: present
|
||||
|
||||
- name: Add Docker GPG key
|
||||
apt_key:
|
||||
url: https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg
|
||||
state: present
|
||||
|
||||
- name: Add Docker repository
|
||||
apt_repository:
|
||||
repo: "deb https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable"
|
||||
state: present
|
||||
|
||||
- name: Install Docker
|
||||
apt:
|
||||
name:
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- containerd.io
|
||||
- docker-compose-plugin
|
||||
state: present
|
||||
|
||||
- name: Enable Docker service
|
||||
service:
|
||||
name: docker
|
||||
state: started
|
||||
enabled: true
|
||||
update_cache: true
|
||||
|
||||
- name: Stop systemd-resolved (conflicts with Pi-hole on port 53)
|
||||
service:
|
||||
@@ -43,7 +20,7 @@
|
||||
enabled: false
|
||||
failed_when: false
|
||||
|
||||
- name: Set DNS fallback
|
||||
- name: Set DNS fallback for installation
|
||||
copy:
|
||||
dest: /etc/resolv.conf
|
||||
content: |
|
||||
@@ -51,6 +28,45 @@
|
||||
nameserver 1.1.1.1
|
||||
mode: "0644"
|
||||
|
||||
- name: Create Pi-hole config directory
|
||||
file:
|
||||
path: /etc/pihole
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Deploy setupVars.conf for unattended install
|
||||
copy:
|
||||
dest: /etc/pihole/setupVars.conf
|
||||
content: |
|
||||
PIHOLE_INTERFACE={{ ansible_default_ipv4.interface | default('eth0') }}
|
||||
PIHOLE_DNS_1={{ pihole_upstream.split(';')[0] | default('9.9.9.9') }}
|
||||
PIHOLE_DNS_2={{ pihole_upstream.split(';')[1] | default('149.112.112.112') }}
|
||||
QUERY_LOGGING=false
|
||||
INSTALL_WEB_SERVER=true
|
||||
INSTALL_WEB_INTERFACE=true
|
||||
LIGHTTPD_ENABLED=false
|
||||
CACHE_SIZE=10000
|
||||
DNS_FQDN_REQUIRED=true
|
||||
DNS_BOGUS_PRIV=true
|
||||
DNSSEC=true
|
||||
BLOCKING_ENABLED=true
|
||||
WEBPASSWORD=
|
||||
mode: "0644"
|
||||
|
||||
- name: Clone Pi-hole repository
|
||||
git:
|
||||
repo: https://github.com/pi-hole/pi-hole.git
|
||||
dest: /opt/pihole-install
|
||||
version: master
|
||||
depth: 1
|
||||
|
||||
- name: Run Pi-hole installer (unattended)
|
||||
command: bash /opt/pihole-install/automated\ install/basic-install.sh --unattended
|
||||
args:
|
||||
creates: /usr/local/bin/pihole
|
||||
environment:
|
||||
PIHOLE_SKIP_OS_CHECK: "true"
|
||||
|
||||
- name: Allow DNS through UFW
|
||||
ufw:
|
||||
rule: allow
|
||||
@@ -59,4 +75,14 @@
|
||||
loop:
|
||||
- { port: "53", proto: "tcp" }
|
||||
- { port: "53", proto: "udp" }
|
||||
- { port: "80", proto: "tcp" }
|
||||
|
||||
- name: Allow Pi-hole admin from private networks only
|
||||
ufw:
|
||||
rule: allow
|
||||
port: "80"
|
||||
proto: tcp
|
||||
from_ip: "{{ item }}"
|
||||
loop:
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
---
|
||||
# Email deployment — Coming soon
|
||||
# This is a stub playbook. Full implementation planned.
|
||||
# Mail-in-a-Box deployment — native installer script
|
||||
# MIAB manages its own nginx, TLS, DNS, and mail stack
|
||||
|
||||
- name: Deploy Email Server
|
||||
- name: Deploy Mail-in-a-Box Email Server
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
miab_domain: "{{ domain }}"
|
||||
miab_first_user: "{{ email_first_user }}"
|
||||
target_host: "{{ target_host | default('localhost') }}"
|
||||
|
||||
tasks:
|
||||
- name: Placeholder
|
||||
debug:
|
||||
msg: "Email playbook not yet implemented. Check phantom/README.md for status."
|
||||
- name: Include Mail-in-a-Box installation
|
||||
include_tasks: tasks/install.yml
|
||||
|
||||
- name: Include Mail-in-a-Box configuration
|
||||
include_tasks: tasks/configure.yml
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
# Mail-in-a-Box non-interactive installation
|
||||
|
||||
- name: Generate admin password
|
||||
command: openssl rand -base64 16
|
||||
register: miab_admin_password
|
||||
args:
|
||||
creates: /root/.miab_admin_password
|
||||
|
||||
- name: Save admin password
|
||||
copy:
|
||||
content: "{{ miab_admin_password.stdout }}"
|
||||
dest: /root/.miab_admin_password
|
||||
mode: "0600"
|
||||
when: miab_admin_password.changed
|
||||
|
||||
- name: Read saved admin password
|
||||
slurp:
|
||||
src: /root/.miab_admin_password
|
||||
register: miab_password_file
|
||||
|
||||
- name: Clone Mail-in-a-Box repository
|
||||
git:
|
||||
repo: https://github.com/mail-in-a-box/mailinabox.git
|
||||
dest: /opt/mailinabox
|
||||
version: main
|
||||
depth: 1
|
||||
|
||||
- name: Run Mail-in-a-Box installer (non-interactive)
|
||||
command: bash /opt/mailinabox/setup/start.sh
|
||||
args:
|
||||
creates: /etc/mailinabox.conf
|
||||
environment:
|
||||
NONINTERACTIVE: "1"
|
||||
PRIMARY_HOSTNAME: "{{ miab_domain }}"
|
||||
EMAIL_ADDR: "{{ miab_first_user }}"
|
||||
EMAIL_PW: "{{ (miab_password_file.content | b64decode).strip() }}"
|
||||
timeout: 600
|
||||
|
||||
- name: Display admin credentials
|
||||
debug:
|
||||
msg: >
|
||||
Mail-in-a-Box installed. Admin: {{ miab_first_user }}
|
||||
Password saved to /root/.miab_admin_password
|
||||
Web admin: https://{{ miab_domain }}/admin
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
# Mail-in-a-Box prerequisites and environment setup
|
||||
|
||||
- name: Verify Ubuntu distribution
|
||||
assert:
|
||||
that:
|
||||
- ansible_distribution == "Ubuntu"
|
||||
fail_msg: "Mail-in-a-Box requires Ubuntu. Detected: {{ ansible_distribution }}"
|
||||
|
||||
- name: Stop conflicting nginx (MIAB manages its own)
|
||||
service:
|
||||
name: nginx
|
||||
state: stopped
|
||||
enabled: false
|
||||
failed_when: false
|
||||
|
||||
- name: Set system hostname for MIAB
|
||||
hostname:
|
||||
name: "{{ miab_domain }}"
|
||||
|
||||
- name: Update /etc/hostname
|
||||
copy:
|
||||
content: "{{ miab_domain }}\n"
|
||||
dest: /etc/hostname
|
||||
mode: "0644"
|
||||
|
||||
- name: Ensure hostname in /etc/hosts
|
||||
lineinfile:
|
||||
path: /etc/hosts
|
||||
regexp: '^127\.0\.1\.1'
|
||||
line: "127.0.1.1 {{ miab_domain }}"
|
||||
|
||||
- name: Allow mail-related ports through UFW
|
||||
ufw:
|
||||
rule: allow
|
||||
port: "{{ item.port }}"
|
||||
proto: "{{ item.proto }}"
|
||||
loop:
|
||||
- { port: "25", proto: "tcp" }
|
||||
- { port: "587", proto: "tcp" }
|
||||
- { port: "993", proto: "tcp" }
|
||||
- { port: "465", proto: "tcp" }
|
||||
- { port: "80", proto: "tcp" }
|
||||
- { port: "443", proto: "tcp" }
|
||||
- { port: "53", proto: "tcp" }
|
||||
- { port: "53", proto: "udp" }
|
||||
- { port: "4190", proto: "tcp" }
|
||||
|
||||
- name: Install prerequisites
|
||||
apt:
|
||||
name:
|
||||
- curl
|
||||
- ca-certificates
|
||||
- git
|
||||
state: present
|
||||
update_cache: true
|
||||
@@ -26,6 +26,18 @@
|
||||
- name: Include nginx reverse proxy tasks
|
||||
include_tasks: tasks/nginx.yml
|
||||
|
||||
- name: Create Tools directory
|
||||
file:
|
||||
path: /root/Tools
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Deploy Matrix user creation script
|
||||
template:
|
||||
src: ../common/templates/add-matrix-user.sh.j2
|
||||
dest: /root/Tools/add-matrix-user.sh
|
||||
mode: "0700"
|
||||
|
||||
handlers:
|
||||
- name: restart synapse
|
||||
service:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
# Nginx reverse proxy for Matrix + Element
|
||||
# Two-phase: HTTP-only first for certbot, then full SSL config
|
||||
|
||||
- name: Create certbot webroot
|
||||
file:
|
||||
@@ -17,15 +16,8 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ matrix_domain }};
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 200 'phantom setup in progress';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 200 'phantom setup in progress'; add_header Content-Type text/plain; }
|
||||
}
|
||||
mode: "0644"
|
||||
notify: reload nginx
|
||||
@@ -40,35 +32,38 @@
|
||||
- name: Flush handlers to apply HTTP config
|
||||
meta: flush_handlers
|
||||
|
||||
- name: Obtain TLS certificate
|
||||
command: >
|
||||
certbot certonly --webroot -w /var/www/certbot
|
||||
-d {{ matrix_domain }}
|
||||
--non-interactive
|
||||
--agree-tos
|
||||
{% if certbot_email is defined and certbot_email %}
|
||||
--email {{ certbot_email }}
|
||||
{% else %}
|
||||
--register-unsafely-without-email
|
||||
{% endif %}
|
||||
args:
|
||||
creates: "/etc/letsencrypt/live/{{ matrix_domain }}/fullchain.pem"
|
||||
- name: Setup TLS (certbot with self-signed fallback)
|
||||
include_tasks: ../../common/tls_setup.yml
|
||||
vars:
|
||||
_tls_domain: "{{ matrix_domain }}"
|
||||
|
||||
- name: Deploy full SSL nginx config
|
||||
- name: Disable server tokens in nginx
|
||||
lineinfile:
|
||||
path: /etc/nginx/nginx.conf
|
||||
regexp: "^\\s*#?\\s*server_tokens"
|
||||
line: "\tserver_tokens off;"
|
||||
insertafter: "sendfile on;"
|
||||
notify: reload nginx
|
||||
|
||||
- name: Remove default nginx site
|
||||
file:
|
||||
path: /etc/nginx/sites-enabled/default
|
||||
state: absent
|
||||
notify: reload nginx
|
||||
|
||||
- name: Deploy hardened SSL nginx config
|
||||
copy:
|
||||
dest: /etc/nginx/sites-available/matrix
|
||||
content: |
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=matrix_login:10m rate=3r/m;
|
||||
limit_req_zone $binary_remote_addr zone=matrix_general:10m rate=10r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ matrix_domain }};
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
@@ -76,24 +71,51 @@
|
||||
listen 8448 ssl http2;
|
||||
server_name {{ matrix_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ matrix_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ matrix_domain }}/privkey.pem;
|
||||
|
||||
# TLS
|
||||
ssl_certificate {{ _ssl_cert }};
|
||||
ssl_certificate_key {{ _ssl_key }};
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
# Synapse
|
||||
location ~* ^(\/_matrix|\/_synapse\/client) {
|
||||
# Block Synapse admin API from external access
|
||||
location /_synapse/admin {
|
||||
return 403;
|
||||
}
|
||||
|
||||
# Login endpoint — strict rate limit
|
||||
location /_matrix/client/v3/login {
|
||||
limit_req zone=matrix_login burst=3 nodelay;
|
||||
proxy_pass http://127.0.0.1:8008;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# .well-known delegation
|
||||
# Matrix client + federation API
|
||||
location ~* ^(\/_matrix|\/_synapse\/client) {
|
||||
limit_req zone=matrix_general burst=20 nodelay;
|
||||
proxy_pass http://127.0.0.1:8008;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
|
||||
location /.well-known/matrix/server {
|
||||
return 200 '{"m.server": "{{ matrix_domain }}:443"}';
|
||||
add_header Content-Type application/json;
|
||||
@@ -105,10 +127,11 @@
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
}
|
||||
|
||||
# Element Web (if enabled)
|
||||
# Element Web
|
||||
location / {
|
||||
root /var/www/element;
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
}
|
||||
}
|
||||
mode: "0644"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
# Synapse homeserver installation and configuration
|
||||
# Uses SQLite3 (matches c2itall chat-server pattern — no PostgreSQL needed)
|
||||
|
||||
- name: Install dependencies
|
||||
apt:
|
||||
@@ -7,24 +8,25 @@
|
||||
- python3
|
||||
- python3-pip
|
||||
- python3-venv
|
||||
- libpq-dev
|
||||
- postgresql
|
||||
- postgresql-contrib
|
||||
- nginx
|
||||
- certbot
|
||||
- python3-certbot-nginx
|
||||
- curl
|
||||
- wget
|
||||
- gnupg
|
||||
- lsb-release
|
||||
state: present
|
||||
|
||||
- name: Add Matrix Synapse apt repository key
|
||||
apt_key:
|
||||
url: https://packages.matrix.org/debian/matrix-org-archive-keyring.gpg
|
||||
state: present
|
||||
- name: Add Matrix Synapse GPG key and repository
|
||||
shell: |
|
||||
wget -qO /usr/share/keyrings/matrix-org-archive-keyring.gpg https://packages.matrix.org/debian/matrix-org-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/matrix-org-archive-keyring.gpg] https://packages.matrix.org/debian/ $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/matrix-org.list
|
||||
args:
|
||||
creates: /etc/apt/sources.list.d/matrix-org.list
|
||||
|
||||
- name: Add Matrix Synapse apt repository
|
||||
apt_repository:
|
||||
repo: "deb https://packages.matrix.org/debian/ {{ ansible_distribution_release }} main"
|
||||
state: present
|
||||
filename: matrix-org
|
||||
- name: Update apt cache after adding Matrix repo
|
||||
apt:
|
||||
update_cache: true
|
||||
|
||||
- name: Install Synapse
|
||||
apt:
|
||||
@@ -33,22 +35,17 @@
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
|
||||
- name: Create PostgreSQL database
|
||||
become_user: postgres
|
||||
postgresql_db:
|
||||
name: synapse
|
||||
encoding: UTF-8
|
||||
lc_collate: C
|
||||
lc_ctype: C
|
||||
template: template0
|
||||
|
||||
- name: Create PostgreSQL user
|
||||
become_user: postgres
|
||||
postgresql_user:
|
||||
name: synapse
|
||||
password: "{{ matrix_signing_key[:32] }}"
|
||||
db: synapse
|
||||
priv: ALL
|
||||
- name: Set correct ownership for Matrix directories
|
||||
file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: matrix-synapse
|
||||
group: matrix-synapse
|
||||
mode: "0750"
|
||||
recurse: true
|
||||
loop:
|
||||
- /var/lib/matrix-synapse
|
||||
- /var/log/matrix-synapse
|
||||
|
||||
- name: Deploy Synapse homeserver config
|
||||
template:
|
||||
@@ -59,6 +56,11 @@
|
||||
mode: "0640"
|
||||
notify: restart synapse
|
||||
|
||||
- name: Clear conflicting conf.d configurations
|
||||
shell: |
|
||||
rm -f /etc/matrix-synapse/conf.d/*.yaml 2>/dev/null || true
|
||||
changed_when: false
|
||||
|
||||
- name: Allow Matrix federation port through UFW
|
||||
ufw:
|
||||
rule: allow
|
||||
@@ -77,23 +79,25 @@
|
||||
- name: Enable and start Synapse
|
||||
service:
|
||||
name: matrix-synapse
|
||||
state: started
|
||||
state: restarted
|
||||
enabled: true
|
||||
|
||||
- name: Wait for Synapse to be ready
|
||||
wait_for:
|
||||
port: 8008
|
||||
host: 127.0.0.1
|
||||
timeout: 30
|
||||
delay: 5
|
||||
timeout: 60
|
||||
|
||||
- name: Create admin user
|
||||
command: >
|
||||
register_new_matrix_user
|
||||
-u {{ matrix_admin }}
|
||||
-p {{ matrix_admin_pass }}
|
||||
-a
|
||||
-c /etc/matrix-synapse/homeserver.yaml
|
||||
http://localhost:8008
|
||||
shell: |
|
||||
/opt/venvs/matrix-synapse/bin/register_new_matrix_user \
|
||||
-c /etc/matrix-synapse/homeserver.yaml \
|
||||
-u {{ matrix_admin }} \
|
||||
-p {{ matrix_admin_pass }} \
|
||||
-a
|
||||
register: admin_create
|
||||
failed_when: false
|
||||
changed_when: admin_create.rc == 0
|
||||
failed_when:
|
||||
- admin_create.rc != 0
|
||||
- "'User ID already taken' not in admin_create.stderr"
|
||||
changed_when: "'User ID already taken' not in admin_create.stderr"
|
||||
|
||||
@@ -2,43 +2,83 @@
|
||||
## Generated by phantom
|
||||
|
||||
server_name: "{{ matrix_server_name }}"
|
||||
pid_file: /run/matrix-synapse.pid
|
||||
public_baseurl: "https://{{ matrix_domain }}/"
|
||||
pid_file: /var/run/matrix-synapse.pid
|
||||
|
||||
listeners:
|
||||
- port: 8008
|
||||
tls: false
|
||||
type: http
|
||||
x_forwarded: true
|
||||
bind_addresses:
|
||||
- '127.0.0.1'
|
||||
resources:
|
||||
- names: [client, federation]
|
||||
compress: false
|
||||
|
||||
database:
|
||||
name: psycopg2
|
||||
name: sqlite3
|
||||
args:
|
||||
user: synapse
|
||||
password: "{{ matrix_signing_key[:32] }}"
|
||||
database: synapse
|
||||
host: localhost
|
||||
cp_min: 5
|
||||
cp_max: 10
|
||||
database: /var/lib/matrix-synapse/homeserver.db
|
||||
|
||||
log_config: "/etc/matrix-synapse/log.yaml"
|
||||
media_store_path: /var/lib/matrix-synapse/media
|
||||
signing_key_path: "/etc/matrix-synapse/{{ matrix_server_name }}.signing.key"
|
||||
signing_key_path: "/etc/matrix-synapse/homeserver.signing.key"
|
||||
|
||||
registration_shared_secret: "{{ matrix_signing_key }}"
|
||||
enable_registration: {{ matrix_registration_enabled | lower }}
|
||||
{% if not matrix_registration_enabled %}
|
||||
enable_registration_without_verification: false
|
||||
{% endif %}
|
||||
allow_guest_access: false
|
||||
|
||||
suppress_key_server_warning: true
|
||||
form_secret: "{{ matrix_form_secret | default(matrix_signing_key[:28]) }}"
|
||||
macaroon_secret_key: "{{ matrix_macaroon_secret | default(matrix_signing_key[:30]) }}"
|
||||
|
||||
trusted_key_servers:
|
||||
- server_name: "matrix.org"
|
||||
|
||||
max_upload_size: 50M
|
||||
# Rate limiting
|
||||
rc_message:
|
||||
per_second: 0.2
|
||||
burst_count: 10
|
||||
rc_login:
|
||||
address:
|
||||
per_second: 0.17
|
||||
burst_count: 3
|
||||
account:
|
||||
per_second: 0.17
|
||||
burst_count: 3
|
||||
rc_registration:
|
||||
per_second: 0.17
|
||||
burst_count: 3
|
||||
rc_admin_redaction:
|
||||
per_second: 1
|
||||
burst_count: 50
|
||||
|
||||
# Privacy / access control
|
||||
require_auth_for_profile_requests: true
|
||||
limit_profile_requests_to_users_who_share_rooms: true
|
||||
allow_public_rooms_without_auth: false
|
||||
allow_public_rooms_over_federation: false
|
||||
|
||||
# Media
|
||||
enable_media_repo: true
|
||||
max_upload_size: 50M
|
||||
max_image_pixels: 32M
|
||||
|
||||
# Telemetry
|
||||
enable_metrics: false
|
||||
report_stats: false
|
||||
|
||||
# Session / auth hardening
|
||||
session_lifetime: 24h
|
||||
bcrypt_rounds: 12
|
||||
|
||||
# Federation TLS
|
||||
federation_client_minimum_tls_version: 1.2
|
||||
|
||||
# URL preview with SSRF protection
|
||||
url_preview_enabled: true
|
||||
url_preview_ip_range_blacklist:
|
||||
- '127.0.0.0/8'
|
||||
@@ -46,8 +86,9 @@ url_preview_ip_range_blacklist:
|
||||
- '172.16.0.0/12'
|
||||
- '192.168.0.0/16'
|
||||
- '100.64.0.0/10'
|
||||
- '192.0.0.0/24'
|
||||
- '169.254.0.0/16'
|
||||
- '::1/128'
|
||||
- 'fe80::/10'
|
||||
- 'fc00::/7'
|
||||
|
||||
suppress_key_server_warning: true
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
---
|
||||
# Media deployment — Coming soon
|
||||
# This is a stub playbook. Full implementation planned.
|
||||
# Jellyfin media server deployment (native package from official repo)
|
||||
|
||||
- name: Deploy Media Server
|
||||
- name: Deploy Jellyfin Media Server
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
jellyfin_domain: "{{ domain }}"
|
||||
jellyfin_library: "{{ media_library_path | default('/srv/media') }}"
|
||||
target_host: "{{ target_host | default('localhost') }}"
|
||||
|
||||
tasks:
|
||||
- name: Placeholder
|
||||
debug:
|
||||
msg: "Media playbook not yet implemented. Check phantom/README.md for status."
|
||||
- name: Include Jellyfin installation
|
||||
include_tasks: tasks/install.yml
|
||||
|
||||
- name: Include Jellyfin configuration
|
||||
include_tasks: tasks/configure.yml
|
||||
|
||||
- name: Include Jellyfin nginx reverse proxy
|
||||
include_tasks: tasks/nginx.yml
|
||||
|
||||
handlers:
|
||||
- name: reload nginx
|
||||
service:
|
||||
name: nginx
|
||||
state: reloaded
|
||||
|
||||
- name: restart jellyfin
|
||||
service:
|
||||
name: jellyfin
|
||||
state: restarted
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
# Jellyfin service configuration
|
||||
|
||||
- name: Create media library directory
|
||||
file:
|
||||
path: "{{ jellyfin_library }}"
|
||||
state: directory
|
||||
owner: jellyfin
|
||||
group: jellyfin
|
||||
mode: "0755"
|
||||
|
||||
- name: Ensure Jellyfin binds to localhost only
|
||||
lineinfile:
|
||||
path: /etc/jellyfin/network.xml
|
||||
regexp: '<LocalNetworkAddresses>'
|
||||
line: ' <LocalNetworkAddresses><string>127.0.0.1</string></LocalNetworkAddresses>'
|
||||
create: true
|
||||
mode: "0644"
|
||||
notify: restart jellyfin
|
||||
failed_when: false
|
||||
|
||||
- name: Enable and start Jellyfin service
|
||||
service:
|
||||
name: jellyfin
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Wait for Jellyfin to be ready
|
||||
wait_for:
|
||||
port: 8096
|
||||
host: 127.0.0.1
|
||||
delay: 5
|
||||
timeout: 60
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
# Jellyfin installation from official apt repository
|
||||
|
||||
- name: Install prerequisites
|
||||
apt:
|
||||
name:
|
||||
- curl
|
||||
- gnupg
|
||||
- ca-certificates
|
||||
- nginx
|
||||
- certbot
|
||||
- python3-certbot-nginx
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Add Jellyfin GPG signing key
|
||||
ansible.builtin.get_url:
|
||||
url: https://repo.jellyfin.org/jellyfin_team.gpg.key
|
||||
dest: /usr/share/keyrings/jellyfin-archive-keyring.asc
|
||||
mode: "0644"
|
||||
|
||||
- name: Add Jellyfin apt repository
|
||||
apt_repository:
|
||||
repo: "deb [signed-by=/usr/share/keyrings/jellyfin-archive-keyring.asc] https://repo.jellyfin.org/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} main"
|
||||
state: present
|
||||
filename: jellyfin
|
||||
|
||||
- name: Install Jellyfin
|
||||
apt:
|
||||
name: jellyfin
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Allow HTTP/HTTPS through UFW
|
||||
ufw:
|
||||
rule: allow
|
||||
port: "{{ item }}"
|
||||
proto: tcp
|
||||
loop:
|
||||
- "80"
|
||||
- "443"
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
# Nginx reverse proxy for Jellyfin
|
||||
|
||||
- name: Create certbot webroot
|
||||
file:
|
||||
path: /var/www/certbot
|
||||
state: directory
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: "0755"
|
||||
|
||||
- name: Deploy HTTP-only nginx config (for certbot)
|
||||
copy:
|
||||
dest: /etc/nginx/sites-available/jellyfin
|
||||
content: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ jellyfin_domain }};
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 200 'phantom setup in progress'; add_header Content-Type text/plain; }
|
||||
}
|
||||
mode: "0644"
|
||||
notify: reload nginx
|
||||
|
||||
- name: Enable Jellyfin nginx site
|
||||
file:
|
||||
src: /etc/nginx/sites-available/jellyfin
|
||||
dest: /etc/nginx/sites-enabled/jellyfin
|
||||
state: link
|
||||
notify: reload nginx
|
||||
|
||||
- name: Flush handlers to apply HTTP config
|
||||
meta: flush_handlers
|
||||
|
||||
- name: Setup TLS (certbot with self-signed fallback)
|
||||
include_tasks: ../../common/tls_setup.yml
|
||||
vars:
|
||||
_tls_domain: "{{ jellyfin_domain }}"
|
||||
|
||||
- name: Deploy SSL nginx config
|
||||
copy:
|
||||
dest: /etc/nginx/sites-available/jellyfin
|
||||
content: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ jellyfin_domain }};
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name {{ jellyfin_domain }};
|
||||
|
||||
ssl_certificate {{ _ssl_cert }};
|
||||
ssl_certificate_key {{ _ssl_key }};
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
server_tokens off;
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
client_max_body_size 100m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8096;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location /socket {
|
||||
proxy_pass http://127.0.0.1:8096;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
mode: "0644"
|
||||
notify: reload nginx
|
||||
@@ -1,11 +1,32 @@
|
||||
---
|
||||
# Vault deployment — Coming soon
|
||||
# This is a stub playbook. Full implementation planned.
|
||||
# Vaultwarden (Bitwarden-compatible) deployment — pre-built binary + systemd
|
||||
|
||||
- name: Deploy Vault Server
|
||||
- name: Deploy Vaultwarden Password Manager
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
vaultwarden_domain: "{{ domain }}"
|
||||
vaultwarden_admin_token: "{{ vault_admin_token }}"
|
||||
vaultwarden_signups: "{{ vault_signups_allowed | default(false) }}"
|
||||
target_host: "{{ target_host | default('localhost') }}"
|
||||
|
||||
tasks:
|
||||
- name: Placeholder
|
||||
debug:
|
||||
msg: "Vault playbook not yet implemented. Check phantom/README.md for status."
|
||||
- name: Include Vaultwarden installation
|
||||
include_tasks: tasks/install.yml
|
||||
|
||||
- name: Include Vaultwarden configuration
|
||||
include_tasks: tasks/configure.yml
|
||||
|
||||
- name: Include Vaultwarden nginx reverse proxy
|
||||
include_tasks: tasks/nginx.yml
|
||||
|
||||
handlers:
|
||||
- name: reload nginx
|
||||
service:
|
||||
name: nginx
|
||||
state: reloaded
|
||||
|
||||
- name: restart vaultwarden
|
||||
service:
|
||||
name: vaultwarden
|
||||
state: restarted
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
# Vaultwarden service configuration
|
||||
|
||||
- name: Deploy Vaultwarden environment file
|
||||
copy:
|
||||
dest: /opt/vaultwarden/.env
|
||||
content: |
|
||||
DOMAIN=https://{{ vaultwarden_domain }}
|
||||
SIGNUPS_ALLOWED={{ vaultwarden_signups | lower }}
|
||||
ADMIN_TOKEN={{ vaultwarden_admin_token }}
|
||||
ROCKET_ADDRESS=127.0.0.1
|
||||
ROCKET_PORT=8081
|
||||
DATA_FOLDER=/opt/vaultwarden/data
|
||||
WEB_VAULT_FOLDER=/opt/vaultwarden/web-vault
|
||||
LOG_FILE=/opt/vaultwarden/data/vaultwarden.log
|
||||
LOG_LEVEL=warn
|
||||
SENDS_ALLOWED=true
|
||||
EMERGENCY_ACCESS_ALLOWED=true
|
||||
WEBSOCKET_ENABLED=true
|
||||
WEBSOCKET_ADDRESS=127.0.0.1
|
||||
WEBSOCKET_PORT=3012
|
||||
owner: vaultwarden
|
||||
group: vaultwarden
|
||||
mode: "0600"
|
||||
notify: restart vaultwarden
|
||||
|
||||
- name: Deploy Vaultwarden systemd unit
|
||||
copy:
|
||||
dest: /etc/systemd/system/vaultwarden.service
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Vaultwarden (Bitwarden-compatible server)
|
||||
Documentation=https://github.com/dani-garcia/vaultwarden
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=vaultwarden
|
||||
Group=vaultwarden
|
||||
EnvironmentFile=/opt/vaultwarden/.env
|
||||
ExecStart=/opt/vaultwarden/vaultwarden
|
||||
WorkingDirectory=/opt/vaultwarden
|
||||
|
||||
# Hardening
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
NoNewPrivileges=true
|
||||
ReadWritePaths=/opt/vaultwarden/data
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
mode: "0644"
|
||||
|
||||
- name: Reload systemd daemon
|
||||
systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Enable and start Vaultwarden
|
||||
service:
|
||||
name: vaultwarden
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Wait for Vaultwarden to be ready
|
||||
wait_for:
|
||||
port: 8081
|
||||
host: 127.0.0.1
|
||||
delay: 3
|
||||
timeout: 30
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
# Vaultwarden binary installation from GitHub releases
|
||||
|
||||
- name: Install prerequisites
|
||||
apt:
|
||||
name:
|
||||
- nginx
|
||||
- certbot
|
||||
- python3-certbot-nginx
|
||||
- libssl-dev
|
||||
- pkg-config
|
||||
- curl
|
||||
- ca-certificates
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create vaultwarden system user
|
||||
user:
|
||||
name: vaultwarden
|
||||
system: true
|
||||
shell: /usr/sbin/nologin
|
||||
home: /opt/vaultwarden
|
||||
create_home: false
|
||||
|
||||
- name: Create Vaultwarden directories
|
||||
file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: vaultwarden
|
||||
group: vaultwarden
|
||||
mode: "0750"
|
||||
loop:
|
||||
- /opt/vaultwarden
|
||||
- /opt/vaultwarden/data
|
||||
|
||||
- name: Detect system architecture
|
||||
command: uname -m
|
||||
register: system_arch
|
||||
changed_when: false
|
||||
|
||||
- name: Set architecture for download
|
||||
set_fact:
|
||||
vw_arch: "{{ 'aarch64' if system_arch.stdout == 'aarch64' else 'x86_64' }}"
|
||||
|
||||
- name: Get latest Vaultwarden release tag
|
||||
uri:
|
||||
url: https://api.github.com/repos/dani-garcia/vaultwarden/releases/latest
|
||||
return_content: true
|
||||
headers:
|
||||
Accept: application/vnd.github+json
|
||||
register: vw_release
|
||||
|
||||
- name: Set Vaultwarden version
|
||||
set_fact:
|
||||
vw_version: "{{ vw_release.json.tag_name }}"
|
||||
|
||||
- name: Download Vaultwarden binary
|
||||
get_url:
|
||||
url: "https://github.com/dani-garcia/vaultwarden/releases/download/{{ vw_version }}/vaultwarden-{{ vw_version }}-linux-{{ vw_arch }}.tar.gz"
|
||||
dest: "/tmp/vaultwarden-{{ vw_version }}.tar.gz"
|
||||
mode: "0644"
|
||||
|
||||
- name: Extract Vaultwarden binary
|
||||
unarchive:
|
||||
src: "/tmp/vaultwarden-{{ vw_version }}.tar.gz"
|
||||
dest: /opt/vaultwarden/
|
||||
remote_src: true
|
||||
owner: vaultwarden
|
||||
group: vaultwarden
|
||||
|
||||
- name: Ensure binary is executable
|
||||
file:
|
||||
path: /opt/vaultwarden/vaultwarden
|
||||
mode: "0755"
|
||||
owner: vaultwarden
|
||||
group: vaultwarden
|
||||
|
||||
- name: Download Vaultwarden web vault
|
||||
get_url:
|
||||
url: "https://github.com/dani-garcia/bw_web_builds/releases/latest/download/bw_web_v2024.6.2c.tar.gz"
|
||||
dest: /tmp/bw_web_vault.tar.gz
|
||||
mode: "0644"
|
||||
failed_when: false
|
||||
|
||||
- name: Extract web vault
|
||||
unarchive:
|
||||
src: /tmp/bw_web_vault.tar.gz
|
||||
dest: /opt/vaultwarden/
|
||||
remote_src: true
|
||||
owner: vaultwarden
|
||||
group: vaultwarden
|
||||
failed_when: false
|
||||
|
||||
- name: Allow HTTP/HTTPS through UFW
|
||||
ufw:
|
||||
rule: allow
|
||||
port: "{{ item }}"
|
||||
proto: tcp
|
||||
loop:
|
||||
- "80"
|
||||
- "443"
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
# Nginx reverse proxy for Vaultwarden
|
||||
|
||||
- name: Create certbot webroot
|
||||
file:
|
||||
path: /var/www/certbot
|
||||
state: directory
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: "0755"
|
||||
|
||||
- name: Deploy HTTP-only nginx config (for certbot)
|
||||
copy:
|
||||
dest: /etc/nginx/sites-available/vaultwarden
|
||||
content: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ vaultwarden_domain }};
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 200 'phantom setup in progress'; add_header Content-Type text/plain; }
|
||||
}
|
||||
mode: "0644"
|
||||
notify: reload nginx
|
||||
|
||||
- name: Enable Vaultwarden nginx site
|
||||
file:
|
||||
src: /etc/nginx/sites-available/vaultwarden
|
||||
dest: /etc/nginx/sites-enabled/vaultwarden
|
||||
state: link
|
||||
notify: reload nginx
|
||||
|
||||
- name: Flush handlers to apply HTTP config
|
||||
meta: flush_handlers
|
||||
|
||||
- name: Setup TLS (certbot with self-signed fallback)
|
||||
include_tasks: ../../common/tls_setup.yml
|
||||
vars:
|
||||
_tls_domain: "{{ vaultwarden_domain }}"
|
||||
|
||||
- name: Deploy SSL nginx config
|
||||
copy:
|
||||
dest: /etc/nginx/sites-available/vaultwarden
|
||||
content: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ vaultwarden_domain }};
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name {{ vaultwarden_domain }};
|
||||
|
||||
ssl_certificate {{ _ssl_cert }};
|
||||
ssl_certificate_key {{ _ssl_key }};
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
server_tokens off;
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
|
||||
client_max_body_size 525m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /notifications/hub {
|
||||
proxy_pass http://127.0.0.1:3012;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location /notifications/hub/negotiate {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
mode: "0644"
|
||||
notify: reload nginx
|
||||
@@ -7,10 +7,11 @@
|
||||
connection: local
|
||||
gather_facts: false
|
||||
vars:
|
||||
deploy_id: "{{ deployment_id }}"
|
||||
aws_region: "{{ region | default('us-east-1') }}"
|
||||
ec2_instance_type: "{{ instance_type | default('t3.micro') }}"
|
||||
ec2_ami: "{{ ami | default('') }}"
|
||||
ec2_key_name: "phantom-{{ deploy_id }}"
|
||||
ec2_key_name: "ph-{{ deployment_id }}"
|
||||
ssh_key_path: "{{ ssh_key }}.pub"
|
||||
|
||||
environment:
|
||||
@@ -48,7 +49,7 @@
|
||||
|
||||
- name: Create security group
|
||||
amazon.aws.ec2_security_group:
|
||||
name: "phantom-{{ deploy_id }}"
|
||||
name: "ph-{{ deploy_id }}"
|
||||
description: "phantom deployment {{ deploy_id }}"
|
||||
region: "{{ aws_region }}"
|
||||
rules:
|
||||
@@ -68,7 +69,7 @@
|
||||
|
||||
- name: Launch EC2 instance
|
||||
amazon.aws.ec2_instance:
|
||||
name: "phantom-{{ deploy_id }}"
|
||||
name: "ph-{{ deploy_id }}"
|
||||
key_name: "{{ ec2_key_name }}"
|
||||
instance_type: "{{ ec2_instance_type }}"
|
||||
image_id: "{{ ec2_ami }}"
|
||||
@@ -97,6 +98,12 @@
|
||||
dest: "{{ _host_output_file }}"
|
||||
when: _host_output_file is defined
|
||||
|
||||
- name: Write instance ID for teardown
|
||||
copy:
|
||||
content: "{{ ec2_result.instances[0].instance_id }}"
|
||||
dest: "{{ _host_output_file | dirname }}/instance_id"
|
||||
when: _host_output_file is defined
|
||||
|
||||
- name: Wait for SSH
|
||||
wait_for:
|
||||
host: "{{ target_host }}"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
# Tear down a Phantom AWS EC2 instance
|
||||
# Matches c2itall cleanup pattern
|
||||
|
||||
- name: Teardown Phantom EC2 Instance
|
||||
hosts: localhost
|
||||
connection: local
|
||||
gather_facts: false
|
||||
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: "{{ aws_access_key }}"
|
||||
AWS_SECRET_ACCESS_KEY: "{{ aws_secret_key }}"
|
||||
AWS_DEFAULT_REGION: "{{ region | default('us-east-1') }}"
|
||||
|
||||
tasks:
|
||||
- name: Find instance by name tag
|
||||
amazon.aws.ec2_instance_info:
|
||||
filters:
|
||||
"tag:Name": "{{ instance_label }}"
|
||||
instance-state-name: ["running", "stopped", "pending"]
|
||||
region: "{{ region | default('us-east-1') }}"
|
||||
register: ec2_info
|
||||
|
||||
- name: Terminate instance
|
||||
amazon.aws.ec2_instance:
|
||||
instance_ids: "{{ ec2_info.instances | map(attribute='instance_id') | list }}"
|
||||
state: absent
|
||||
region: "{{ region | default('us-east-1') }}"
|
||||
when: ec2_info.instances | length > 0
|
||||
register: deletion
|
||||
ignore_errors: true
|
||||
|
||||
- name: Delete security group
|
||||
amazon.aws.ec2_security_group:
|
||||
name: "{{ instance_label }}"
|
||||
state: absent
|
||||
region: "{{ region | default('us-east-1') }}"
|
||||
ignore_errors: true
|
||||
|
||||
- name: Delete key pair
|
||||
amazon.aws.ec2_key:
|
||||
name: "{{ instance_label }}"
|
||||
state: absent
|
||||
region: "{{ region | default('us-east-1') }}"
|
||||
ignore_errors: true
|
||||
|
||||
- name: Report teardown result
|
||||
debug:
|
||||
msg: "Instance {{ instance_label }}: {{ (ec2_info.instances | length > 0) | ternary('Terminated', 'Not found') }}"
|
||||
@@ -7,11 +7,12 @@
|
||||
connection: local
|
||||
gather_facts: false
|
||||
vars:
|
||||
deploy_id: "{{ deployment_id }}"
|
||||
linode_token: "{{ api_token }}"
|
||||
linode_region: "{{ region | default('us-east') }}"
|
||||
linode_plan: "{{ plan | default('g6-nanode-1') }}"
|
||||
linode_image: "{{ image | default('linode/debian12') }}"
|
||||
linode_label: "phantom-{{ deploy_id }}"
|
||||
linode_label: "ph-{{ deployment_id }}"
|
||||
ssh_key_path: "{{ ssh_key }}.pub"
|
||||
|
||||
tasks:
|
||||
@@ -20,6 +21,10 @@
|
||||
src: "{{ ssh_key_path }}"
|
||||
register: ssh_pubkey
|
||||
|
||||
- name: Generate root password (required by API, SSH key auth used instead)
|
||||
command: openssl rand -base64 32
|
||||
register: root_pass_gen
|
||||
|
||||
- name: Create Linode instance
|
||||
uri:
|
||||
url: https://api.linode.com/v4/linode/instances
|
||||
@@ -33,6 +38,7 @@
|
||||
region: "{{ linode_region }}"
|
||||
image: "{{ linode_image }}"
|
||||
label: "{{ linode_label }}"
|
||||
root_pass: "{{ root_pass_gen.stdout }}"
|
||||
authorized_keys:
|
||||
- "{{ ssh_pubkey.content | b64decode | trim }}"
|
||||
booted: true
|
||||
@@ -58,6 +64,12 @@
|
||||
dest: "{{ _host_output_file }}"
|
||||
when: _host_output_file is defined
|
||||
|
||||
- name: Write instance ID for teardown
|
||||
copy:
|
||||
content: "{{ linode_result.json.id }}"
|
||||
dest: "{{ _host_output_file | dirname }}/instance_id"
|
||||
when: _host_output_file is defined
|
||||
|
||||
- name: Wait for SSH
|
||||
wait_for:
|
||||
host: "{{ target_host }}"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
# Tear down a Phantom Linode instance by label using raw API calls
|
||||
# No extra collections needed — uses uri module (same as provisioning)
|
||||
|
||||
- name: Teardown Phantom Linode Instance
|
||||
hosts: localhost
|
||||
connection: local
|
||||
gather_facts: false
|
||||
|
||||
tasks:
|
||||
- name: Validate required variables
|
||||
assert:
|
||||
that:
|
||||
- api_token is defined and api_token != ""
|
||||
- instance_label is defined and instance_label != ""
|
||||
fail_msg: "api_token and instance_label are required for teardown"
|
||||
|
||||
- name: List all Linode instances
|
||||
uri:
|
||||
url: https://api.linode.com/v4/linode/instances
|
||||
method: GET
|
||||
headers:
|
||||
Authorization: "Bearer {{ api_token }}"
|
||||
return_content: true
|
||||
register: linode_list
|
||||
|
||||
- name: Find instance by label
|
||||
set_fact:
|
||||
target_instance: "{{ linode_list.json.data | selectattr('label', 'equalto', instance_label) | list | first | default(None) }}"
|
||||
|
||||
- name: Delete instance
|
||||
uri:
|
||||
url: "https://api.linode.com/v4/linode/instances/{{ target_instance.id }}"
|
||||
method: DELETE
|
||||
headers:
|
||||
Authorization: "Bearer {{ api_token }}"
|
||||
status_code: 200
|
||||
when: target_instance is not none and target_instance != None
|
||||
register: deletion
|
||||
|
||||
- name: Report result
|
||||
debug:
|
||||
msg: "{{ (target_instance is not none and target_instance != None) | ternary('Instance ' ~ instance_label ~ ' (ID ' ~ target_instance.id | default('?') ~ ') deleted', 'No instance found with label ' ~ instance_label) }}"
|
||||
@@ -0,0 +1,617 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phantom test runner — exercises all modules, playbooks, and deployment flows.
|
||||
|
||||
Runs as a simulated user through every service type without actual deployment.
|
||||
Tests: imports, module configs, playbook syntax, deploy pipeline (dry run).
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Setup path
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
PLAYBOOKS_DIR = BASE_DIR / "playbooks"
|
||||
|
||||
|
||||
# ─── Colors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
GREEN = "\033[38;5;49m"
|
||||
RED = "\033[38;5;196m"
|
||||
CYAN = "\033[38;5;51m"
|
||||
YELLOW = "\033[38;5;214m"
|
||||
GREY = "\033[38;5;244m"
|
||||
MAGENTA = "\033[38;5;201m"
|
||||
|
||||
|
||||
def header(msg):
|
||||
print(f"\n{CYAN}{'═' * 60}{RESET}")
|
||||
print(f"{MAGENTA} {msg}{RESET}")
|
||||
print(f"{CYAN}{'═' * 60}{RESET}")
|
||||
|
||||
|
||||
def passed(msg):
|
||||
print(f" {GREEN}✓{RESET} {msg}")
|
||||
|
||||
|
||||
def failed(msg):
|
||||
print(f" {RED}✗{RESET} {msg}")
|
||||
|
||||
|
||||
def skipped(msg):
|
||||
print(f" {YELLOW}○{RESET} {msg}")
|
||||
|
||||
|
||||
# ─── Test 1: Module Imports ──────────────────────────────────────────────────
|
||||
|
||||
def test_imports():
|
||||
header("Test 1: Module Imports")
|
||||
modules = ["matrix", "vpn", "dns", "cloud", "vault", "media", "email", "all_in_one"]
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
for mod_name in modules:
|
||||
try:
|
||||
mod = importlib.import_module(f"modules.{mod_name}")
|
||||
if hasattr(mod, "gather_config"):
|
||||
passed(f"modules.{mod_name} — imported, gather_config() present")
|
||||
else:
|
||||
failed(f"modules.{mod_name} — imported but no gather_config()")
|
||||
results["fail"] += 1
|
||||
continue
|
||||
results["pass"] += 1
|
||||
except Exception as e:
|
||||
failed(f"modules.{mod_name} — import failed: {e}")
|
||||
results["fail"] += 1
|
||||
|
||||
# Import phantom itself
|
||||
try:
|
||||
import phantom
|
||||
for fn in ["generate_id", "instance_label", "generate_ssh_key",
|
||||
"run_playbook", "deploy", "save_deploy_info", "main_menu",
|
||||
"manage_menu", "_migrate_ssh_keys", "_archive_deployment"]:
|
||||
assert hasattr(phantom, fn), f"missing {fn}"
|
||||
passed(f"phantom.py — imported, all core functions present")
|
||||
results["pass"] += 1
|
||||
except Exception as e:
|
||||
failed(f"phantom.py — import failed: {e}")
|
||||
results["fail"] += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Test 2: Deployment ID Generation ────────────────────────────────────────
|
||||
|
||||
def test_id_generation():
|
||||
header("Test 2: Deployment ID Generation")
|
||||
import phantom
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
ids = set()
|
||||
for _ in range(100):
|
||||
deployment_id = phantom.generate_id()
|
||||
ids.add(deployment_id)
|
||||
# verb+animal format: all lowercase, no dashes, no digits
|
||||
if not deployment_id.isalpha() or not deployment_id.islower():
|
||||
failed(f"Bad ID format: {deployment_id} (expected verbanimal, e.g. blazingwolf)")
|
||||
results["fail"] += 1
|
||||
return results
|
||||
|
||||
if len(ids) >= 50: # verb+animal combos have ~840 possibilities
|
||||
passed(f"Generated 100 IDs, {len(ids)} unique — good entropy")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"Only {len(ids)}/100 unique IDs — poor entropy")
|
||||
results["fail"] += 1
|
||||
|
||||
# Verify instance_label format
|
||||
sample_id = phantom.generate_id()
|
||||
label = phantom.instance_label(sample_id)
|
||||
if label == f"ph-{sample_id}":
|
||||
passed(f"Instance label format correct: {label}")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"Bad label: {label} (expected ph-{sample_id})")
|
||||
results["fail"] += 1
|
||||
|
||||
# Sample output
|
||||
sample = list(ids)[:5]
|
||||
for s in sample:
|
||||
print(f" {GREY}sample: {s} → ph-{s}{RESET}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Test 3: Ansible Playbook Syntax ─────────────────────────────────────────
|
||||
|
||||
def test_playbook_syntax():
|
||||
header("Test 3: Ansible Playbook Syntax Check")
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
playbooks = [
|
||||
PLAYBOOKS_DIR / "common/base_hardening.yml",
|
||||
PLAYBOOKS_DIR / "matrix/main.yml",
|
||||
PLAYBOOKS_DIR / "vpn/main.yml",
|
||||
PLAYBOOKS_DIR / "dns/main.yml",
|
||||
PLAYBOOKS_DIR / "cloud/main.yml",
|
||||
PLAYBOOKS_DIR / "vault/main.yml",
|
||||
PLAYBOOKS_DIR / "media/main.yml",
|
||||
PLAYBOOKS_DIR / "email/main.yml",
|
||||
PLAYBOOKS_DIR / "all_in_one/main.yml",
|
||||
]
|
||||
|
||||
# Also check provider playbooks
|
||||
for p in (BASE_DIR / "providers").glob("*.yml"):
|
||||
playbooks.append(p)
|
||||
|
||||
for pb in playbooks:
|
||||
rel = pb.relative_to(BASE_DIR)
|
||||
if not pb.exists():
|
||||
failed(f"{rel} — file missing")
|
||||
results["fail"] += 1
|
||||
continue
|
||||
|
||||
result = subprocess.run(
|
||||
["ansible-playbook", "--syntax-check", str(pb),
|
||||
"-i", "localhost,", "--connection", "local"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
passed(f"{rel}")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
# Show first line of error
|
||||
err_line = result.stderr.strip().split("\n")[0] if result.stderr else result.stdout.strip().split("\n")[0]
|
||||
failed(f"{rel} — {err_line}")
|
||||
results["fail"] += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Test 4: Module gather_config Flows ──────────────────────────────────────
|
||||
|
||||
def test_gather_config():
|
||||
header("Test 4: Module gather_config() — Simulated User Input")
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
# Input sequences for each module (what a user would type)
|
||||
test_inputs = {
|
||||
"matrix": [
|
||||
"matrix.test.local", # domain
|
||||
"admin", # admin user
|
||||
"testpass123", # admin password (getpass)
|
||||
"n", # registration
|
||||
"y", # element web
|
||||
],
|
||||
"vpn": [
|
||||
"", # port (default 51820)
|
||||
"3", # client configs
|
||||
"", # dns (default)
|
||||
"", # allowed IPs (default)
|
||||
"", # subnet (default)
|
||||
],
|
||||
"dns": [
|
||||
"1", # upstream DNS (Quad9)
|
||||
"", # admin domain (optional)
|
||||
"1", # blocklist (standard)
|
||||
],
|
||||
"cloud": [
|
||||
"cloud.test.local", # domain
|
||||
"admin", # admin user
|
||||
"10", # storage GB
|
||||
],
|
||||
"vault": [
|
||||
"vault.test.local", # domain
|
||||
"", # admin token (auto-generate)
|
||||
"n", # signups
|
||||
],
|
||||
"media": [
|
||||
"media.test.local", # domain
|
||||
"/srv/media", # library path
|
||||
],
|
||||
"email": [
|
||||
"mail.test.local", # domain
|
||||
"admin@test.local", # first user
|
||||
],
|
||||
}
|
||||
|
||||
for mod_name, inputs in test_inputs.items():
|
||||
try:
|
||||
mod = importlib.import_module(f"modules.{mod_name}")
|
||||
config = {"deployment_id": f"test-{mod_name}-01"}
|
||||
|
||||
# Mock input() and getpass.getpass() to feed our test inputs
|
||||
input_iter = iter(inputs)
|
||||
|
||||
def mock_input(prompt=""):
|
||||
try:
|
||||
val = next(input_iter)
|
||||
print(f" {GREY}→ {prompt.strip()[:50]} {CYAN}{val or '(default)'}{RESET}")
|
||||
return val
|
||||
except StopIteration:
|
||||
return ""
|
||||
|
||||
def mock_getpass(prompt=""):
|
||||
return mock_input(prompt)
|
||||
|
||||
with patch("builtins.input", mock_input), \
|
||||
patch("getpass.getpass", mock_getpass):
|
||||
# Some modules import getpass at module level
|
||||
if hasattr(mod, "getpass"):
|
||||
with patch.object(mod, "getpass", MagicMock(getpass=mock_getpass)):
|
||||
result = mod.gather_config(config)
|
||||
else:
|
||||
# For modules that use getpass.getpass directly
|
||||
import modules
|
||||
result = mod.gather_config(config)
|
||||
|
||||
if result is not None:
|
||||
keys = [k for k in result.keys() if k != "deployment_id"]
|
||||
passed(f"{mod_name} — config collected: {', '.join(keys[:6])}")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"{mod_name} — gather_config returned None")
|
||||
results["fail"] += 1
|
||||
|
||||
except Exception as e:
|
||||
failed(f"{mod_name} — {type(e).__name__}: {e}")
|
||||
results["fail"] += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Test 5: Deploy Pipeline (Dry Run) ──────────────────────────────────────
|
||||
|
||||
def test_deploy_pipeline():
|
||||
header("Test 5: Deploy Pipeline — Dry Run (Cancel Before Execute)")
|
||||
import phantom
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
services = ["matrix", "vpn", "dns", "cloud", "vault", "media", "email"]
|
||||
|
||||
for svc in services:
|
||||
config = {
|
||||
"deployment_id": f"dryrun-{svc}-99",
|
||||
"provider": "local",
|
||||
"target_host": "localhost",
|
||||
"ssh_user": os.getenv("USER", "root"),
|
||||
"domain": f"{svc}.test.local",
|
||||
}
|
||||
|
||||
# Capture deploy() output — answer "n" to the deploy confirmation
|
||||
def mock_input(prompt=""):
|
||||
return "n"
|
||||
|
||||
try:
|
||||
with patch("builtins.input", mock_input), \
|
||||
patch("sys.stdout", new_callable=StringIO) as mock_stdout:
|
||||
phantom.deploy(svc, config)
|
||||
|
||||
output = mock_stdout.getvalue()
|
||||
if "Deployment cancelled" in output and config["deployment_id"] in output:
|
||||
passed(f"{svc} — summary displayed, deploy cancelled correctly")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"{svc} — unexpected output")
|
||||
results["fail"] += 1
|
||||
|
||||
except Exception as e:
|
||||
failed(f"{svc} — {type(e).__name__}: {e}")
|
||||
results["fail"] += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Test 6: SSH Key Generation ──────────────────────────────────────────────
|
||||
|
||||
def test_ssh_keygen():
|
||||
header("Test 6: SSH Key Generation (new ~/.ssh/ path)")
|
||||
import phantom
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
test_id = "testkeygen00"
|
||||
try:
|
||||
key_path = phantom.generate_ssh_key(test_id)
|
||||
key_file = Path(key_path)
|
||||
pub_file = Path(f"{key_path}.pub")
|
||||
|
||||
# Verify key is at ~/.ssh/c2deploy_ph-{id}
|
||||
expected_path = Path.home() / ".ssh" / f"c2deploy_ph-{test_id}"
|
||||
if key_file == expected_path:
|
||||
passed(f"Key at correct path: ~/.ssh/c2deploy_ph-{test_id}")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"Key at wrong path: {key_file} (expected {expected_path})")
|
||||
results["fail"] += 1
|
||||
|
||||
if key_file.exists() and pub_file.exists():
|
||||
# Check permissions
|
||||
mode = oct(key_file.stat().st_mode)[-3:]
|
||||
if mode == "600":
|
||||
passed(f"Key generated: {key_file.name} (mode 0600)")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"Key permissions wrong: {mode} (expected 600)")
|
||||
results["fail"] += 1
|
||||
|
||||
# Verify key format
|
||||
pub_content = pub_file.read_text()
|
||||
if pub_content.startswith("ssh-rsa"):
|
||||
passed(f"RSA 4096 public key valid")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"Unexpected key format")
|
||||
results["fail"] += 1
|
||||
else:
|
||||
failed("Key files not created")
|
||||
results["fail"] += 1
|
||||
|
||||
except Exception as e:
|
||||
failed(f"Key generation failed: {e}")
|
||||
results["fail"] += 1
|
||||
finally:
|
||||
# Cleanup — remove from ~/.ssh/
|
||||
for suffix in ["", ".pub"]:
|
||||
f = Path.home() / ".ssh" / f"c2deploy_ph-{test_id}{suffix}"
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
kh = Path.home() / ".ssh" / f"c2deploy_ph-{test_id}_known_hosts"
|
||||
if kh.exists():
|
||||
kh.unlink()
|
||||
print(f" {GREY}cleaned up test keys{RESET}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Test 7: Full Interactive Flow Simulation ────────────────────────────────
|
||||
|
||||
def test_interactive_flow():
|
||||
header("Test 7: Full Menu Flow — Matrix Deploy (Simulated)")
|
||||
import phantom
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
# Simulate: select Matrix (1), Local provider (5), confirm local (y),
|
||||
# fill config, cancel deploy (n), exit (0)
|
||||
inputs = iter([
|
||||
"1", # Menu: Matrix
|
||||
"5", # Provider: Local
|
||||
"y", # Confirm local
|
||||
"matrix.test.local", # Domain
|
||||
"admin", # Admin user
|
||||
"testpass123", # Admin password
|
||||
"n", # Registration
|
||||
"y", # Element web
|
||||
"n", # Deploy? No
|
||||
"0", # Exit menu
|
||||
])
|
||||
|
||||
def mock_input(prompt=""):
|
||||
try:
|
||||
val = next(inputs)
|
||||
short_prompt = prompt.strip()[:60].replace("\033[38;5;51m", "").replace("\033[0m", "").replace("\033[38;5;201m", "").replace("\033[38;5;255m", "").replace("\033[38;5;214m", "")
|
||||
print(f" {GREY}→ {short_prompt} {CYAN}{val}{RESET}")
|
||||
return val
|
||||
except StopIteration:
|
||||
return "0"
|
||||
|
||||
def mock_getpass(prompt=""):
|
||||
return mock_input(prompt)
|
||||
|
||||
try:
|
||||
with patch("builtins.input", mock_input), \
|
||||
patch("getpass.getpass", mock_getpass), \
|
||||
patch("sys.stdout", new_callable=StringIO):
|
||||
phantom.main_menu()
|
||||
|
||||
passed("Full interactive flow completed without errors")
|
||||
results["pass"] += 1
|
||||
except SystemExit:
|
||||
passed("Full interactive flow completed (sys.exit)")
|
||||
results["pass"] += 1
|
||||
except StopIteration:
|
||||
passed("Full interactive flow completed (all inputs consumed)")
|
||||
results["pass"] += 1
|
||||
except Exception as e:
|
||||
failed(f"Interactive flow failed: {type(e).__name__}: {e}")
|
||||
results["fail"] += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Test 8: Deployment Discovery (manage_menu) ─────────────────────────────
|
||||
|
||||
def test_deployment_discovery():
|
||||
header("Test 8: Deployment Discovery")
|
||||
import phantom
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
# Create a temporary deployment info file
|
||||
test_id = "testdiscovery01"
|
||||
info_file = phantom.PHANTOM_LOGS / f"deployment_info_{test_id}.txt"
|
||||
try:
|
||||
with open(info_file, "w") as f:
|
||||
f.write("Deployment Information\n")
|
||||
f.write("=" * 50 + "\n")
|
||||
f.write(f"Deployment ID: {test_id}\n")
|
||||
f.write("Provider: linode\n")
|
||||
f.write("Deployment Type: phantom_matrix\n")
|
||||
f.write("\nConfiguration:\n")
|
||||
f.write("-" * 30 + "\n")
|
||||
f.write("domain: test.example.com\n")
|
||||
f.write("\nAccess Information:\n")
|
||||
f.write("-" * 30 + "\n")
|
||||
f.write("Instance IP: 10.0.0.99\n")
|
||||
|
||||
# Test parsing
|
||||
data = phantom._parse_deployment_info(info_file)
|
||||
checks = [
|
||||
("deployment_id", test_id),
|
||||
("deployment_type", "phantom_matrix"),
|
||||
("ip", "10.0.0.99"),
|
||||
("domain", "test.example.com"),
|
||||
]
|
||||
for field, expected in checks:
|
||||
if data.get(field) == expected:
|
||||
passed(f"Parsed {field}: {expected}")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"Parse {field}: got '{data.get(field)}', expected '{expected}'")
|
||||
results["fail"] += 1
|
||||
|
||||
except Exception as e:
|
||||
failed(f"Discovery test failed: {e}")
|
||||
results["fail"] += 1
|
||||
finally:
|
||||
if info_file.exists():
|
||||
info_file.unlink()
|
||||
print(f" {GREY}cleaned up test info file{RESET}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Test 9: Mode-Aware Log Paths ───────────────────────────────────────────
|
||||
|
||||
def test_mode_aware_paths():
|
||||
header("Test 9: Mode-Aware Log Paths")
|
||||
import phantom
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
c2_root = Path.home() / "tools" / "c2itall"
|
||||
c2_integrated = os.environ.get("C2ITALL_INTEGRATED") == "1"
|
||||
|
||||
if c2_integrated:
|
||||
expected_logs = c2_root / "logs"
|
||||
mode_desc = "c2itall integrated"
|
||||
else:
|
||||
expected_logs = phantom.BASE_DIR / "logs"
|
||||
mode_desc = "standalone"
|
||||
|
||||
if phantom.PHANTOM_LOGS == expected_logs:
|
||||
passed(f"PHANTOM_LOGS correct for {mode_desc} mode: {expected_logs}")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"PHANTOM_LOGS wrong: {phantom.PHANTOM_LOGS} (expected {expected_logs})")
|
||||
results["fail"] += 1
|
||||
|
||||
# WORK_DIR should always be local
|
||||
if phantom.WORK_DIR == phantom.BASE_DIR / "logs":
|
||||
passed(f"WORK_DIR correct: {phantom.WORK_DIR}")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed(f"WORK_DIR wrong: {phantom.WORK_DIR}")
|
||||
results["fail"] += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Test 10: Config Key Rename ──────────────────────────────────────────────
|
||||
|
||||
def test_config_key_rename():
|
||||
header("Test 10: Config Key Rename (deployment_id)")
|
||||
import phantom
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
# deploy() should use deployment_id, not deploy_id
|
||||
config = {
|
||||
"deployment_id": "testrename01",
|
||||
"provider": "local",
|
||||
"target_host": "localhost",
|
||||
"ssh_user": os.getenv("USER", "root"),
|
||||
}
|
||||
|
||||
def mock_input(prompt=""):
|
||||
return "n"
|
||||
|
||||
try:
|
||||
with patch("builtins.input", mock_input), \
|
||||
patch("sys.stdout", new_callable=StringIO) as mock_stdout:
|
||||
phantom.deploy("vpn", config)
|
||||
|
||||
output = mock_stdout.getvalue()
|
||||
if "testrename01" in output:
|
||||
passed("deploy() accepts deployment_id key")
|
||||
results["pass"] += 1
|
||||
else:
|
||||
failed("deploy() didn't use deployment_id")
|
||||
results["fail"] += 1
|
||||
|
||||
except KeyError as e:
|
||||
if "deploy_id" in str(e):
|
||||
failed(f"deploy() still looking for old 'deploy_id' key: {e}")
|
||||
else:
|
||||
failed(f"Unexpected KeyError: {e}")
|
||||
results["fail"] += 1
|
||||
except Exception as e:
|
||||
failed(f"Config key rename test failed: {e}")
|
||||
results["fail"] += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
print(f"""
|
||||
{MAGENTA} ██████╗ ██╗ ██╗ █████╗ ███╗ ██╗████████╗ ██████╗ ███╗ ███╗
|
||||
██╔══██╗██║ ██║██╔══██╗████╗ ██║╚══██╔══╝██╔═══██╗████╗ ████║
|
||||
██████╔╝███████║███████║██╔██╗ ██║ ██║ ██║ ██║██╔████╔██║
|
||||
██╔═══╝ ██╔══██║██╔══██║██║╚██╗██║ ██║ ██║ ██║██║╚██╔╝██║
|
||||
██║ ██║ ██║██║ ██║██║ ╚████║ ██║ ╚██████╔╝██║ ╚═╝ ██║
|
||||
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝{RESET}
|
||||
{CYAN} Test Suite{RESET}
|
||||
""")
|
||||
|
||||
all_results = {"pass": 0, "fail": 0}
|
||||
|
||||
tests = [
|
||||
test_imports,
|
||||
test_id_generation,
|
||||
test_playbook_syntax,
|
||||
test_gather_config,
|
||||
test_deploy_pipeline,
|
||||
test_ssh_keygen,
|
||||
test_interactive_flow,
|
||||
test_deployment_discovery,
|
||||
test_mode_aware_paths,
|
||||
test_config_key_rename,
|
||||
]
|
||||
|
||||
for test_fn in tests:
|
||||
try:
|
||||
r = test_fn()
|
||||
all_results["pass"] += r["pass"]
|
||||
all_results["fail"] += r["fail"]
|
||||
except Exception as e:
|
||||
print(f"\n {RED}✗ {test_fn.__name__} crashed: {e}{RESET}")
|
||||
all_results["fail"] += 1
|
||||
|
||||
# Summary
|
||||
total = all_results["pass"] + all_results["fail"]
|
||||
header("Results")
|
||||
print(f" {GREEN}Passed: {all_results['pass']}{RESET}")
|
||||
if all_results["fail"]:
|
||||
print(f" {RED}Failed: {all_results['fail']}{RESET}")
|
||||
else:
|
||||
print(f" {GREY}Failed: 0{RESET}")
|
||||
print(f" {CYAN}Total: {total}{RESET}")
|
||||
|
||||
if all_results["fail"] == 0:
|
||||
print(f"\n {GREEN}{BOLD}ALL TESTS PASSED{RESET}\n")
|
||||
else:
|
||||
print(f"\n {RED}{BOLD}{all_results['fail']} TEST(S) FAILED{RESET}\n")
|
||||
|
||||
return 0 if all_results["fail"] == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user