Files
CoM-ghost_protocol/phantom/phantom.py
T
n0mad1k 7484a0e034 Phantom v2: dual-mode architecture, security hardening, deployment management
- Dual-mode operation: standalone + c2itall integrated (env var detection)
- SSH keys moved to ~/.ssh/c2deploy_ph-{id} with per-deployment known_hosts
- Ansible output streaming with filtered console + full log capture
- Deployment management menu: discover, SSH, teardown existing deployments
- Cert setup script (setup-cert.sh) deployed to servers for post-DNS LE certs
- Matrix hardening: unique secrets, SSRF protection, rate limits, nginx security headers
- Base hardening: fail2ban systemd backend (Debian 12), SSH limits, nginx jails
- Add-matrix-user helper script deployed to all Matrix servers
- .env support for standalone credential storage
- Config key rename: deploy_id → deployment_id (with backward compat)
- Provider cleanup playbooks for teardown
- Test suite with 50 tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:45:24 -04:00

1067 lines
42 KiB
Python
Executable File

#!/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 shutil
import subprocess
import sys
import time
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"
# 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 ──────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────
# Uses c2itall's verb+animal naming convention for consistency
INSTANCE_PREFIX = "ph" # phantom privacy server prefix
def generate_id():
"""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(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}")
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"c2deploy-ph-{deployment_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 ─────────────────────────────────────────────
# 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 streaming output and log capture.
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
deployment_id = config["deployment_id"]
cmd = ["ansible-playbook", str(playbook_path)]
# Build vars file in work dir
vars_file = WORK_DIR / deployment_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 = 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:
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")
# 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 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")
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):
"""Offer to SSH into the deployed server."""
target = config.get("target_host", "")
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", "-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}")
print()
resp = input(f"{CYAN}[?]{RESET} SSH into {target}? [y/N] ").strip().lower()
if resp == "y":
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():
"""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.
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"
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"
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 [{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 [{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
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
# ─── 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.
Follows c2itall pattern: on ANY failure after provisioning, auto-teardown.
"""
config["server_type"] = server_type
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} {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 ("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}")
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(deployment_id)
# ── Provision cloud instance ──────────────────────────────────────────
if is_cloud:
provider_playbook = PROVIDERS_DIR / f"{config['provider']}.yml"
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():
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.")
teardown_instance(config)
return
# ── 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
# ─── 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"),
("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),
]
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:
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
if choice == "9":
manage_menu()
continue
# 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 = {"deployment_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")