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