From 7484a0e03487acc0afebc7eacbc945ef17cc7caa Mon Sep 17 00:00:00 2001 From: n0mad1k Date: Mon, 9 Mar 2026 15:45:24 -0400 Subject: [PATCH] Phantom v2: dual-mode architecture, security hardening, deployment management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- LICENSE | 2 +- covert_sd/README.md | 2 +- opsec/configs/opsec.conf | 21 +- phantom/.env.example | 15 + phantom/README.md | 14 +- phantom/modules/all_in_one.py | 20 +- phantom/modules/cloud.py | 4 +- phantom/modules/email.py | 4 +- phantom/modules/matrix.py | 2 + phantom/modules/media.py | 4 +- phantom/modules/vault.py | 4 +- phantom/phantom.py | 839 +++++++++++++++--- phantom/playbooks/all_in_one/main.yml | 85 +- phantom/playbooks/cloud/main.yml | 43 +- phantom/playbooks/cloud/tasks/configure.yml | 180 ++++ phantom/playbooks/cloud/tasks/install.yml | 96 ++ phantom/playbooks/cloud/tasks/nginx.yml | 109 +++ phantom/playbooks/common/base_hardening.yml | 80 +- .../common/templates/add-matrix-user.sh.j2 | 17 + .../common/templates/setup-cert.sh.j2 | 120 +++ phantom/playbooks/common/tls_setup.yml | 59 ++ phantom/playbooks/dns/main.yml | 8 +- phantom/playbooks/dns/tasks/configure.yml | 108 +-- phantom/playbooks/dns/tasks/install.yml | 92 +- phantom/playbooks/email/main.yml | 19 +- phantom/playbooks/email/tasks/configure.yml | 45 + phantom/playbooks/email/tasks/install.yml | 56 ++ phantom/playbooks/matrix/main.yml | 12 + phantom/playbooks/matrix/tasks/nginx.yml | 105 ++- phantom/playbooks/matrix/tasks/synapse.yml | 82 +- .../matrix/templates/homeserver.yaml.j2 | 65 +- phantom/playbooks/media/main.yml | 32 +- phantom/playbooks/media/tasks/configure.yml | 33 + phantom/playbooks/media/tasks/install.yml | 41 + phantom/playbooks/media/tasks/nginx.yml | 91 ++ phantom/playbooks/vault/main.yml | 33 +- phantom/playbooks/vault/tasks/configure.yml | 73 ++ phantom/playbooks/vault/tasks/install.yml | 101 +++ phantom/playbooks/vault/tasks/nginx.yml | 96 ++ phantom/providers/aws.yml | 13 +- phantom/providers/aws_cleanup.yml | 49 + phantom/providers/linode.yml | 14 +- phantom/providers/linode_cleanup.yml | 43 + phantom/test_phantom.py | 617 +++++++++++++ 44 files changed, 3167 insertions(+), 381 deletions(-) create mode 100644 phantom/.env.example create mode 100644 phantom/playbooks/cloud/tasks/configure.yml create mode 100644 phantom/playbooks/cloud/tasks/install.yml create mode 100644 phantom/playbooks/cloud/tasks/nginx.yml create mode 100644 phantom/playbooks/common/templates/add-matrix-user.sh.j2 create mode 100644 phantom/playbooks/common/templates/setup-cert.sh.j2 create mode 100644 phantom/playbooks/common/tls_setup.yml create mode 100644 phantom/playbooks/email/tasks/configure.yml create mode 100644 phantom/playbooks/email/tasks/install.yml create mode 100644 phantom/playbooks/media/tasks/configure.yml create mode 100644 phantom/playbooks/media/tasks/install.yml create mode 100644 phantom/playbooks/media/tasks/nginx.yml create mode 100644 phantom/playbooks/vault/tasks/configure.yml create mode 100644 phantom/playbooks/vault/tasks/install.yml create mode 100644 phantom/playbooks/vault/tasks/nginx.yml create mode 100644 phantom/providers/aws_cleanup.yml create mode 100644 phantom/providers/linode_cleanup.yml create mode 100644 phantom/test_phantom.py diff --git a/LICENSE b/LICENSE index a903e33..42d90d8 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/covert_sd/README.md b/covert_sd/README.md index d3f4623..be23b0e 100644 --- a/covert_sd/README.md +++ b/covert_sd/README.md @@ -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 ``` diff --git a/opsec/configs/opsec.conf b/opsec/configs/opsec.conf index 695422d..ceeaf76 100644 --- a/opsec/configs/opsec.conf +++ b/opsec/configs/opsec.conf @@ -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" diff --git a/phantom/.env.example b/phantom/.env.example new file mode 100644 index 0000000..6f0ab91 --- /dev/null +++ b/phantom/.env.example @@ -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 diff --git a/phantom/README.md b/phantom/README.md index 666f3fa..7430fb6 100644 --- a/phantom/README.md +++ b/phantom/README.md @@ -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 diff --git a/phantom/modules/all_in_one.py b/phantom/modules/all_in_one.py index 6d11ace..e1e8541 100644 --- a/phantom/modules/all_in_one.py +++ b/phantom/modules/all_in_one.py @@ -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 diff --git a/phantom/modules/cloud.py b/phantom/modules/cloud.py index 8b47f3b..07ca936 100644 --- a/phantom/modules/cloud.py +++ b/phantom/modules/cloud.py @@ -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): " diff --git a/phantom/modules/email.py b/phantom/modules/email.py index 0e06b44..f6a9cb1 100644 --- a/phantom/modules/email.py +++ b/phantom/modules/email.py @@ -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): " diff --git a/phantom/modules/matrix.py b/phantom/modules/matrix.py index 0302df4..4762f50 100644 --- a/phantom/modules/matrix.py +++ b/phantom/modules/matrix.py @@ -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 diff --git a/phantom/modules/media.py b/phantom/modules/media.py index 53057b1..d73f522 100644 --- a/phantom/modules/media.py +++ b/phantom/modules/media.py @@ -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): " diff --git a/phantom/modules/vault.py b/phantom/modules/vault.py index 41c8738..dadb575 100644 --- a/phantom/modules/vault.py +++ b/phantom/modules/vault.py @@ -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): " diff --git a/phantom/phantom.py b/phantom/phantom.py index 1e3431c..2639c19 100755 --- a/phantom/phantom.py +++ b/phantom/phantom.py @@ -10,6 +10,7 @@ Providers: Linode, AWS, FlokiNET, or local/existing server. import json import os import random +import shutil import subprocess import sys import time @@ -18,11 +19,33 @@ from pathlib import Path # Allow modules to import from phantom sys.path.insert(0, os.path.dirname(__file__)) +# Kill stale bytecode cache on every run +for _d in [Path(__file__).resolve().parent / "__pycache__", + Path(__file__).resolve().parent / "modules" / "__pycache__"]: + if _d.exists(): + shutil.rmtree(_d, ignore_errors=True) + BASE_DIR = Path(__file__).resolve().parent PLAYBOOKS_DIR = BASE_DIR / "playbooks" PROVIDERS_DIR = BASE_DIR / "providers" -LOGS_DIR = BASE_DIR / "logs" -LOGS_DIR.mkdir(exist_ok=True) + +# Working artifacts (vars.yaml, inventory, deploy_info.json) — always local +WORK_DIR = BASE_DIR / "logs" +WORK_DIR.mkdir(exist_ok=True) + +# Mode-aware log directory: only use c2itall logs when launched FROM c2itall +# c2itall sets C2ITALL_INTEGRATED=1 when invoking phantom through its menu +C2_ROOT = Path.home() / "tools" / "c2itall" +C2_INTEGRATED = os.environ.get("C2ITALL_INTEGRATED") == "1" + +if C2_INTEGRATED: + PHANTOM_LOGS = C2_ROOT / "logs" # central c2itall logs +else: + PHANTOM_LOGS = BASE_DIR / "logs" # standalone phantom logs + +PHANTOM_LOGS.mkdir(parents=True, exist_ok=True) + +SSH_DIR = Path.home() / ".ssh" # ─── Color Helpers ────────────────────────────────────────────────────────── @@ -72,56 +95,125 @@ def banner(): # ─── Deployment ID Generation ─────────────────────────────────────────────── +# Uses c2itall's verb+animal naming convention for consistency -ADJECTIVES = [ - "silent", "hidden", "shadow", "quiet", "swift", "dark", "fading", "lost", - "frozen", "drifting", "hollow", "veiled", "pale", "deep", "still", "wild", - "broken", "burning", "crimson", "golden", "silver", "iron", "copper", - "cobalt", "jade", "amber", "arctic", "lunar", "solar", "astral", "coral", - "misty", "foggy", "dusty", "rusty", "mossy", "stormy", "cloudy", "windy", - "gentle", "fierce", "steady", "rapid", "lazy", "bold", "brave", "calm", - "clever", "cryptic", "cunning", "eager", "faint", "grave", "keen", "noble", - "prime", "rare", "stark", "terse", "vivid", "wary", "zealous", "agile", - "blunt", "coarse", "dense", "eerie", "fleet", "gaunt", "harsh", "lucid", - "muted", "numb", "opaque", "plain", "rigid", "sleek", "taut", "urban", - "vacant", "woven", "binary", "cipher", "delta", "echo", "foxtrot", "gamma", - "hex", "index", "kilo", "lambda", "micro", "nano", "omega", "proxy", - "quantum", "rogue", "sigma", "theta", "ultra", "vector", "xray", "zero", -] - -NOUNS = [ - "phantom", "spectre", "wraith", "shade", "ghost", "echo", "void", "rift", - "nexus", "pulse", "signal", "cipher", "prism", "beacon", "aegis", "bastion", - "citadel", "forge", "haven", "vault", "harbor", "summit", "ridge", "canyon", - "glacier", "tundra", "steppe", "mesa", "delta", "fjord", "grove", "marsh", - "oasis", "reef", "shoal", "brook", "creek", "falls", "rapids", "spring", - "falcon", "raven", "hawk", "condor", "osprey", "heron", "crane", "wren", - "finch", "swift", "sparrow", "robin", "wolf", "fox", "lynx", "panther", - "tiger", "cobra", "viper", "mantis", "hornet", "spider", "scorpion", - "anchor", "arrow", "blade", "bolt", "chain", "crown", "flint", "glyph", - "helm", "ingot", "jewel", "knot", "lance", "mast", "oar", "pike", - "quill", "rune", "shard", "thorn", "urn", "wand", "atlas", "core", - "dusk", "ember", "frost", "gale", "haze", "iris", "jade", "karma", - "lumen", "myth", "nova", "orbit", "pixel", "quest", "relay", "sage", - "trace", "unity", "vertex", "zenith", -] - +INSTANCE_PREFIX = "ph" # phantom privacy server prefix def generate_id(): - """Generate a deployment ID with 10k+ unique combinations.""" - adj = random.choice(ADJECTIVES) - noun = random.choice(NOUNS) - num = random.randint(10, 99) - return f"{adj}-{noun}-{num}" + """Generate a deployment ID using verb+animal format (matches c2itall).""" + # Try to import c2itall's name generator + c2_gen = Path.home() / "tools" / "c2itall" / "utils" / "name_generator.py" + if c2_gen.exists(): + try: + import importlib.util + spec = importlib.util.spec_from_file_location("name_generator", c2_gen) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.generate_deployment_id() + except Exception: + pass + + # Fallback — same word lists as c2itall + verbs = [ + "blazing", "soaring", "charging", "prowling", "hunting", "stalking", + "striking", "rushing", "dashing", "racing", "flying", "diving", + "leaping", "climbing", "sliding", "spinning", "rolling", "sneaking", + "roaming", "wandering", "running", "jumping", "swimming", "crawling", + "fighting", "defending", "attacking", "scanning", "searching", "finding", + ] + animals = [ + "wolf", "eagle", "tiger", "falcon", "bear", "lion", "shark", "hawk", + "panther", "cobra", "viper", "rhino", "bull", "fox", "raven", "crow", + "spider", "scorpion", "mantis", "dragon", "phoenix", "griffin", + "badger", "wolverine", "lynx", "jaguar", "cheetah", "leopard", + ] + return f"{random.choice(verbs)}{random.choice(animals)}" + + +def instance_label(deployment_id): + """Generate cloud instance label: ph-{deployment_id}.""" + return f"{INSTANCE_PREFIX}-{deployment_id}" + + +# ─── SSH Key Paths ───────────────────────────────────────────────────────── + +def _ssh_key_path(deployment_id): + """Return the standard SSH key path for a deployment.""" + return SSH_DIR / f"c2deploy_ph-{deployment_id}" + + +def _ssh_known_hosts_path(deployment_id): + """Return the per-deployment known_hosts path.""" + return SSH_DIR / f"c2deploy_ph-{deployment_id}_known_hosts" + + +# ─── SSH Key Migration ───────────────────────────────────────────────────── + +def _migrate_ssh_keys(): + """Migrate SSH keys from old phantom/logs/{id}/ to ~/.ssh/c2deploy_ph-{id}. + + Scans WORK_DIR for deploy_info.json files with old-style key paths. + If found and no key at the new path, copies over and updates the JSON. + """ + migrated = [] + for json_file in WORK_DIR.glob("*/deploy_info.json"): + try: + with open(json_file) as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + continue + + dep_id = data.get("deployment_id") or data.get("deploy_id") + old_key = data.get("ssh_key", "") + if not dep_id or not old_key: + continue + + old_key_path = Path(old_key) + new_key_path = _ssh_key_path(dep_id) + + # Skip if key is already at new location + if str(old_key_path) == str(new_key_path): + continue + + # Skip if old key doesn't exist + if not old_key_path.exists(): + continue + + # Skip if new key already exists + if new_key_path.exists(): + continue + + # Copy key pair to new location + shutil.copy2(old_key_path, new_key_path) + os.chmod(new_key_path, 0o600) + old_pub = Path(f"{old_key}.pub") + new_pub = Path(f"{new_key_path}.pub") + if old_pub.exists(): + shutil.copy2(old_pub, new_pub) + + # Update JSON config in-place + data["ssh_key"] = str(new_key_path) + # Migrate deploy_id → deployment_id while we're here + if "deploy_id" in data and "deployment_id" not in data: + data["deployment_id"] = data.pop("deploy_id") + with open(json_file, "w") as f: + json.dump(data, f, indent=2, default=str) + os.chmod(json_file, 0o600) + + migrated.append(dep_id) + + if migrated: + warn(f"Migrated SSH keys for {len(migrated)} deployment(s) to ~/.ssh/:") + for dep_id in migrated: + dim(f"c2deploy_ph-{dep_id}") # ─── SSH Key Generation ───────────────────────────────────────────────────── -def generate_ssh_key(deploy_id): - """Generate an RSA 4096 SSH keypair for deployment.""" - key_dir = LOGS_DIR / deploy_id - key_dir.mkdir(parents=True, exist_ok=True) - key_path = key_dir / f"deploy_{deploy_id}" +def generate_ssh_key(deployment_id): + """Generate an RSA 4096 SSH keypair at ~/.ssh/c2deploy_ph-{id}.""" + SSH_DIR.mkdir(mode=0o700, exist_ok=True) + key_path = _ssh_key_path(deployment_id) if key_path.exists(): info(f"SSH key already exists: {key_path}") @@ -130,7 +222,7 @@ def generate_ssh_key(deploy_id): info(f"Generating SSH key: {key_path}") subprocess.run( ["ssh-keygen", "-t", "rsa", "-b", "4096", "-f", str(key_path), - "-N", "", "-C", f"deploy-{deploy_id}"], + "-N", "", "-C", f"c2deploy-ph-{deployment_id}"], check=True, capture_output=True, ) os.chmod(key_path, 0o600) @@ -140,8 +232,12 @@ def generate_ssh_key(deploy_id): # ─── Ansible Playbook Execution ───────────────────────────────────────────── +# Lines to show on console during ansible streaming +_ANSIBLE_SHOW = ("TASK [", "PLAY [", "ok:", "changed:", "failed:", "fatal:", "PLAY RECAP") + + def run_playbook(playbook_path, config, extra_vars=None): - """Execute an Ansible playbook with the given config. + """Execute an Ansible playbook with streaming output and log capture. Args: playbook_path: Path object or string to the playbook file. @@ -156,10 +252,11 @@ def run_playbook(playbook_path, config, extra_vars=None): err(f"Playbook not found: {playbook_path}") return False + deployment_id = config["deployment_id"] cmd = ["ansible-playbook", str(playbook_path)] - # Build vars file - vars_file = LOGS_DIR / config["deploy_id"] / "vars.yaml" + # Build vars file in work dir + vars_file = WORK_DIR / deployment_id / "vars.yaml" vars_file.parent.mkdir(parents=True, exist_ok=True) try: @@ -182,13 +279,20 @@ def run_playbook(playbook_path, config, extra_vars=None): if target == "localhost": cmd.extend(["-i", "localhost,", "--connection", "local"]) else: - inv_file = LOGS_DIR / config["deploy_id"] / "inventory" + inv_file = WORK_DIR / deployment_id / "inventory" + ssh_key = config.get("ssh_key", "") + ssh_user = config.get("ssh_user", "root") + known_hosts = str(_ssh_known_hosts_path(deployment_id)) + + ssh_args = ( + f"-o StrictHostKeyChecking=accept-new " + f"-o IdentitiesOnly=yes " + f"-o UserKnownHostsFile={known_hosts}" + ) + line = f"{target} ansible_user={ssh_user} ansible_ssh_common_args='{ssh_args}'" + if ssh_key: + line += f" ansible_ssh_private_key_file={ssh_key}" with open(inv_file, "w") as f: - ssh_key = config.get("ssh_key", "") - ssh_user = config.get("ssh_user", "root") - line = f"{target} ansible_user={ssh_user}" - if ssh_key: - line += f" ansible_ssh_private_key_file={ssh_key}" f.write(f"[servers]\n{line}\n") cmd.extend(["-i", str(inv_file)]) @@ -196,25 +300,155 @@ def run_playbook(playbook_path, config, extra_vars=None): if ssh_user != "root": cmd.append("--become") - info(f"Running: {' '.join(cmd)}") - result = subprocess.run(cmd) - return result.returncode == 0 + # Log file for full ansible output + log_file = PHANTOM_LOGS / f"deployment_{deployment_id}.log" + + info(f"Running: {playbook_path.name}") + dim(f"Log: {log_file}") + + # Stream output — show filtered lines on console, full output to log + try: + with open(log_file, "a") as lf: + lf.write(f"\n{'=' * 60}\n") + lf.write(f"Playbook: {playbook_path}\n") + lf.write(f"Time: {time.strftime('%Y-%m-%dT%H:%M:%S%z')}\n") + lf.write(f"Command: {' '.join(cmd)}\n") + lf.write(f"{'=' * 60}\n\n") + + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + for line in proc.stdout: + lf.write(line) + stripped = line.strip() + if stripped and any(stripped.startswith(prefix) for prefix in _ANSIBLE_SHOW): + print(f" {GREY}{stripped}{RESET}") + proc.wait() + + lf.write(f"\nExit code: {proc.returncode}\n") + + if proc.returncode != 0: + err(f"Playbook failed (rc={proc.returncode})") + warn(f"Full log: {log_file}") + return proc.returncode == 0 + + except Exception as e: + err(f"Playbook execution error: {e}") + return False # ─── Deployment Info Logging ──────────────────────────────────────────────── def save_deploy_info(config): - """Save deployment metadata to logs.""" - deploy_dir = LOGS_DIR / config["deploy_id"] - deploy_dir.mkdir(parents=True, exist_ok=True) - info_file = deploy_dir / "deploy_info.json" + """Save deployment info — mode-aware output location. + Saves: + PHANTOM_LOGS/deployment_info_{id}.txt — human-readable info + creds + WORK_DIR/{id}/deploy_info.json — local JSON backup + """ + deployment_id = config["deployment_id"] + deploy_dir = WORK_DIR / deployment_id + deploy_dir.mkdir(parents=True, exist_ok=True) config["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%S%z") - with open(info_file, "w") as f: + + target = config.get("target_host", "localhost") + ssh_key = config.get("ssh_key", "") + ssh_user = config.get("ssh_user", "root") + label = instance_label(deployment_id) + known_hosts = str(_ssh_known_hosts_path(deployment_id)) + + # Build SSH command + ssh_parts = ["ssh", "-o", "StrictHostKeyChecking=accept-new", + "-o", "IdentitiesOnly=yes", + "-o", f"UserKnownHostsFile={known_hosts}"] + if ssh_key: + ssh_parts.extend(["-i", ssh_key]) + ssh_parts.append(f"{ssh_user}@{target}") + ssh_cmd = " ".join(ssh_parts) + + # ── Deployment info file (written to PHANTOM_LOGS) ──────────────────── + info_file = PHANTOM_LOGS / f"deployment_info_{deployment_id}.txt" + _write_info_file(info_file, config, deployment_id, label, target, ssh_key, + ssh_user, ssh_cmd) + os.chmod(info_file, 0o600) + + # ── JSON config (local backup in work dir) ──────────────────────────── + json_file = deploy_dir / "deploy_info.json" + with open(json_file, "w") as f: json.dump(config, f, indent=2, default=str) + os.chmod(json_file, 0o600) + + # ── Terminal summary ────────────────────────────────────────────────── + print(f"\n{CYAN}{'═' * 60}{RESET}") + print(f"{GREEN} Phantom Privacy Server — Deployed!{RESET}") + print(f"{CYAN}{'═' * 60}{RESET}") + print(f" {WHITE}Instance:{RESET} {label}") + print(f" {WHITE}IP:{RESET} {target}") + if config.get("domain"): + print(f" {WHITE}Domain:{RESET} {config['domain']}") + print(f" {WHITE}SSH:{RESET} {ssh_cmd}") + if config.get("matrix_admin_user"): + print(f" {WHITE}Admin:{RESET} {config['matrix_admin_user']}") + if config.get("matrix_admin_password"): + print(f" {WHITE}Password:{RESET} {config['matrix_admin_password']}") + if config.get("cloud_admin_user"): + print(f" {WHITE}Admin:{RESET} {config['cloud_admin_user']}") + if config.get("vault_admin_token"): + print(f" {WHITE}Token:{RESET} {config['vault_admin_token']}") + print(f"\n {CYAN}Credentials saved to:{RESET} {info_file}") + if C2_INTEGRATED: + dim("(c2itall integrated mode)") + print(f"{CYAN}{'═' * 60}{RESET}") + ok(f"Deployment info saved: {info_file}") +def _write_info_file(info_file, config, deployment_id, label, target, ssh_key, + ssh_user, ssh_cmd): + """Write deployment info in c2itall's standard format.""" + with open(info_file, "w") as f: + f.write(f"Deployment Information\n") + f.write(f"{'=' * 50}\n") + f.write(f"Deployment ID: {deployment_id}\n") + f.write(f"Provider: {config.get('provider', 'unknown')}\n") + f.write(f"Deployment Type: phantom_{config.get('server_type', 'unknown')}\n") + f.write(f"\n") + f.write(f"Configuration:\n") + f.write(f"{'-' * 30}\n") + f.write(f"provider: {config.get('provider', 'unknown')}\n") + f.write(f"deployment_id: {deployment_id}\n") + f.write(f"deployment_type: phantom_{config.get('server_type', 'unknown')}\n") + f.write(f"instance_name: {label}\n") + if config.get("domain"): + f.write(f"domain: {config['domain']}\n") + if config.get("region"): + f.write(f"linode_region: {config['region']}\n") + if ssh_key: + f.write(f"ssh_key_path: {ssh_key}\n") + f.write(f"\n") + f.write(f"Access Information:\n") + f.write(f"{'-' * 30}\n") + f.write(f"Instance IP: {target}\n") + if ssh_key: + f.write(f"SSH Key: {ssh_key}\n") + f.write(f"SSH Command: {ssh_cmd}\n") + if config.get("matrix_admin_user"): + f.write(f"Matrix Admin: {config['matrix_admin_user']}\n") + if config.get("matrix_admin_password"): + f.write(f"Matrix Password: {config['matrix_admin_password']}\n") + if config.get("cloud_admin_user"): + f.write(f"Nextcloud Admin: {config['cloud_admin_user']}\n") + if config.get("vault_admin_token"): + f.write(f"Vaultwarden Token: {config['vault_admin_token']}\n") + if config.get("email_first_user"): + f.write(f"Email User: {config['email_first_user']}\n") + f.write(f"\nGenerated at: {config['timestamp']}\n") + + # ─── SSH Connect ──────────────────────────────────────────────────────────── def ssh_connect(config): @@ -223,10 +457,14 @@ def ssh_connect(config): if not target or target == "localhost": return + deployment_id = config["deployment_id"] ssh_key = config.get("ssh_key", "") ssh_user = config.get("ssh_user", "root") + known_hosts = str(_ssh_known_hosts_path(deployment_id)) - cmd = ["ssh"] + cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new", + "-o", f"UserKnownHostsFile={known_hosts}", + "-o", "IdentitiesOnly=yes"] if ssh_key: cmd.extend(["-i", ssh_key]) cmd.append(f"{ssh_user}@{target}") @@ -237,6 +475,95 @@ def ssh_connect(config): os.execvp("ssh", cmd) +# ─── Provider Credential Loading ─────────────────────────────────────────── + +def _load_dotenv(): + """Load phantom/.env if it exists (standalone credential storage).""" + env_file = BASE_DIR / ".env" + if not env_file.exists(): + return + with open(env_file) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'\"") + # Don't overwrite existing env vars + if key and key not in os.environ: + os.environ[key] = value + + +def _load_provider_defaults(): + """Load provider credentials from .env, env vars, or c2itall vars files. + + Priority: env vars > .env file > c2itall vars files > prompt + """ + _load_dotenv() + + defaults = {} + c2itall_base = Path.home() / "tools" / "c2itall" / "providers" + + # Linode — check env first, then c2itall vars file + linode_token = os.environ.get("LINODE_TOKEN", "") + linode_vars = c2itall_base / "Linode" / "vars.yaml" + if linode_token or linode_vars.exists(): + d = {"api_token": linode_token, + "region": os.environ.get("LINODE_REGION", "us-east"), + "plan": os.environ.get("LINODE_PLAN", "g6-nanode-1")} + if linode_vars.exists(): + try: + import yaml + with open(linode_vars) as f: + data = yaml.safe_load(f) or {} + d["api_token"] = d["api_token"] or data.get("linode_token", "") + regions = data.get("region_choices", []) + if regions: + d["region"] = regions[0] + d["plan"] = data.get("linode_instance_type", d["plan"]) + except Exception: + pass + if d["api_token"]: + defaults["linode"] = d + + # AWS — check env first, then c2itall vars file + aws_key = os.environ.get("AWS_ACCESS_KEY_ID", "") + aws_secret = os.environ.get("AWS_SECRET_ACCESS_KEY", "") + aws_vars = c2itall_base / "AWS" / "c2-vars-template.yaml" + if aws_key or aws_vars.exists(): + d = {"aws_access_key": aws_key, "aws_secret_key": aws_secret, + "region": os.environ.get("AWS_REGION", "us-east-1"), + "instance_type": os.environ.get("AWS_INSTANCE_TYPE", "t3.micro")} + if aws_vars.exists(): + try: + import yaml + with open(aws_vars) as f: + data = yaml.safe_load(f) or {} + ak = data.get("aws_access_key", "") + sk = data.get("aws_secret_key", "") + if ak and "YOUR_" not in ak: + d["aws_access_key"] = d["aws_access_key"] or ak + d["aws_secret_key"] = d["aws_secret_key"] or sk + d["region"] = data.get("region", d["region"]) + d["instance_type"] = data.get("instance_type", d["instance_type"]) + except Exception: + pass + if d["aws_access_key"]: + defaults["aws"] = d + + return defaults + + +def _mask(token): + """Mask a token for display: show first 4 and last 4 chars.""" + if not token or len(token) < 12: + return token + return token[:4] + "…" + token[-4:] + + # ─── Provider Selection ───────────────────────────────────────────────────── def select_provider(): @@ -255,29 +582,63 @@ def select_provider(): def gather_credentials(provider, config): - """Gather provider-specific credentials.""" + """Gather provider-specific credentials. + + Loads defaults from c2itall vars files or environment variables. + Press Enter at any prompt to accept the stored default. + """ + defaults = _load_provider_defaults() + if provider == "linode": config["provider"] = "linode" - config["api_token"] = input(f" {CYAN}Linode API token:{RESET} ").strip() - config["region"] = input(f" {CYAN}Region (e.g. us-east):{RESET} ").strip() or "us-east" - config["plan"] = input(f" {CYAN}Plan (e.g. g6-nanode-1):{RESET} ").strip() or "g6-nanode-1" + d = defaults.get("linode", {}) + stored_token = d.get("api_token", "") + default_region = d.get("region", "us-east") + default_plan = d.get("plan", "g6-nanode-1") + + if stored_token: + ok(f"Linode token loaded: {_mask(stored_token)}") + override = input(f" {CYAN}Use stored token? [Y/n]:{RESET} ").strip().lower() + config["api_token"] = stored_token if override != "n" else input(f" {CYAN}Linode API token:{RESET} ").strip() + else: + config["api_token"] = input(f" {CYAN}Linode API token:{RESET} ").strip() + + config["region"] = input(f" {CYAN}Region [{WHITE}{default_region}{RESET}]: ").strip() or default_region + config["plan"] = input(f" {CYAN}Plan [{WHITE}{default_plan}{RESET}]: ").strip() or default_plan elif provider == "aws": config["provider"] = "aws" - config["aws_access_key"] = input(f" {CYAN}AWS Access Key ID:{RESET} ").strip() - config["aws_secret_key"] = input(f" {CYAN}AWS Secret Access Key:{RESET} ").strip() - config["region"] = input(f" {CYAN}Region (e.g. us-east-1):{RESET} ").strip() or "us-east-1" - config["instance_type"] = input(f" {CYAN}Instance type (e.g. t3.micro):{RESET} ").strip() or "t3.micro" + d = defaults.get("aws", {}) + stored_key = d.get("aws_access_key", "") + stored_secret = d.get("aws_secret_key", "") + default_region = d.get("region", "us-east-1") + default_type = d.get("instance_type", "t3.micro") + + if stored_key: + ok(f"AWS credentials loaded: {_mask(stored_key)}") + override = input(f" {CYAN}Use stored credentials? [Y/n]:{RESET} ").strip().lower() + if override != "n": + config["aws_access_key"] = stored_key + config["aws_secret_key"] = stored_secret + else: + config["aws_access_key"] = input(f" {CYAN}AWS Access Key ID:{RESET} ").strip() + config["aws_secret_key"] = input(f" {CYAN}AWS Secret Access Key:{RESET} ").strip() + else: + config["aws_access_key"] = input(f" {CYAN}AWS Access Key ID:{RESET} ").strip() + config["aws_secret_key"] = input(f" {CYAN}AWS Secret Access Key:{RESET} ").strip() + + config["region"] = input(f" {CYAN}Region [{WHITE}{default_region}{RESET}]: ").strip() or default_region + config["instance_type"] = input(f" {CYAN}Instance type [{WHITE}{default_type}{RESET}]: ").strip() or default_type elif provider == "flokinet": config["provider"] = "flokinet" config["target_host"] = input(f" {CYAN}Server IP:{RESET} ").strip() - config["ssh_user"] = input(f" {CYAN}SSH user [root]:{RESET} ").strip() or "root" + config["ssh_user"] = input(f" {CYAN}SSH user [{WHITE}root{RESET}]: ").strip() or "root" elif provider == "existing": config["provider"] = "existing" config["target_host"] = input(f" {CYAN}Server IP/hostname:{RESET} ").strip() - config["ssh_user"] = input(f" {CYAN}SSH user [root]:{RESET} ").strip() or "root" + config["ssh_user"] = input(f" {CYAN}SSH user [{WHITE}root{RESET}]: ").strip() or "root" existing_key = input(f" {CYAN}SSH key path (blank to generate):{RESET} ").strip() if existing_key: config["ssh_key"] = existing_key @@ -294,26 +655,250 @@ def gather_credentials(provider, config): return True +# ─── Teardown ────────────────────────────────────────────────────────────── + +def teardown_instance(config_or_id): + """Tear down a provisioned cloud instance. + + Accepts either a full config dict OR a deployment_id string. + If string: loads config from WORK_DIR/{id}/deploy_info.json. + After successful teardown: archives info file, cleans SSH keys. + """ + # Resolve config from string ID + if isinstance(config_or_id, str): + deployment_id = config_or_id + json_file = WORK_DIR / deployment_id / "deploy_info.json" + if json_file.exists(): + with open(json_file) as f: + config = json.load(f) + # Normalize key name + if "deploy_id" in config and "deployment_id" not in config: + config["deployment_id"] = config.pop("deploy_id") + else: + err(f"No deploy_info.json found for {deployment_id}") + return False + + # May need API credentials — check env / provider defaults / prompt + provider = config.get("provider") + if provider == "linode" and not config.get("api_token"): + defaults = _load_provider_defaults() + config["api_token"] = defaults.get("linode", {}).get("api_token", "") + if not config["api_token"]: + config["api_token"] = input(f" {CYAN}Linode API token for teardown:{RESET} ").strip() + elif provider == "aws" and not config.get("aws_access_key"): + defaults = _load_provider_defaults() + aws = defaults.get("aws", {}) + config["aws_access_key"] = aws.get("aws_access_key", "") + config["aws_secret_key"] = aws.get("aws_secret_key", "") + if not config["aws_access_key"]: + config["aws_access_key"] = input(f" {CYAN}AWS Access Key for teardown:{RESET} ").strip() + config["aws_secret_key"] = input(f" {CYAN}AWS Secret Key for teardown:{RESET} ").strip() + else: + config = config_or_id + + provider = config.get("provider") + deployment_id = config.get("deployment_id") + label = instance_label(deployment_id) + + cleanup_map = { + "linode": PROVIDERS_DIR / "linode_cleanup.yml", + "aws": PROVIDERS_DIR / "aws_cleanup.yml", + } + + playbook = cleanup_map.get(provider) + if not playbook or not playbook.exists(): + err(f"No cleanup playbook for provider: {provider}") + warn(f"Manually delete instance labeled '{label}' from your provider dashboard.") + return False + + warn(f"Tearing down {provider} instance: {label}") + + # Build teardown vars — pass instance_label + credentials + teardown_vars = {"instance_label": label} + for key in ("api_token", "aws_access_key", "aws_secret_key", "region"): + if config.get(key): + teardown_vars[key] = config[key] + + cmd = [ + "ansible-playbook", str(playbook), + "-i", "localhost,", "--connection", "local", + ] + for k, v in teardown_vars.items(): + cmd.extend(["-e", f"{k}={v}"]) + + info(f"Running: {' '.join(cmd)}") + try: + result = subprocess.run(cmd, timeout=120) + if result.returncode == 0: + ok(f"Instance {label} destroyed.") + _archive_deployment(deployment_id) + return True + else: + err(f"Cleanup playbook failed (rc={result.returncode})") + warn(f"Manually delete instance '{label}' from your provider dashboard.") + return False + except Exception as e: + err(f"Teardown failed: {e}") + warn(f"Manually delete instance '{label}' from your provider dashboard.") + return False + + +def _archive_deployment(deployment_id): + """Archive deployment info file and clean SSH keys after teardown.""" + # Archive info file + info_file = PHANTOM_LOGS / f"deployment_info_{deployment_id}.txt" + if info_file.exists(): + archive_dir = PHANTOM_LOGS / "archive" + archive_dir.mkdir(exist_ok=True) + shutil.move(str(info_file), str(archive_dir / info_file.name)) + dim(f"Archived: {info_file.name}") + + # Archive ansible log + log_file = PHANTOM_LOGS / f"deployment_{deployment_id}.log" + if log_file.exists(): + archive_dir = PHANTOM_LOGS / "archive" + archive_dir.mkdir(exist_ok=True) + shutil.move(str(log_file), str(archive_dir / log_file.name)) + + # Clean SSH keys + key_path = _ssh_key_path(deployment_id) + for f in [key_path, Path(f"{key_path}.pub"), _ssh_known_hosts_path(deployment_id)]: + if f.exists(): + f.unlink() + dim(f"Removed: {f.name}") + + +# ─── Manage Existing Deployments ─────────────────────────────────────────── + +def _parse_deployment_info(info_file): + """Parse a deployment_info_*.txt file and return key fields.""" + data = {} + try: + text = info_file.read_text() + for line in text.splitlines(): + line = line.strip() + if line.startswith("Deployment ID:"): + data["deployment_id"] = line.split(":", 1)[1].strip() + elif line.startswith("Deployment Type:"): + data["deployment_type"] = line.split(":", 1)[1].strip() + elif line.startswith("Instance IP:"): + data["ip"] = line.split(":", 1)[1].strip() + elif line.startswith("domain:"): + data["domain"] = line.split(":", 1)[1].strip() + elif line.startswith("SSH Key:"): + data["ssh_key"] = line.split(":", 1)[1].strip() + elif line.startswith("Provider:"): + data["provider"] = line.split(":", 1)[1].strip() + except OSError: + pass + return data + + +def manage_menu(): + """Manage existing phantom deployments: SSH, teardown.""" + # Discover deployments from info files + deployments = [] + for info_file in sorted(PHANTOM_LOGS.glob("deployment_info_*.txt")): + data = _parse_deployment_info(info_file) + dep_type = data.get("deployment_type", "") + if dep_type.startswith("phantom_"): + data["_info_file"] = info_file + deployments.append(data) + + if not deployments: + warn("No phantom deployments found.") + return + + print(f"\n{CYAN} ┌─ Existing Deployments ────────────────────────────────┐{RESET}") + for i, dep in enumerate(deployments, 1): + dep_type = dep.get("deployment_type", "unknown").replace("phantom_", "") + ip = dep.get("ip", "?") + domain = dep.get("domain", "") + dep_id = dep.get("deployment_id", "?") + domain_str = f" ({domain})" if domain else "" + print(f" {CYAN}│{RESET} {WHITE}{i}{RESET}) {dep_id:<25} {GREY}{dep_type:<12} {ip}{domain_str}{RESET}") + print(f" {CYAN}│{RESET} {WHITE}0{RESET}) Return") + print(f" {CYAN}└─────────────────────────────────────────────────────────┘{RESET}") + + choice = input(f"\n {MAGENTA}>{RESET} ").strip() + if choice == "0" or not choice: + return + + try: + idx = int(choice) - 1 + if idx < 0 or idx >= len(deployments): + warn("Invalid selection.") + return + except ValueError: + warn("Invalid selection.") + return + + dep = deployments[idx] + dep_id = dep.get("deployment_id", "") + + print(f"\n{CYAN} ┌─ {dep_id} ─────────────────────────────────────────┐{RESET}") + print(f" {CYAN}│{RESET} {WHITE}1{RESET}) SSH into server") + print(f" {CYAN}│{RESET} {WHITE}2{RESET}) Teardown") + print(f" {CYAN}│{RESET} {WHITE}3{RESET}) Return") + print(f" {CYAN}└─────────────────────────────────────────────────────────┘{RESET}") + + action = input(f"\n {MAGENTA}>{RESET} ").strip() + + if action == "1": + # SSH into server + ip = dep.get("ip", "") + ssh_key = dep.get("ssh_key", str(_ssh_key_path(dep_id))) + known_hosts = str(_ssh_known_hosts_path(dep_id)) + + if not ip: + err("No IP address found for this deployment.") + return + + cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new", + "-o", f"UserKnownHostsFile={known_hosts}", + "-o", "IdentitiesOnly=yes"] + if ssh_key and Path(ssh_key).exists(): + cmd.extend(["-i", ssh_key]) + cmd.append(f"root@{ip}") + + info(f"Connecting to {ip}...") + os.execvp("ssh", cmd) + + elif action == "2": + # Teardown + confirm = input(f" {RED}Destroy {dep_id}? [y/N]:{RESET} ").strip().lower() + if confirm == "y": + teardown_instance(dep_id) + + # action == "3" or anything else: return + + # ─── Deployment Orchestration ─────────────────────────────────────────────── def deploy(server_type, config): - """Full deployment pipeline: summarize -> confirm -> provision -> configure.""" + """Full deployment pipeline: summarize -> confirm -> provision -> configure. + + Follows c2itall pattern: on ANY failure after provisioning, auto-teardown. + """ config["server_type"] = server_type - config.setdefault("deploy_id", generate_id()) - deploy_id = config["deploy_id"] + config.setdefault("deployment_id", generate_id()) + deployment_id = config["deployment_id"] + is_cloud = config.get("provider") in ("linode", "aws") # Summary print(f"\n{CYAN}{'─' * 60}{RESET}") print(f"{MAGENTA} Deployment Summary{RESET}") print(f"{CYAN}{'─' * 60}{RESET}") - print(f" {WHITE}ID:{RESET} {deploy_id}") + print(f" {WHITE}ID:{RESET} {deployment_id}") + if is_cloud: + print(f" {WHITE}Label:{RESET} {instance_label(deployment_id)}") print(f" {WHITE}Type:{RESET} {server_type}") print(f" {WHITE}Provider:{RESET} {config.get('provider', 'unknown')}") print(f" {WHITE}Target:{RESET} {config.get('target_host', 'TBD (will provision)')}") if config.get("domain"): print(f" {WHITE}Domain:{RESET} {config['domain']}") for k, v in config.items(): - if k not in ("deploy_id", "server_type", "provider", "target_host", + if k not in ("deployment_id", "server_type", "provider", "target_host", "domain", "api_token", "aws_access_key", "aws_secret_key", "ssh_key", "ssh_user", "timestamp"): print(f" {WHITE}{k}:{RESET} {v}") @@ -326,17 +911,20 @@ def deploy(server_type, config): # Generate SSH key if needed if config.get("provider") not in ("local",) and not config.get("ssh_key"): - config["ssh_key"] = generate_ssh_key(deploy_id) + config["ssh_key"] = generate_ssh_key(deployment_id) - # Provision if cloud provider - if config.get("provider") in ("linode", "aws"): + # ── Provision cloud instance ────────────────────────────────────────── + if is_cloud: provider_playbook = PROVIDERS_DIR / f"{config['provider']}.yml" - # Tell the provider playbook where to write the provisioned IP - host_file = LOGS_DIR / deploy_id / "provisioned_host" + host_file = WORK_DIR / deployment_id / "provisioned_host" config["_host_output_file"] = str(host_file) info(f"Provisioning {config['provider']} instance...") if not run_playbook(provider_playbook, config): err("Provisioning failed.") + # Instance may already exist (API call succeeded, SSH wait timed out) + # Always attempt teardown — cleanup playbook uses ignore_errors + warn("Attempting teardown in case instance was created...") + teardown_instance(config) return # Read back the provisioned host IP if host_file.exists(): @@ -344,39 +932,46 @@ def deploy(server_type, config): ok(f"Provisioned server: {config['target_host']}") else: err("Provisioning completed but no host IP was returned.") + teardown_instance(config) return - # Base hardening - info("Applying base hardening...") - if not run_playbook(PLAYBOOKS_DIR / "common/base_hardening.yml", config): - err("Base hardening failed — aborting deployment.") + # ── Post-provisioning: hardening + service config ───────────────────── + # Any failure here tears down the cloud instance (matches c2itall pattern) + try: + # Base hardening + info("Applying base hardening...") + if not run_playbook(PLAYBOOKS_DIR / "common/base_hardening.yml", config): + raise RuntimeError("Base hardening failed") + + # Service-specific playbook + playbook_map = { + "matrix": PLAYBOOKS_DIR / "matrix/main.yml", + "vpn": PLAYBOOKS_DIR / "vpn/main.yml", + "dns": PLAYBOOKS_DIR / "dns/main.yml", + "cloud": PLAYBOOKS_DIR / "cloud/main.yml", + "vault": PLAYBOOKS_DIR / "vault/main.yml", + "media": PLAYBOOKS_DIR / "media/main.yml", + "email": PLAYBOOKS_DIR / "email/main.yml", + "all_in_one": PLAYBOOKS_DIR / "all_in_one/main.yml", + } + + playbook = playbook_map.get(server_type) + if playbook: + info(f"Configuring {server_type}...") + if not run_playbook(playbook, config): + raise RuntimeError(f"Service configuration failed for {server_type}") + + ok(f"Deployment complete: {deployment_id}") + save_deploy_info(config) + ssh_connect(config) + + except Exception as e: + err(str(e)) + if is_cloud: + warn("Tearing down provisioned instance...") + teardown_instance(config) return - # Service-specific playbook - playbook_map = { - "matrix": PLAYBOOKS_DIR / "matrix/main.yml", - "vpn": PLAYBOOKS_DIR / "vpn/main.yml", - "dns": PLAYBOOKS_DIR / "dns/main.yml", - "cloud": PLAYBOOKS_DIR / "cloud/main.yml", - "vault": PLAYBOOKS_DIR / "vault/main.yml", - "media": PLAYBOOKS_DIR / "media/main.yml", - "email": PLAYBOOKS_DIR / "email/main.yml", - "all_in_one": PLAYBOOKS_DIR / "all_in_one/main.yml", - } - - playbook = playbook_map.get(server_type) - if playbook: - info(f"Configuring {server_type}...") - if run_playbook(playbook, config): - ok(f"Deployment complete: {deploy_id}") - else: - err(f"Service configuration failed for {server_type}") - return - - # Save info and offer SSH - save_deploy_info(config) - ssh_connect(config) - # ─── Main Menu ────────────────────────────────────────────────────────────── @@ -384,11 +979,12 @@ MENU_ITEMS = [ ("1", "Matrix + Element", "matrix", "Encrypted messaging homeserver"), ("2", "WireGuard VPN", "vpn", "Private VPN server"), ("3", "Pi-hole DNS", "dns", "Ad-blocking DNS server"), - ("4", "Nextcloud", "cloud", "Self-hosted file sync (coming soon)"), - ("5", "Vaultwarden", "vault", "Password manager (coming soon)"), - ("6", "Jellyfin", "media", "Media server (coming soon)"), - ("7", "Mail-in-a-Box", "email", "Email server (coming soon)"), + ("4", "Nextcloud", "cloud", "Self-hosted file sync"), + ("5", "Vaultwarden", "vault", "Password manager"), + ("6", "Jellyfin", "media", "Media server"), + ("7", "Mail-in-a-Box", "email", "Email server"), ("8", "All-in-One", "all_in_one", "Multiple services on one server"), + ("9", "Manage Deployment", None, "SSH, teardown existing"), ("0", "Exit", None, None), ] @@ -396,6 +992,9 @@ MENU_ITEMS = [ def main_menu(): banner() + # Migrate old SSH keys on startup + _migrate_ssh_keys() + while True: print(f"\n{CYAN} ┌─ Deploy a Privacy Server ──────────────────────────┐{RESET}") for num, label, _, desc in MENU_ITEMS: @@ -411,6 +1010,10 @@ def main_menu(): print(f"\n{GREY} Goodbye.{RESET}\n") break + if choice == "9": + manage_menu() + continue + # Find matching menu item selected = None for num, label, stype, _ in MENU_ITEMS: @@ -435,7 +1038,7 @@ def main_menu(): warn("Invalid provider selection.") continue - config = {"deploy_id": generate_id()} + config = {"deployment_id": generate_id()} if not gather_credentials(provider, config): continue diff --git a/phantom/playbooks/all_in_one/main.yml b/phantom/playbooks/all_in_one/main.yml index 8d4f862..dba170d 100644 --- a/phantom/playbooks/all_in_one/main.yml +++ b/phantom/playbooks/all_in_one/main.yml @@ -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 diff --git a/phantom/playbooks/cloud/main.yml b/phantom/playbooks/cloud/main.yml index 2b9b462..a4db806 100644 --- a/phantom/playbooks/cloud/main.yml +++ b/phantom/playbooks/cloud/main.yml @@ -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 diff --git a/phantom/playbooks/cloud/tasks/configure.yml b/phantom/playbooks/cloud/tasks/configure.yml new file mode 100644 index 0000000..338e90c --- /dev/null +++ b/phantom/playbooks/cloud/tasks/configure.yml @@ -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 }}" diff --git a/phantom/playbooks/cloud/tasks/install.yml b/phantom/playbooks/cloud/tasks/install.yml new file mode 100644 index 0000000..849f19a --- /dev/null +++ b/phantom/playbooks/cloud/tasks/install.yml @@ -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" diff --git a/phantom/playbooks/cloud/tasks/nginx.yml b/phantom/playbooks/cloud/tasks/nginx.yml new file mode 100644 index 0000000..555eaaa --- /dev/null +++ b/phantom/playbooks/cloud/tasks/nginx.yml @@ -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 diff --git a/phantom/playbooks/common/base_hardening.yml b/phantom/playbooks/common/base_hardening.yml index 4459d71..bf09785 100644 --- a/phantom/playbooks/common/base_hardening.yml +++ b/phantom/playbooks/common/base_hardening.yml @@ -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: diff --git a/phantom/playbooks/common/templates/add-matrix-user.sh.j2 b/phantom/playbooks/common/templates/add-matrix-user.sh.j2 new file mode 100644 index 0000000..b26f8cc --- /dev/null +++ b/phantom/playbooks/common/templates/add-matrix-user.sh.j2 @@ -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 diff --git a/phantom/playbooks/common/templates/setup-cert.sh.j2 b/phantom/playbooks/common/templates/setup-cert.sh.j2 new file mode 100644 index 0000000..e8a8774 --- /dev/null +++ b/phantom/playbooks/common/templates/setup-cert.sh.j2 @@ -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 "============================================" diff --git a/phantom/playbooks/common/tls_setup.yml b/phantom/playbooks/common/tls_setup.yml new file mode 100644 index 0000000..1ce02d2 --- /dev/null +++ b/phantom/playbooks/common/tls_setup.yml @@ -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" diff --git a/phantom/playbooks/dns/main.yml b/phantom/playbooks/dns/main.yml index d012c6d..91a21e2 100644 --- a/phantom/playbooks/dns/main.yml +++ b/phantom/playbooks/dns/main.yml @@ -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 diff --git a/phantom/playbooks/dns/tasks/configure.yml b/phantom/playbooks/dns/tasks/configure.yml index a573ce8..5d85a41 100644 --- a/phantom/playbooks/dns/tasks/configure.yml +++ b/phantom/playbooks/dns/tasks/configure.yml @@ -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" diff --git a/phantom/playbooks/dns/tasks/install.yml b/phantom/playbooks/dns/tasks/install.yml index 07a1c61..02c20df 100644 --- a/phantom/playbooks/dns/tasks/install.yml +++ b/phantom/playbooks/dns/tasks/install.yml @@ -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" diff --git a/phantom/playbooks/email/main.yml b/phantom/playbooks/email/main.yml index e9eab6c..35d0d39 100644 --- a/phantom/playbooks/email/main.yml +++ b/phantom/playbooks/email/main.yml @@ -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 diff --git a/phantom/playbooks/email/tasks/configure.yml b/phantom/playbooks/email/tasks/configure.yml new file mode 100644 index 0000000..155c431 --- /dev/null +++ b/phantom/playbooks/email/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 diff --git a/phantom/playbooks/email/tasks/install.yml b/phantom/playbooks/email/tasks/install.yml new file mode 100644 index 0000000..13b7bf6 --- /dev/null +++ b/phantom/playbooks/email/tasks/install.yml @@ -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 diff --git a/phantom/playbooks/matrix/main.yml b/phantom/playbooks/matrix/main.yml index bb2ab5b..7fbb0fa 100644 --- a/phantom/playbooks/matrix/main.yml +++ b/phantom/playbooks/matrix/main.yml @@ -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: diff --git a/phantom/playbooks/matrix/tasks/nginx.yml b/phantom/playbooks/matrix/tasks/nginx.yml index 4b3e6a6..6caaee7 100644 --- a/phantom/playbooks/matrix/tasks/nginx.yml +++ b/phantom/playbooks/matrix/tasks/nginx.yml @@ -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" diff --git a/phantom/playbooks/matrix/tasks/synapse.yml b/phantom/playbooks/matrix/tasks/synapse.yml index 6bc1c72..adc8ffa 100644 --- a/phantom/playbooks/matrix/tasks/synapse.yml +++ b/phantom/playbooks/matrix/tasks/synapse.yml @@ -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" diff --git a/phantom/playbooks/matrix/templates/homeserver.yaml.j2 b/phantom/playbooks/matrix/templates/homeserver.yaml.j2 index d364b23..81d70b3 100644 --- a/phantom/playbooks/matrix/templates/homeserver.yaml.j2 +++ b/phantom/playbooks/matrix/templates/homeserver.yaml.j2 @@ -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 diff --git a/phantom/playbooks/media/main.yml b/phantom/playbooks/media/main.yml index e57bbd4..b941d02 100644 --- a/phantom/playbooks/media/main.yml +++ b/phantom/playbooks/media/main.yml @@ -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 diff --git a/phantom/playbooks/media/tasks/configure.yml b/phantom/playbooks/media/tasks/configure.yml new file mode 100644 index 0000000..4d22ca4 --- /dev/null +++ b/phantom/playbooks/media/tasks/configure.yml @@ -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: '' + line: ' 127.0.0.1' + 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 diff --git a/phantom/playbooks/media/tasks/install.yml b/phantom/playbooks/media/tasks/install.yml new file mode 100644 index 0000000..cf30985 --- /dev/null +++ b/phantom/playbooks/media/tasks/install.yml @@ -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" diff --git a/phantom/playbooks/media/tasks/nginx.yml b/phantom/playbooks/media/tasks/nginx.yml new file mode 100644 index 0000000..eca727a --- /dev/null +++ b/phantom/playbooks/media/tasks/nginx.yml @@ -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 diff --git a/phantom/playbooks/vault/main.yml b/phantom/playbooks/vault/main.yml index 1f3727b..8680da4 100644 --- a/phantom/playbooks/vault/main.yml +++ b/phantom/playbooks/vault/main.yml @@ -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 diff --git a/phantom/playbooks/vault/tasks/configure.yml b/phantom/playbooks/vault/tasks/configure.yml new file mode 100644 index 0000000..d47b5a3 --- /dev/null +++ b/phantom/playbooks/vault/tasks/configure.yml @@ -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 diff --git a/phantom/playbooks/vault/tasks/install.yml b/phantom/playbooks/vault/tasks/install.yml new file mode 100644 index 0000000..73360a3 --- /dev/null +++ b/phantom/playbooks/vault/tasks/install.yml @@ -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" diff --git a/phantom/playbooks/vault/tasks/nginx.yml b/phantom/playbooks/vault/tasks/nginx.yml new file mode 100644 index 0000000..29aa9ee --- /dev/null +++ b/phantom/playbooks/vault/tasks/nginx.yml @@ -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 diff --git a/phantom/providers/aws.yml b/phantom/providers/aws.yml index 9fd6f65..c1de250 100644 --- a/phantom/providers/aws.yml +++ b/phantom/providers/aws.yml @@ -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 }}" diff --git a/phantom/providers/aws_cleanup.yml b/phantom/providers/aws_cleanup.yml new file mode 100644 index 0000000..d379e35 --- /dev/null +++ b/phantom/providers/aws_cleanup.yml @@ -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') }}" diff --git a/phantom/providers/linode.yml b/phantom/providers/linode.yml index e545281..ea5e1f0 100644 --- a/phantom/providers/linode.yml +++ b/phantom/providers/linode.yml @@ -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 }}" diff --git a/phantom/providers/linode_cleanup.yml b/phantom/providers/linode_cleanup.yml new file mode 100644 index 0000000..756e9bb --- /dev/null +++ b/phantom/providers/linode_cleanup.yml @@ -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) }}" diff --git a/phantom/test_phantom.py b/phantom/test_phantom.py new file mode 100644 index 0000000..7d35a0f --- /dev/null +++ b/phantom/test_phantom.py @@ -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())