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:
n0mad1k
2026-03-09 15:45:24 -04:00
parent 973cede31f
commit 7484a0e034
44 changed files with 3167 additions and 381 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+15
View File
@@ -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
View File
@@ -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
+4 -16
View File
@@ -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
+2 -2
View File
@@ -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): "
+2 -2
View File
@@ -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): "
+2
View File
@@ -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
+2 -2
View File
@@ -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): "
+2 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+80 -5
View File
@@ -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
+37 -6
View File
@@ -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
+180
View File
@@ -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 }}"
+96
View File
@@ -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"
+109
View File
@@ -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
+71 -9
View File
@@ -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 "============================================"
+59
View File
@@ -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"
+7 -1
View File
@@ -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
+54 -54
View File
@@ -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"
+59 -33
View File
@@ -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"
+13 -6
View File
@@ -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
+56
View File
@@ -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
+12
View File
@@ -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:
+64 -41
View File
@@ -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"
+43 -39
View File
@@ -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
+26 -6
View File
@@ -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
+41
View File
@@ -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"
+91
View File
@@ -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
+27 -6
View File
@@ -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
+101
View File
@@ -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"
+96
View File
@@ -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
+10 -3
View File
@@ -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 }}"
+49
View File
@@ -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') }}"
+13 -1
View File
@@ -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 }}"
+43
View File
@@ -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) }}"
+617
View File
@@ -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())