#!/usr/bin/env python3 """phantom — Privacy Server Deployer Deploy self-hosted privacy infrastructure with a single command. Supports Matrix, WireGuard VPN, Pi-hole DNS, Nextcloud, and more. Providers: Linode, AWS, FlokiNET, or local/existing server. """ import json import os import random import subprocess import sys import time from pathlib import Path # Allow modules to import from phantom sys.path.insert(0, os.path.dirname(__file__)) 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) # ─── Color Helpers ────────────────────────────────────────────────────────── RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" CYAN = "\033[38;5;51m" GREEN = "\033[38;5;49m" RED = "\033[38;5;196m" YELLOW = "\033[38;5;214m" MAGENTA = "\033[38;5;201m" WHITE = "\033[38;5;255m" GREY = "\033[38;5;244m" def ok(msg): print(f"{GREEN}[+]{RESET} {msg}") def err(msg): print(f"{RED}[-]{RESET} {msg}") def warn(msg): print(f"{YELLOW}[*]{RESET} {msg}") def info(msg): print(f"{CYAN}[~]{RESET} {msg}") def dim(msg): print(f"{GREY} {msg}{RESET}") # ─── Banner ───────────────────────────────────────────────────────────────── BANNER = f""" {MAGENTA} ██████╗ ██╗ ██╗ █████╗ ███╗ ██╗████████╗ ██████╗ ███╗ ███╗ ██╔══██╗██║ ██║██╔══██╗████╗ ██║╚══██╔══╝██╔═══██╗████╗ ████║ ██████╔╝███████║███████║██╔██╗ ██║ ██║ ██║ ██║██╔████╔██║ ██╔═══╝ ██╔══██║██╔══██║██║╚██╗██║ ██║ ██║ ██║██║╚██╔╝██║ ██║ ██║ ██║██║ ██║██║ ╚████║ ██║ ╚██████╔╝██║ ╚═╝ ██║ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝{RESET} {GREY} Privacy Server Deployer — Your infrastructure, your rules{RESET} """ def banner(): print(BANNER) # ─── Deployment ID Generation ─────────────────────────────────────────────── 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", ] 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}" # ─── 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}" if key_path.exists(): info(f"SSH key already exists: {key_path}") return str(key_path) 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}"], check=True, capture_output=True, ) os.chmod(key_path, 0o600) ok(f"SSH key generated: {key_path}") return str(key_path) # ─── Ansible Playbook Execution ───────────────────────────────────────────── def run_playbook(playbook_path, config, extra_vars=None): """Execute an Ansible playbook with the given config. Args: playbook_path: Path object or string to the playbook file. config: Deployment config dict. extra_vars: Optional dict of extra Ansible variables. Returns: True if playbook succeeded, False otherwise. """ playbook_path = Path(playbook_path) if not playbook_path.exists(): err(f"Playbook not found: {playbook_path}") return False cmd = ["ansible-playbook", str(playbook_path)] # Build vars file vars_file = LOGS_DIR / config["deploy_id"] / "vars.yaml" vars_file.parent.mkdir(parents=True, exist_ok=True) try: import yaml # noqa: optional dependency with open(vars_file, "w") as f: yaml.dump(config, f, default_flow_style=False) except ImportError: with open(vars_file, "w") as f: for k, v in config.items(): f.write(f"{k}: {json.dumps(v)}\n") cmd.extend(["-e", f"@{vars_file}"]) if extra_vars: for k, v in extra_vars.items(): cmd.extend(["-e", f"{k}={v}"]) # Set inventory target = config.get("target_host", "localhost") if target == "localhost": cmd.extend(["-i", "localhost,", "--connection", "local"]) else: inv_file = LOGS_DIR / config["deploy_id"] / "inventory" 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)]) # Elevate privileges for non-root users if ssh_user != "root": cmd.append("--become") info(f"Running: {' '.join(cmd)}") result = subprocess.run(cmd) return result.returncode == 0 # ─── 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" config["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%S%z") with open(info_file, "w") as f: json.dump(config, f, indent=2, default=str) ok(f"Deployment info saved: {info_file}") # ─── SSH Connect ──────────────────────────────────────────────────────────── def ssh_connect(config): """Offer to SSH into the deployed server.""" target = config.get("target_host", "") if not target or target == "localhost": return ssh_key = config.get("ssh_key", "") ssh_user = config.get("ssh_user", "root") cmd = ["ssh"] if ssh_key: cmd.extend(["-i", ssh_key]) cmd.append(f"{ssh_user}@{target}") print() resp = input(f"{CYAN}[?]{RESET} SSH into {target}? [y/N] ").strip().lower() if resp == "y": os.execvp("ssh", cmd) # ─── Provider Selection ───────────────────────────────────────────────────── def select_provider(): """Select deployment target: cloud provider or local/existing server.""" print(f"\n{CYAN} Select deployment target:{RESET}") print(f" {WHITE}1{RESET}) Linode") print(f" {WHITE}2{RESET}) AWS (EC2)") print(f" {WHITE}3{RESET}) FlokiNET (pre-provisioned)") print(f" {WHITE}4{RESET}) Existing server (SSH)") print(f" {WHITE}5{RESET}) Local (this machine)") print() choice = input(f" {MAGENTA}>{RESET} ").strip() providers = {"1": "linode", "2": "aws", "3": "flokinet", "4": "existing", "5": "local"} return providers.get(choice) def gather_credentials(provider, config): """Gather provider-specific credentials.""" 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" 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" 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" 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" existing_key = input(f" {CYAN}SSH key path (blank to generate):{RESET} ").strip() if existing_key: config["ssh_key"] = existing_key elif provider == "local": config["provider"] = "local" config["target_host"] = "localhost" config["ssh_user"] = os.getenv("USER", "root") warn("Local deployment will install services directly on this machine.") confirm = input(f" {YELLOW}Continue? [y/N]:{RESET} ").strip().lower() if confirm != "y": return False return True # ─── Deployment Orchestration ─────────────────────────────────────────────── def deploy(server_type, config): """Full deployment pipeline: summarize -> confirm -> provision -> configure.""" config["server_type"] = server_type config.setdefault("deploy_id", generate_id()) deploy_id = config["deploy_id"] # 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}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", "domain", "api_token", "aws_access_key", "aws_secret_key", "ssh_key", "ssh_user", "timestamp"): print(f" {WHITE}{k}:{RESET} {v}") print(f"{CYAN}{'─' * 60}{RESET}") confirm = input(f"\n {MAGENTA}Deploy? [y/N]:{RESET} ").strip().lower() if confirm != "y": warn("Deployment cancelled.") return # 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) # Provision if cloud provider if config.get("provider") in ("linode", "aws"): 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" config["_host_output_file"] = str(host_file) info(f"Provisioning {config['provider']} instance...") if not run_playbook(provider_playbook, config): err("Provisioning failed.") return # Read back the provisioned host IP if host_file.exists(): config["target_host"] = host_file.read_text().strip() ok(f"Provisioned server: {config['target_host']}") else: err("Provisioning completed but no host IP was returned.") return # Base hardening info("Applying base hardening...") if not run_playbook(PLAYBOOKS_DIR / "common/base_hardening.yml", config): err("Base hardening failed — aborting deployment.") 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 ────────────────────────────────────────────────────────────── 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)"), ("8", "All-in-One", "all_in_one", "Multiple services on one server"), ("0", "Exit", None, None), ] def main_menu(): banner() while True: print(f"\n{CYAN} ┌─ Deploy a Privacy Server ──────────────────────────┐{RESET}") for num, label, _, desc in MENU_ITEMS: if desc: print(f" {CYAN}│{RESET} {WHITE}{num}{RESET}) {label:<20} {GREY}{desc}{RESET}") else: print(f" {CYAN}│{RESET} {WHITE}{num}{RESET}) {label}") print(f" {CYAN}└─────────────────────────────────────────────────────┘{RESET}") choice = input(f"\n {MAGENTA}>{RESET} ").strip() if choice == "0": print(f"\n{GREY} Goodbye.{RESET}\n") break # Find matching menu item selected = None for num, label, stype, _ in MENU_ITEMS: if choice == num and stype: selected = stype break if not selected: warn("Invalid selection.") continue # Import the module try: mod = __import__(f"modules.{selected}", fromlist=[selected]) except ImportError as e: err(f"Module not found: {selected} ({e})") continue # Select provider provider = select_provider() if not provider: warn("Invalid provider selection.") continue config = {"deploy_id": generate_id()} if not gather_credentials(provider, config): continue # Gather service-specific config if hasattr(mod, "gather_config"): config = mod.gather_config(config) if config is None: continue # Deploy deploy(selected, config) if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] in ("--help", "-h"): print("Usage: python3 phantom.py") print(" Interactive privacy server deployer.") print(" Supports Matrix, WireGuard, Pi-hole, and more.") print(" Run without arguments to launch the interactive menu.") sys.exit(0) try: main_menu() except KeyboardInterrupt: print(f"\n{GREY} Interrupted.{RESET}\n")