Compare commits

...

10 Commits

Author SHA1 Message Date
n0mad1k 27144ccaf8 Point clone URL to CoM public repo on churchofmalware.org 2026-06-25 12:24:38 -04:00
n0mad1k 701f7078f5 Fix Pi-hole deployment: remove undefined 'update gravity' handler notify
The 'Add aggressive blocklists' task notified a handler that didn't exist
in main.yml, causing deployment failure. Removed the notify since the
gravity update is already handled by the subsequent task.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:24:14 -04:00
n0mad1k d687423c22 Add service accounts and systemd hardening
- cloudflared: create dedicated service account, run as non-root with
  ProtectSystem=strict and full hardening directives
- cloudflared: skip credentials overwrite when existing tunnel has valid
  credentials on disk; delete and recreate if credentials are missing
- wireguard: add systemd drop-in with ProtectHome, ProtectClock,
  ProtectHostname, ProtectKernelLogs, PrivateTmp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:46:24 -04:00
n0mad1k 1eff254a66 Fix tunnel script overwriting credentials for existing tunnels
When an existing Cloudflare tunnel was found, the script would overwrite
the credentials file with an empty TunnelSecret, breaking the service.
Now validates existing credentials and only recreates the tunnel if the
credentials file is missing or invalid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:37:20 -04:00
n0mad1k 3542913689 LAN/local deployment support, OS compat, Cloudflare Tunnel
- Smart TLS: skip certbot for IPs/.local/.lan, self-signed with SAN,
  HSTS max-age=0 for self-signed certs, split LAN vs public messages
- Dynamic PHP: versionless meta-packages, runtime detection via php_ver fact
- Vaultwarden: fail-fast on armv7l (32-bit ARM not supported upstream)
- Module prompts: accept IPs for matrix/cloud/vault/media, hard error
  on email with IP, all_in_one skips certbot email for LAN
- Matrix: skip matrix. prefix strip for IPs, warn about immutable server_name
- OS family guards: ansible_os_family == Debian on all apt tasks
- SSH key path: expanduser().resolve() on user-provided key paths
- Cloudflare Tunnel: post-deploy script (setup-tunnel.sh) using CF API
  token — no browser auth needed, creates tunnel + credentials + DNS + systemd

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:27:12 -04:00
n0mad1k 040fa12705 Split base hardening packages into required and optional
software-properties-common removed — dropped from Debian 13 (trixie).
unattended-upgrades and apt-listchanges moved to optional task with
ignore_errors so missing packages don't fail the entire deployment.
2026-03-10 10:59:06 -04:00
n0mad1k 34a3961926 Fix sudo password handling for non-root SSH deployments
When deploying to existing servers as non-root, Ansible needs
become_password for sudo. Now prompts for it during credential
gathering and passes it via ansible_become_password in the
inventory (file chmod 0600). Blank input = NOPASSWD sudo assumed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:52:57 -04:00
n0mad1k 8ffc92b6db Fix invalid AllowedIPs in WireGuard peer config
Root cause: regex_replace chains to extract subnet base from CIDR
weren't working in Ansible shell blocks, producing 10.66.66.0/24.2/32
instead of 10.66.66.2/32 — invalid CIDR that wg-quick can't parse.

Fix: replace fragile regex with simple string split via set_fact
(_subnet_base = wg_subnet.split('.')[0:3] | join('.')). Use echo
statements for lines needing shell expansion (PrivateKey, peer keys)
and quoted heredoc for static Jinja2-rendered lines (PostUp/PostDown).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:49:56 -04:00
n0mad1k d4b7c356dc Fix WireGuard PostUp failures causing service start abort
- wg-quick runs under set -e; any non-zero exit kills the service
- 2>/dev/null only hides stderr, doesn't change exit code — need || true
- Split PostUp back to multiple lines (one command per line, cleaner)
- Use Jinja2 vars instead of shell heredoc for interface/subnet values
  (quoted heredoc 'CONFEOF' prevents shell expansion issues, PrivateKey
  written separately via echo since it needs shell expansion)
- Use -m conntrack --ctstate instead of deprecated -m state --state
- Add config dump debug task to capture generated config on failure
- Separate heredoc markers (CONFEOF/CLIENTEOF) to avoid conflicts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:21:52 -04:00
n0mad1k 0d9da7f7f2 Fix WireGuard service start failure
- ip6tables nat commands now redirect stderr to /dev/null so missing
  ip6table_nat module doesn't fail PostUp and kill the service
- Pre-load ip6table_nat kernel module (ignore_errors for minimal kernels)
- Collapse PostUp/PostDown to single-line semicolon-chained commands
  (avoids any heredoc whitespace or multi-line parsing issues)
- Remove comments from inside wg0.conf config body

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:14:56 -04:00
26 changed files with 621 additions and 103 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ The **Covert SD Card Tool** is a Python script designed to automate the process
1. **Clone the Repository or Download the Script:**
```bash
git clone https://github.com/n0mad1k/ghost_protocol.git
git clone https://git.churchofmalware.org/n0mad1k/CoM-ghost_protocol.git
cd ghost_protocol/covert_sd
```
+42 -20
View File
@@ -4,6 +4,8 @@ Composes multiple services onto a single server with nginx reverse proxy
and TLS via Certbot. Includes clear warnings about single-server risks.
"""
import re
CYAN = "\033[38;5;51m"
WHITE = "\033[38;5;255m"
GREY = "\033[38;5;244m"
@@ -11,6 +13,9 @@ YELLOW = "\033[38;5;214m"
RED = "\033[38;5;196m"
RESET = "\033[0m"
_IP_RE = re.compile(r'^\d+\.\d+\.\d+\.\d+$')
_LOCAL_RE = re.compile(r'\.(local|lan|home|internal|test)$')
SERVICES = [
("matrix", "Matrix + Element", "Encrypted messaging"),
("vpn", "WireGuard VPN", "Private VPN server"),
@@ -69,37 +74,54 @@ def gather_config(config):
config["services"] = selected_services
config["all_in_one"] = True
# Base domain for nginx vhosts
# Base domain or IP for nginx vhosts
config["domain"] = input(
f"\n {CYAN}Base domain (e.g. example.com):{RESET} "
f"\n {CYAN}Base domain or IP (e.g. example.com or 192.168.1.100):{RESET} "
).strip()
if not config["domain"]:
print(f" {RED}Domain is required for reverse proxy.{RESET}")
print(f" {RED}Domain or IP is required for reverse proxy.{RESET}")
return None
config["certbot_email"] = input(
f" {CYAN}Email for Let's Encrypt [{GREY}optional{RESET}]: "
).strip()
base_domain = config["domain"]
is_ip = bool(_IP_RE.match(base_domain))
is_local = bool(_LOCAL_RE.search(base_domain))
if is_ip:
# Warn about multiple web services on a single IP
web_services = [s for s in selected_services if s not in ("vpn", "dns")]
if len(web_services) > 1:
print(f"\n {YELLOW}WARNING: Multiple web services on a single IP.{RESET}")
print(f" {YELLOW}Only the last nginx config on port 443 wins.{RESET}")
print(f" {YELLOW}Consider using different ports or a reverse proxy with path-based routing.{RESET}")
# Skip certbot email for IPs / .local domains
if is_ip or is_local:
config["certbot_email"] = ""
else:
config["certbot_email"] = input(
f" {CYAN}Email for Let's Encrypt [{GREY}optional{RESET}]: "
).strip()
# Gather per-service configs
# Save base domain — each service stores config under its own prefixed keys
base_domain = config["domain"]
for svc in selected_services:
try:
mod = __import__(f"modules.{svc}", fromlist=[svc])
if hasattr(mod, "gather_config"):
# Set per-service subdomain default
svc_subdomains = {
"matrix": f"matrix.{base_domain}",
"cloud": f"cloud.{base_domain}",
"vault": f"vault.{base_domain}",
"media": f"media.{base_domain}",
"email": f"mail.{base_domain}",
"dns": f"dns.{base_domain}",
"vpn": base_domain,
}
if svc in svc_subdomains:
config["domain"] = svc_subdomains[svc]
# Set per-service domain: use same IP for all when base is IP
if is_ip:
config["domain"] = base_domain
else:
svc_subdomains = {
"matrix": f"matrix.{base_domain}",
"cloud": f"cloud.{base_domain}",
"vault": f"vault.{base_domain}",
"media": f"media.{base_domain}",
"email": f"mail.{base_domain}",
"dns": f"dns.{base_domain}",
"vpn": base_domain,
}
if svc in svc_subdomains:
config["domain"] = svc_subdomains[svc]
config = mod.gather_config(config)
if config is None:
return None
+1 -1
View File
@@ -12,7 +12,7 @@ def gather_config(config):
print(f" {CYAN}{RESET} {GREY}PHP-FPM + MariaDB + Redis + nginx{RESET}")
config["domain"] = config.get("domain") or input(
f" {CYAN}{RESET} Domain (e.g. cloud.example.com): "
f" {CYAN}{RESET} Domain or IP (e.g. cloud.example.com or 192.168.1.100): "
).strip()
config["cloud_admin_user"] = input(
+9
View File
@@ -1,10 +1,15 @@
"""Mail-in-a-Box deployment module — native installer script."""
import re
CYAN = "\033[38;5;51m"
WHITE = "\033[38;5;255m"
GREY = "\033[38;5;244m"
RED = "\033[38;5;196m"
RESET = "\033[0m"
_IP_RE = re.compile(r'^\d+\.\d+\.\d+\.\d+$')
def gather_config(config):
"""Gather Mail-in-a-Box configuration."""
@@ -15,6 +20,10 @@ def gather_config(config):
f" {CYAN}{RESET} Mail domain (e.g. mail.example.com): "
).strip()
if _IP_RE.match(config.get("domain", "")):
print(f" {CYAN}{RESET} {RED}ERROR: Email requires a real domain — bare IPs cannot receive mail (no MX records).{RESET}")
return None
config["email_first_user"] = input(
f" {CYAN}{RESET} First email user (e.g. admin@example.com): "
).strip()
+18 -3
View File
@@ -1,25 +1,36 @@
"""Matrix + Element homeserver deployment module."""
import getpass
import re
import secrets
CYAN = "\033[38;5;51m"
WHITE = "\033[38;5;255m"
GREY = "\033[38;5;244m"
YELLOW = "\033[38;5;214m"
RESET = "\033[0m"
_IP_RE = re.compile(r'^\d+\.\d+\.\d+\.\d+$')
def gather_config(config):
"""Gather Matrix/Synapse + Element configuration."""
print(f"\n{CYAN} ┌─ Matrix Homeserver Configuration ─────────────────┐{RESET}")
config["domain"] = config.get("domain") or input(
f" {CYAN}{RESET} Domain (e.g. matrix.example.com): "
f" {CYAN}{RESET} Domain or IP (e.g. matrix.example.com or 192.168.1.100): "
).strip()
if not config["domain"]:
print(f" {CYAN}{RESET} Domain is required.")
return None
is_ip = bool(_IP_RE.match(config["domain"]))
if is_ip:
print(f" {CYAN}{RESET} {YELLOW}WARNING: Deploying with an IP as server_name.{RESET}")
print(f" {CYAN}{RESET} {YELLOW}Synapse server_name is immutable after first federation.{RESET}")
print(f" {CYAN}{RESET} {YELLOW}Migrating to a domain later requires a fresh database.{RESET}")
config["matrix_admin_user"] = input(
f" {CYAN}{RESET} Admin username [{WHITE}admin{RESET}]: "
).strip() or "admin"
@@ -38,8 +49,12 @@ def gather_config(config):
).strip().lower()
config["matrix_element_web"] = config["matrix_element_web"] not in ("no", "n", "false")
config["matrix_server_name"] = config["domain"].replace("matrix.", "", 1) \
if config["domain"].startswith("matrix.") else config["domain"]
# Skip matrix. prefix stripping for IPs
if is_ip:
config["matrix_server_name"] = config["domain"]
else:
config["matrix_server_name"] = config["domain"].replace("matrix.", "", 1) \
if config["domain"].startswith("matrix.") else config["domain"]
config["matrix_signing_key"] = secrets.token_hex(32)
config["matrix_form_secret"] = secrets.token_hex(32)
+1 -1
View File
@@ -12,7 +12,7 @@ def gather_config(config):
print(f" {CYAN}{RESET} {GREY}Official apt repo + nginx reverse proxy{RESET}")
config["domain"] = config.get("domain") or input(
f" {CYAN}{RESET} Domain (e.g. media.example.com): "
f" {CYAN}{RESET} Domain or IP (e.g. media.example.com or 192.168.1.100): "
).strip()
config["media_library_path"] = input(
+1 -1
View File
@@ -14,7 +14,7 @@ def gather_config(config):
print(f" {CYAN}{RESET} {GREY}Pre-built binary + systemd + nginx{RESET}")
config["domain"] = config.get("domain") or input(
f" {CYAN}{RESET} Domain (e.g. vault.example.com): "
f" {CYAN}{RESET} Domain or IP (e.g. vault.example.com or 192.168.1.100): "
).strip()
config["vault_admin_token"] = input(
+12 -5
View File
@@ -292,14 +292,16 @@ def run_playbook(playbook_path, config, extra_vars=None):
line = f"{target} ansible_user={ssh_user} ansible_ssh_common_args='{ssh_args}'"
if ssh_key:
line += f" ansible_ssh_private_key_file={ssh_key}"
if ssh_user != "root":
become_pass = config.get("become_password", "")
line += " ansible_become=true"
if become_pass:
line += f" ansible_become_password={become_pass}"
with open(inv_file, "w") as f:
f.write(f"[servers]\n{line}\n")
inv_file.chmod(0o600)
cmd.extend(["-i", str(inv_file)])
# Elevate privileges for non-root users
if ssh_user != "root":
cmd.append("--become")
# Log file for full ansible output
log_file = PHANTOM_LOGS / f"deployment_{deployment_id}.log"
@@ -660,7 +662,12 @@ def gather_credentials(provider, config, service_type=None):
config["ssh_user"] = input(f" {CYAN}SSH user [{WHITE}root{RESET}]: ").strip() or "root"
existing_key = input(f" {CYAN}SSH key path (blank to generate):{RESET} ").strip()
if existing_key:
config["ssh_key"] = existing_key
config["ssh_key"] = str(Path(existing_key).expanduser().resolve())
if config["ssh_user"] != "root":
import getpass
sudo_pass = getpass.getpass(f" {CYAN}Sudo password (blank if NOPASSWD):{RESET} ")
if sudo_pass:
config["become_password"] = sudo_pass
elif provider == "local":
config["provider"] = "local"
+2 -1
View File
@@ -25,6 +25,7 @@
- certbot
- python3-certbot-nginx
state: present
when: ansible_os_family == "Debian"
- name: Remove default nginx site
file:
@@ -133,7 +134,7 @@
- name: restart php-fpm
service:
name: php8.2-fpm
name: "php{{ php_ver | default('8.2') }}-fpm"
state: restarted
- name: restart mariadb
+1 -1
View File
@@ -28,7 +28,7 @@
- name: restart php-fpm
service:
name: php8.2-fpm
name: "php{{ php_ver }}-fpm"
state: restarted
- name: restart mariadb
+2 -2
View File
@@ -64,12 +64,12 @@
- name: Configure PHP-FPM pool for Nextcloud
copy:
dest: /etc/php/8.2/fpm/pool.d/nextcloud.conf
dest: "/etc/php/{{ php_ver }}/fpm/pool.d/nextcloud.conf"
content: |
[nextcloud]
user = www-data
group = www-data
listen = /run/php/php8.2-fpm-nextcloud.sock
listen = /run/php/php{{ php_ver }}-fpm-nextcloud.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
+24 -14
View File
@@ -4,19 +4,19 @@
- 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
- php-fpm
- php-xml
- php-mbstring
- php-gd
- php-curl
- php-zip
- php-intl
- php-mysql
- php-redis
- php-apcu
- php-imagick
- php-bcmath
- php-gmp
- mariadb-server
- redis-server
- nginx
@@ -27,6 +27,16 @@
- ca-certificates
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Detect installed PHP version
command: php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;'
register: _php_ver_result
changed_when: false
- name: Set PHP version fact
set_fact:
php_ver: "{{ _php_ver_result.stdout }}"
- name: Enable and start MariaDB
service:
@@ -42,7 +52,7 @@
- name: Enable and start PHP-FPM
service:
name: php8.2-fpm
name: "php{{ php_ver }}-fpm"
state: started
enabled: true
+5 -1
View File
@@ -42,7 +42,7 @@
dest: /etc/nginx/sites-available/nextcloud
content: |
upstream php-handler {
server unix:/run/php/php8.2-fpm-nextcloud.sock;
server unix:/run/php/php{{ php_ver }}-fpm-nextcloud.sock;
}
server {
@@ -63,7 +63,11 @@
ssl_prefer_server_ciphers on;
server_tokens off;
{% if _is_selfsigned | default(false) | bool %}
add_header Strict-Transport-Security "max-age=0" always;
{% else %}
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
{% endif %}
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Robots-Tag "noindex, nofollow" always;
+9 -3
View File
@@ -27,16 +27,22 @@
name:
- ufw
- fail2ban
- unattended-upgrades
- apt-listchanges
- curl
- wget
- gnupg
- ca-certificates
- software-properties-common
state: present
when: ansible_os_family == "Debian"
- name: Install optional hardening packages
apt:
name:
- unattended-upgrades
- apt-listchanges
state: present
when: ansible_os_family == "Debian"
ignore_errors: true
# ─── UFW Firewall ───────────────────────────────────────────────────
- name: Set UFW default deny incoming
ufw:
@@ -0,0 +1,344 @@
#!/usr/bin/env bash
# Phantom — Cloudflare Tunnel Setup
# Deployed by phantom to {{ ansible_host | default('this server') }}
# Run this script to expose {{ _tls_domain }} via Cloudflare Tunnel.
# Requires: a Cloudflare account managing the domain's DNS zone.
#
# Usage:
# ./setup-tunnel.sh # Interactive — prompts for API token
# ./setup-tunnel.sh <CF_API_TOKEN> # Non-interactive — token as argument
set -euo pipefail
DOMAIN="{{ _tls_domain }}"
TUNNEL_NAME="phantom-${DOMAIN%%.*}"
echo "============================================"
echo " Phantom — Cloudflare Tunnel Setup"
echo "============================================"
echo " Domain: ${DOMAIN}"
echo " Tunnel Name: ${TUNNEL_NAME}"
echo ""
# ── Get Cloudflare API token ──────────────────────────────────────────
CF_TOKEN="${1:-}"
if [ -z "${CF_TOKEN}" ]; then
echo "[*] Cloudflare API token required."
echo " Create one at: https://dash.cloudflare.com/profile/api-tokens"
echo " Permissions needed: Zone:DNS:Edit + Account:Cloudflare Tunnel:Edit"
echo ""
read -rp " Cloudflare API token: " CF_TOKEN
echo ""
fi
if [ -z "${CF_TOKEN}" ]; then
echo "[-] No token provided."
exit 1
fi
# ── Install cloudflared ───────────────────────────────────────────────
if command -v cloudflared &>/dev/null; then
echo "[+] cloudflared already installed: $(cloudflared --version 2>&1 | head -1)"
else
echo "[*] Installing cloudflared..."
if [ -f /usr/share/keyrings/cloudflare-main.gpg ]; then
echo " Cloudflare apt repo already configured"
else
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \
| tee /etc/apt/sources.list.d/cloudflared.list >/dev/null
apt-get update -qq
fi
apt-get install -y -qq cloudflared
echo "[+] cloudflared installed"
fi
# ── Resolve Cloudflare Account ID ─────────────────────────────────────
echo "[*] Verifying API token..."
ACCOUNT_INFO=$(curl -sf -X GET "https://api.cloudflare.com/client/v4/accounts?page=1&per_page=1" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" 2>/dev/null || true)
ACCOUNT_ID=$(echo "${ACCOUNT_INFO}" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if data.get('success') and data.get('result'):
print(data['result'][0]['id'])
except: pass
" 2>/dev/null || true)
if [ -z "${ACCOUNT_ID}" ]; then
echo "[-] Failed to verify API token. Check permissions:"
echo " - Account:Cloudflare Tunnel:Edit"
echo " - Zone:DNS:Edit"
exit 1
fi
echo "[+] Account verified: ${ACCOUNT_ID}"
# ── Create or find existing tunnel via API ────────────────────────────
echo "[*] Checking for existing tunnel '${TUNNEL_NAME}'..."
TUNNEL_LIST=$(curl -sf -X GET \
"https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel?name=${TUNNEL_NAME}&is_deleted=false" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" 2>/dev/null || true)
TUNNEL_ID=$(echo "${TUNNEL_LIST}" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if data.get('success') and data.get('result'):
print(data['result'][0]['id'])
except: pass
" 2>/dev/null || true)
CREATED_NEW_TUNNEL=false
if [ -n "${TUNNEL_ID}" ]; then
echo "[+] Tunnel '${TUNNEL_NAME}' already exists: ${TUNNEL_ID}"
# Check if we have valid credentials on disk
CRED_FILE="/etc/cloudflared/${TUNNEL_ID}.json"
if [ -f "${CRED_FILE}" ]; then
EXISTING_SECRET=$(python3 -c "
import json, sys
try:
with open('${CRED_FILE}') as f:
d = json.load(f)
s = d.get('TunnelSecret', '')
if s: print(s)
except: pass
" 2>/dev/null || true)
if [ -n "${EXISTING_SECRET}" ]; then
echo "[+] Existing credentials file is valid"
else
echo "[-] Credentials file exists but has empty secret — tunnel must be recreated"
echo "[*] Deleting tunnel '${TUNNEL_NAME}' to recreate with new credentials..."
curl -sf -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" >/dev/null 2>&1 || true
TUNNEL_ID=""
fi
else
echo "[-] No credentials file found — tunnel must be recreated"
echo "[*] Deleting tunnel '${TUNNEL_NAME}' to recreate with new credentials..."
curl -sf -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" >/dev/null 2>&1 || true
TUNNEL_ID=""
fi
fi
if [ -z "${TUNNEL_ID}" ]; then
echo "[*] Creating tunnel '${TUNNEL_NAME}'..."
TUNNEL_SECRET=$(python3 -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())")
CREATE_RESP=$(curl -sf -X POST \
"https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" \
--data "{\"name\": \"${TUNNEL_NAME}\", \"tunnel_secret\": \"${TUNNEL_SECRET}\", \"config_src\": \"local\"}" \
2>/dev/null || true)
TUNNEL_ID=$(echo "${CREATE_RESP}" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if data.get('success') and data.get('result'):
print(data['result']['id'])
else:
errors = data.get('errors', [])
if errors:
print('ERROR:' + errors[0].get('message', 'unknown'), file=sys.stderr)
except: pass
" 2>/dev/null || true)
if [ -z "${TUNNEL_ID}" ] || [[ "${TUNNEL_ID}" == ERROR:* ]]; then
echo "[-] Failed to create tunnel."
echo "${CREATE_RESP}" | python3 -m json.tool 2>/dev/null || echo "${CREATE_RESP}"
exit 1
fi
echo "[+] Tunnel created: ${TUNNEL_ID}"
CREATED_NEW_TUNNEL=true
fi
# ── Write credentials + config ────────────────────────────────────────
echo "[*] Writing tunnel configuration..."
mkdir -p /etc/cloudflared
# Write credentials file only for newly created tunnels
if [ "${CREATED_NEW_TUNNEL}" = true ]; then
python3 -c "
import json
creds = {'AccountTag': '${ACCOUNT_ID}', 'TunnelID': '${TUNNEL_ID}', 'TunnelSecret': '${TUNNEL_SECRET}'}
with open('/etc/cloudflared/${TUNNEL_ID}.json', 'w') as f:
json.dump(creds, f)
"
chmod 600 "/etc/cloudflared/${TUNNEL_ID}.json"
echo "[+] Credentials file written"
fi
cat > /etc/cloudflared/config.yml << CFEOF
tunnel: ${TUNNEL_ID}
credentials-file: /etc/cloudflared/${TUNNEL_ID}.json
ingress:
- hostname: ${DOMAIN}
service: https://localhost:443
originRequest:
noTLSVerify: true
- service: http_status:404
CFEOF
chmod 600 /etc/cloudflared/config.yml
echo "[+] Config written"
# ── Route DNS ─────────────────────────────────────────────────────────
echo "[*] Setting up DNS route for ${DOMAIN}..."
# Get zone ID for the domain
BASE_DOMAIN=$(echo "${DOMAIN}" | rev | cut -d. -f1-2 | rev)
ZONE_INFO=$(curl -sf -X GET \
"https://api.cloudflare.com/client/v4/zones?name=${BASE_DOMAIN}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" 2>/dev/null || true)
ZONE_ID=$(echo "${ZONE_INFO}" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if data.get('success') and data.get('result'):
print(data['result'][0]['id'])
except: pass
" 2>/dev/null || true)
if [ -z "${ZONE_ID}" ]; then
echo "[-] Could not find zone for ${BASE_DOMAIN}"
echo " Add the CNAME manually: ${DOMAIN} → ${TUNNEL_ID}.cfargotunnel.com"
else
# Check for existing record
EXISTING_RECORD=$(curl -sf -X GET \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${DOMAIN}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" 2>/dev/null || true)
RECORD_ID=$(echo "${EXISTING_RECORD}" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if data.get('success') and data.get('result'):
print(data['result'][0]['id'])
except: pass
" 2>/dev/null || true)
TUNNEL_CNAME="${TUNNEL_ID}.cfargotunnel.com"
if [ -n "${RECORD_ID}" ]; then
# Update existing record to point to tunnel
echo " Updating existing DNS record..."
curl -sf -X PUT \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" \
--data "{\"type\":\"CNAME\",\"name\":\"${DOMAIN}\",\"content\":\"${TUNNEL_CNAME}\",\"proxied\":true}" \
>/dev/null 2>&1
echo "[+] DNS record updated: ${DOMAIN} → tunnel (proxied)"
else
# Create new CNAME
curl -sf -X POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" \
--data "{\"type\":\"CNAME\",\"name\":\"${DOMAIN}\",\"content\":\"${TUNNEL_CNAME}\",\"proxied\":true}" \
>/dev/null 2>&1
echo "[+] DNS CNAME created: ${DOMAIN} → tunnel (proxied)"
fi
fi
# ── Create service account ───────────────────────────────────────────
if id cloudflared &>/dev/null; then
echo "[+] cloudflared user already exists"
else
echo "[*] Creating cloudflared service account..."
useradd --system --no-create-home --shell /usr/sbin/nologin cloudflared
echo "[+] cloudflared service account created"
fi
# Fix ownership on config and credentials
chown -R cloudflared:cloudflared /etc/cloudflared
# ── Install systemd service ──────────────────────────────────────────
echo "[*] Installing cloudflared systemd service..."
# Stop existing service if running
systemctl stop cloudflared 2>/dev/null || true
# Remove any existing unit from `cloudflared service install` (runs as root)
rm -f /etc/systemd/system/cloudflared.service 2>/dev/null || true
cat > /etc/systemd/system/cloudflared.service << SVCEOF
[Unit]
Description=cloudflared tunnel
After=network-online.target
Wants=network-online.target
[Service]
User=cloudflared
Group=cloudflared
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
Restart=on-failure
RestartSec=5s
# Hardening
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
ReadWritePaths=/etc/cloudflared
ProtectClock=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
RestrictRealtime=true
[Install]
WantedBy=multi-user.target
SVCEOF
systemctl daemon-reload
systemctl enable cloudflared
systemctl start cloudflared
echo "[+] cloudflared service started"
# ── Verify ────────────────────────────────────────────────────────────
echo ""
echo "[*] Waiting for tunnel to connect..."
sleep 5
if systemctl is-active --quiet cloudflared; then
echo "[+] cloudflared service is running"
else
echo "[-] cloudflared service failed to start"
echo " Check: journalctl -u cloudflared --no-pager -n 20"
exit 1
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://${DOMAIN}/" --max-time 10 2>/dev/null || echo "000")
if [ "${HTTP_CODE}" != "000" ] && [ "${HTTP_CODE}" != "502" ] && [ "${HTTP_CODE}" != "503" ]; then
echo "[+] Tunnel verified — https://${DOMAIN}/ returned HTTP ${HTTP_CODE}"
else
echo "[!] Tunnel may need a moment to propagate."
echo " DNS changes can take 1-5 minutes."
echo " Test: curl -I https://${DOMAIN}/"
fi
echo ""
echo "============================================"
echo " Cloudflare Tunnel setup complete!"
echo ""
echo " Domain: https://${DOMAIN}"
echo " Tunnel: ${TUNNEL_NAME} (${TUNNEL_ID})"
echo " Service: systemctl status cloudflared"
echo ""
echo " To remove: cloudflared tunnel delete ${TUNNEL_NAME}"
echo "============================================"
+54 -6
View File
@@ -1,7 +1,16 @@
---
# Shared TLS setup: tries certbot, falls back to self-signed
# Shared TLS setup: tries certbot for public domains, self-signed for LAN
# Include with: include_tasks: ../common/tls_setup.yml
# Required vars: _tls_domain (the domain to get a cert for)
# Outputs: _ssl_cert, _ssl_key, _is_selfsigned
- name: Detect if domain is public (not IP, not .local/.lan/.home/.internal/.test)
set_fact:
_is_public_domain: >-
{{ _tls_domain is not regex('^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$')
and _tls_domain is not regex('\.(local|lan|home|internal|test)$')
and _tls_domain is not regex('^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)')
and _tls_domain is not regex('^(localhost|127\.)') }}
- name: Attempt TLS certificate from Let's Encrypt
command: >
@@ -18,33 +27,66 @@
creates: "/etc/letsencrypt/live/{{ _tls_domain }}/fullchain.pem"
register: _certbot_result
ignore_errors: true
when: _is_public_domain | bool
- name: Generate self-signed certificate (fallback)
- name: Init certbot result as failed for LAN deployments
set_fact:
_certbot_result: { "failed": true, "skipped": true }
when: not (_is_public_domain | bool)
- name: Generate self-signed certificate with SAN (IP)
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 }}"
-addext "subjectAltName=IP:{{ _tls_domain }}"
args:
creates: "/etc/ssl/certs/{{ _tls_domain }}-selfsigned.crt"
when: _certbot_result is failed
when:
- _certbot_result is failed
- _tls_domain is regex('^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$')
- name: Generate self-signed certificate with SAN (hostname)
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 }}"
-addext "subjectAltName=DNS:{{ _tls_domain }}"
args:
creates: "/etc/ssl/certs/{{ _tls_domain }}-selfsigned.crt"
when:
- _certbot_result is failed
- _tls_domain is not regex('^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$')
- 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"
_is_selfsigned: false
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"
_is_selfsigned: true
when: _certbot_result is failed
- name: Warn about self-signed certificate
- name: LAN deployment — using 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
msg: "LAN deployment detected — using self-signed TLS cert for {{ _tls_domain }}"
when:
- _certbot_result is failed
- not (_is_public_domain | bool)
- name: Certbot failed for public domain — falling back to self-signed
debug:
msg: "Certbot failed for {{ _tls_domain }} — using self-signed cert. Point DNS to this server and run: certbot certonly --webroot -w /var/www/certbot -d {{ _tls_domain }}"
when:
- _certbot_result is failed
- _is_public_domain | bool
- name: Create Tools directory
file:
@@ -57,3 +99,9 @@
src: ../common/templates/setup-cert.sh.j2
dest: /root/Tools/setup-cert.sh
mode: "0700"
- name: Deploy Cloudflare Tunnel setup script
template:
src: ../common/templates/setup-tunnel.sh.j2
dest: /root/Tools/setup-tunnel.sh
mode: "0700"
@@ -44,7 +44,6 @@
- "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
- "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt"
when: pihole_blocklist == "aggressive"
notify: update gravity
- name: Update gravity after blocklist changes
command: pihole -g
+1
View File
@@ -12,6 +12,7 @@
- cron
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Stop systemd-resolved (conflicts with Pi-hole on port 53)
service:
@@ -5,6 +5,7 @@
apt:
name: nginx
state: present
when: ansible_os_family == "Debian"
- name: Create Element Web directory
file:
+4
View File
@@ -82,7 +82,11 @@
ssl_session_tickets off;
# Security headers
{% if _is_selfsigned | default(false) | bool %}
add_header Strict-Transport-Security "max-age=0" always;
{% else %}
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
{% endif %}
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
@@ -16,6 +16,7 @@
- gnupg
- lsb-release
state: present
when: ansible_os_family == "Debian"
- name: Add Matrix Synapse GPG key and repository
shell: |
@@ -12,6 +12,7 @@
- python3-certbot-nginx
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Add Jellyfin GPG signing key
ansible.builtin.get_url:
@@ -30,6 +31,7 @@
name: jellyfin
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Allow HTTP/HTTPS through UFW
ufw:
+4
View File
@@ -59,7 +59,11 @@
ssl_prefer_server_ciphers on;
server_tokens off;
{% if _is_selfsigned | default(false) | bool %}
add_header Strict-Transport-Security "max-age=0" always;
{% else %}
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
{% endif %}
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
@@ -1,6 +1,11 @@
---
# Vaultwarden binary installation from GitHub releases
- name: Check for unsupported 32-bit ARM architecture
fail:
msg: "Vaultwarden does not support 32-bit ARM (armv7l). Use a 64-bit OS (aarch64) or x86_64 system."
when: ansible_architecture == "armv7l"
- name: Install prerequisites
apt:
name:
@@ -13,6 +18,7 @@
- ca-certificates
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Create vaultwarden system user
user:
+4
View File
@@ -59,7 +59,11 @@
ssl_prefer_server_ciphers on;
server_tokens off;
{% if _is_selfsigned | default(false) | bool %}
add_header Strict-Transport-Security "max-age=0" always;
{% else %}
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
{% endif %}
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
+72 -42
View File
@@ -6,6 +6,11 @@
register: primary_interface
changed_when: false
- name: Set computed vars
set_fact:
_iface: "{{ primary_interface.stdout | trim }}"
_subnet_base: "{{ wg_subnet.split('.')[0:3] | join('.') }}"
- name: Generate client keys
shell: |
mkdir -p /etc/wireguard/clients
@@ -19,65 +24,69 @@
args:
creates: /etc/wireguard/clients/client1_private.key
- name: Ensure ip6tables nat module is available
modprobe:
name: ip6table_nat
state: present
ignore_errors: true
- name: Build WireGuard server config
shell: |
SERVER_PRIVKEY=$(cat /etc/wireguard/server_private.key)
IFACE={{ primary_interface.stdout | trim }}
cat > /etc/wireguard/wg0.conf << 'WGEOF'
[Interface]
WGEOF
# Header + PrivateKey (needs shell expansion)
echo "[Interface]" > /etc/wireguard/wg0.conf
echo "Address = {{ _subnet_base }}.1/24" >> /etc/wireguard/wg0.conf
echo "ListenPort = {{ wg_port }}" >> /etc/wireguard/wg0.conf
echo "PrivateKey = ${SERVER_PRIVKEY}" >> /etc/wireguard/wg0.conf
echo "SaveConfig = false" >> /etc/wireguard/wg0.conf
# Write dynamic values separately to avoid heredoc variable issues
cat >> /etc/wireguard/wg0.conf << EOF
Address = {{ wg_subnet | regex_replace('/\\d+$', '') | regex_replace('\\.[0-9]+$', '.1/24') }}
ListenPort = {{ wg_port }}
PrivateKey = ${SERVER_PRIVKEY}
SaveConfig = false
# IPv4: forward VPN traffic to internet, masquerade source
PostUp = iptables -A FORWARD -i wg0 -o ${IFACE} -j ACCEPT
PostUp = iptables -A FORWARD -i ${IFACE} -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT
PostUp = iptables -t nat -A POSTROUTING -o ${IFACE} -s {{ wg_subnet }} -j MASQUERADE
# IPv6: same rules to prevent leaks
PostUp = ip6tables -A FORWARD -i wg0 -o ${IFACE} -j ACCEPT
PostUp = ip6tables -A FORWARD -i ${IFACE} -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT
PostUp = ip6tables -t nat -A POSTROUTING -o ${IFACE} -j MASQUERADE
# Block VPN clients from accessing server-local services (except DNS if local)
# PostUp/PostDown rules (Jinja2 vars, no shell expansion needed)
cat >> /etc/wireguard/wg0.conf << 'RULESEOF'
PostUp = iptables -A FORWARD -i wg0 -o {{ _iface }} -j ACCEPT
PostUp = iptables -A FORWARD -i {{ _iface }} -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
PostUp = iptables -t nat -A POSTROUTING -o {{ _iface }} -s {{ wg_subnet }} -j MASQUERADE
PostUp = ip6tables -A FORWARD -i wg0 -o {{ _iface }} -j ACCEPT || true
PostUp = ip6tables -A FORWARD -i {{ _iface }} -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT || true
PostUp = ip6tables -t nat -A POSTROUTING -o {{ _iface }} -j MASQUERADE || true
PostUp = iptables -I INPUT -i wg0 -p tcp --dport 22 -j DROP
PostUp = iptables -I INPUT -i wg0 -p tcp --dport 80 -j DROP
PostUp = iptables -I INPUT -i wg0 -p tcp --dport 443 -j DROP
PostDown = iptables -D FORWARD -i wg0 -o ${IFACE} -j ACCEPT
PostDown = iptables -D FORWARD -i ${IFACE} -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o ${IFACE} -s {{ wg_subnet }} -j MASQUERADE
PostDown = ip6tables -D FORWARD -i wg0 -o ${IFACE} -j ACCEPT
PostDown = ip6tables -D FORWARD -i ${IFACE} -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT
PostDown = ip6tables -t nat -D POSTROUTING -o ${IFACE} -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -o {{ _iface }} -j ACCEPT
PostDown = iptables -D FORWARD -i {{ _iface }} -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o {{ _iface }} -s {{ wg_subnet }} -j MASQUERADE
PostDown = ip6tables -D FORWARD -i wg0 -o {{ _iface }} -j ACCEPT || true
PostDown = ip6tables -D FORWARD -i {{ _iface }} -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT || true
PostDown = ip6tables -t nat -D POSTROUTING -o {{ _iface }} -j MASQUERADE || true
PostDown = iptables -D INPUT -i wg0 -p tcp --dport 22 -j DROP
PostDown = iptables -D INPUT -i wg0 -p tcp --dport 80 -j DROP
PostDown = iptables -D INPUT -i wg0 -p tcp --dport 443 -j DROP
RULESEOF
EOF
# Peers
for i in $(seq 1 {{ wg_clients }}); do
CLIENT_PUBKEY=$(cat /etc/wireguard/clients/client${i}_public.key)
CLIENT_PSK=$(cat /etc/wireguard/clients/client${i}_psk.key)
cat >> /etc/wireguard/wg0.conf << EOF
[Peer]
# Client ${i}
PublicKey = ${CLIENT_PUBKEY}
PresharedKey = ${CLIENT_PSK}
AllowedIPs = {{ wg_subnet | regex_replace('/\\d+$', '') | regex_replace('\\.[0-9]+$', '') }}.$(( i + 1 ))/32
EOF
echo "" >> /etc/wireguard/wg0.conf
echo "[Peer]" >> /etc/wireguard/wg0.conf
echo "PublicKey = ${CLIENT_PUBKEY}" >> /etc/wireguard/wg0.conf
echo "PresharedKey = ${CLIENT_PSK}" >> /etc/wireguard/wg0.conf
echo "AllowedIPs = {{ _subnet_base }}.$(( i + 1 ))/32" >> /etc/wireguard/wg0.conf
done
chmod 600 /etc/wireguard/wg0.conf
args:
creates: /etc/wireguard/wg0.conf
- name: Verify WireGuard config
shell: cat /etc/wireguard/wg0.conf
register: wg_config_check
changed_when: false
- name: Show generated config (debug)
debug:
var: wg_config_check.stdout_lines
- name: Generate client config files
shell: |
SERVER_PUBKEY=$(echo '{{ server_privkey_content.content | b64decode | trim }}' | wg pubkey)
@@ -86,9 +95,9 @@
for i in $(seq 1 {{ wg_clients }}); do
CLIENT_PRIVKEY=$(cat /etc/wireguard/clients/client${i}_private.key)
CLIENT_PSK=$(cat /etc/wireguard/clients/client${i}_psk.key)
CLIENT_IP="{{ wg_subnet | regex_replace('/\\d+$', '') | regex_replace('\\.[0-9]+$', '') }}.$(( i + 1 ))"
CLIENT_IP="{{ _subnet_base }}.$(( i + 1 ))"
cat > /etc/wireguard/clients/client${i}.conf << EOF
cat > /etc/wireguard/clients/client${i}.conf << CLIENTEOF
[Interface]
PrivateKey = ${CLIENT_PRIVKEY}
Address = ${CLIENT_IP}/32
@@ -100,14 +109,35 @@
Endpoint = ${SERVER_ENDPOINT}
AllowedIPs = {{ wg_allowed_ips }}
PersistentKeepalive = 25
EOF
CLIENTEOF
# Generate QR code
qrencode -t ansiutf8 < /etc/wireguard/clients/client${i}.conf > /etc/wireguard/clients/client${i}_qr.txt 2>/dev/null || true
done
args:
creates: /etc/wireguard/clients/client1.conf
- name: Create WireGuard systemd override directory
file:
path: /etc/systemd/system/wg-quick@wg0.service.d
state: directory
mode: "0755"
- name: Deploy WireGuard systemd hardening override
copy:
dest: /etc/systemd/system/wg-quick@wg0.service.d/hardening.conf
content: |
[Service]
ProtectHome=true
ProtectClock=true
ProtectHostname=true
ProtectKernelLogs=true
PrivateTmp=true
mode: "0644"
- name: Reload systemd daemon
systemd:
daemon_reload: true
- name: Enable and start WireGuard
systemd:
name: wg-quick@wg0