diff --git a/cloudflared/README.md b/cloudflared/README.md new file mode 100644 index 0000000..d7f6e22 --- /dev/null +++ b/cloudflared/README.md @@ -0,0 +1,198 @@ +# Cloudflare Tunnel (cloudflared) + +Conecta el servidor a Cloudflare sin abrir puertos. Todo el tráfico entra por la red de Cloudflare, +llega al contenedor `cloudflared`, y este lo reenvía a Traefik. + +``` +Internet → Cloudflare Edge → cloudflared → Traefik → servicios +``` + +**Ventajas sobre exposición directa:** +- Tu IP pública nunca es visible — CrowdSec solo ve IPs de Cloudflare (allowlisteadas) +- Funciona desde redes con CGNAT, CG-NAT corporativo o sin IPv4 fija +- Cloudflare absorbe DDoS y filtra bots antes de que lleguen al servidor + +> **Estado actual**: el tunnel está **parado** (`docker stop cloudflared`). +> Los DNS apuntan directo a `193.70.84.224` (registros A, sin proxy Cloudflare). +> Ver sección [Activar / desactivar el tunnel](#activar--desactivar-el-tunnel). + +--- + +## Configuración inicial (una sola vez) + +### 1. Crear el tunnel en Cloudflare + +1. Ve a [dash.cloudflare.com](https://dash.cloudflare.com) → **Zero Trust** → **Networks** → **Conectores** +2. **Create connector** → nombre: `homeserver` +3. Selecciona **Docker** → copia el **token** (cadena larga tras `--token`) + +### 2. Crear un API Token + +En [dash.cloudflare.com](https://dash.cloudflare.com) → **My Profile** → **API Tokens** → **Create Token** + +Permisos mínimos necesarios: + +| Scope | Recurso | Permiso | +|---------|----------------------------|---------| +| Account | Cloudflare Tunnel | Edit | +| Zone | DNS (sherlockhomeless.net) | Edit | + +> El API Token también lo usa `add-domain.sh` en modo directo (solo necesita Zone > DNS > Edit). + +### 3. Configurar el `.env` + +```bash +cp stack.env .env +nano .env +``` + +Variables obligatorias para **ambos modos**: + +```env +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ZONE_ID=f7c4e16f9a434a947ad0f266b2e1f894 +CLOUDFLARE_DOMAIN=sherlockhomeless.net +CLOUDFLARE_SERVER_IP=193.70.84.224 # IP para registros A directos +``` + +Variables adicionales solo necesarias **con tunnel activo**: + +```env +CLOUDFLARE_TUNNEL_TOKEN= +CLOUDFLARE_ACCOUNT_ID=328f4b942827169b11f7d1f11c640522 +CLOUDFLARE_TUNNEL_ID=d405a2a2-69a1-40f1-b939-04cea5d60a50 +``` + +--- + +## Añadir un nuevo subdominio + +### Modo directo (sin tunnel, estado actual) + +Crea un registro `A` apuntando a la IP del servidor, sin proxy de Cloudflare: + +```bash +cd cloudflared +./add-domain.sh immich +./add-domain.sh immich recipes calibre # varios a la vez +``` + +El script: +1. Detecta la IP del servidor desde `CLOUDFLARE_SERVER_IP` (o la autodetecta via `api.ipify.org`) +2. Crea o actualiza el registro DNS `A` con proxy desactivado (nube gris) + +### Modo tunnel (cuando el tunnel esté activo) + +Crea un registro `CNAME` al tunnel con proxy naranja y añade el hostname al ingress: + +```bash +cd cloudflared +./add-domain.sh --tunnel immich +./add-domain.sh --tunnel immich recipes calibre +``` + +El script: +1. Lee el ingress actual del tunnel desde la API de Cloudflare +2. Añade el hostname apuntando a `https://traefik:443` (noTLSVerify) +3. Guarda el ingress actualizado +4. Crea o actualiza el registro DNS `CNAME` con proxy naranja + +--- + +## Activar / desactivar el tunnel + +### Activar + +```bash +# 1. Levantar el contenedor +cd cloudflared +docker compose --env-file .env up -d + +# 2. Migrar los DNS al tunnel (convierte A records → CNAME proxied) +./add-domain.sh --tunnel auth traefik gitea n8n nextcloud paperless paperless-ai \ + notas memos vikunja karakeep kasm remote beszel dozzle crowdsec grafana llm \ + ocode adblock onlyoffice www cockpit coolify kopia notes opencode portainer \ + uptimekuma webtop wg + +# 3. Verificar conexión +docker logs cloudflared | grep "Registered tunnel" +``` + +### Desactivar + +```bash +# 1. Parar el contenedor (no se levantará solo al reiniciar) +docker stop cloudflared + +# 2. Revertir DNS a registros A directos +./add-domain.sh auth traefik gitea n8n nextcloud paperless paperless-ai \ + notas memos vikunja karakeep kasm remote beszel dozzle crowdsec grafana llm \ + ocode adblock onlyoffice www cockpit coolify kopia notes opencode portainer \ + uptimekuma webtop wg +``` + +--- + +## Subdominios registrados + +| Subdominio | Servicio | +|------------|----------| +| `auth` | Authentik SSO | +| `traefik` | Traefik Dashboard | +| `gitea` | Gitea | +| `n8n` | n8n | +| `nextcloud` | Nextcloud | +| `paperless` | Paperless-ngx | +| `paperless-ai` | Paperless AI | +| `notas` | Trilium | +| `memos` | Memos | +| `vikunja` | Vikunja | +| `karakeep` | Karakeep | +| `kasm` | Kasm Workspaces | +| `remote` | Guacamole | +| `beszel` | Beszel | +| `dozzle` | Dozzle | +| `crowdsec` | CrowdSec Grafana | +| `grafana` | Grafana | +| `llm` | LiteLLM | +| `ocode` | OpenCode | +| `adblock` | AdGuard | +| `onlyoffice` | OnlyOffice | +| `www` | Homepage | +| `cockpit` | Cockpit | +| `coolify` | Coolify | +| `kopia` | Kopia | +| `notes` | Notes | +| `opencode` | OpenCode (alt) | +| `portainer` | Portainer | +| `uptimekuma` | Uptime Kuma | +| `webtop` | Webtop | +| `wg` | WireGuard | + +--- + +## Comandos útiles + +```bash +# Ver estado del tunnel +docker logs cloudflared + +# Ver todos los subdominios en DNS de Cloudflare +curl -s "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records?per_page=100" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" | \ + python3 -c "import sys,json; [print(r['type'].ljust(6), r['name']) for r in json.load(sys.stdin)['result']]" + +# Ver ingress del tunnel configurado en Cloudflare +curl -s "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${CLOUDFLARE_TUNNEL_ID}/configurations" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" | \ + python3 -c "import sys,json; [print(r.get('hostname','*'), '->', r['service']) for r in json.load(sys.stdin)['result']['config']['ingress']]" +``` + +--- + +## Seguridad + +- **API Token**: permisos mínimos (Tunnel Edit + DNS Edit). Rótalo periódicamente. +- **Tunnel Token**: si se compromete, regénéralo en el dashboard y reinicia el contenedor con el nuevo token en `.env`. +- **CrowdSec con tunnel activo**: las IPs de Cloudflare están en la allowlist `cloudflare`. El bouncer usa `forwardedHeadersTrustedIPs` con los rangos de CF para ver la IP real del cliente. +- **CrowdSec sin tunnel**: el bouncer ve la IP pública directa del cliente. El bot de Telegram (`/allow `) puede desbanear y allowlistear en CrowdSec, fail2ban y firewalld simultáneamente. diff --git a/cloudflared/add-domain.sh b/cloudflared/add-domain.sh new file mode 100755 index 0000000..a397cfa --- /dev/null +++ b/cloudflared/add-domain.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Registra subdominios en Cloudflare DNS y opcionalmente en el tunnel. +# +# Modos: +# ./add-domain.sh [sub2 ...] → A record directo (sin tunnel) +# ./add-domain.sh --tunnel [sub2 ...] → CNAME al tunnel + ingress +# +# Ejemplos: +# ./add-domain.sh immich → A 193.70.84.224, solo DNS +# ./add-domain.sh --tunnel immich obsidian → CNAME tunnel + ingress +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: No se encontró .env en $SCRIPT_DIR" + exit 1 +fi + +set -a; source "$ENV_FILE"; set +a + +# ── Modo ──────────────────────────────────────────────────────────────────── +MODE="direct" +if [ "${1:-}" = "--tunnel" ]; then + MODE="tunnel" + shift +fi + +if [ $# -eq 0 ]; then + echo "Uso: $0 [--tunnel] [subdominio2 ...]" + exit 1 +fi + +# ── Validar variables obligatorias ────────────────────────────────────────── +REQUIRED_VARS="CLOUDFLARE_API_TOKEN CLOUDFLARE_ZONE_ID CLOUDFLARE_DOMAIN" +[ "$MODE" = "tunnel" ] && REQUIRED_VARS="$REQUIRED_VARS CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_TUNNEL_ID" + +for VAR in $REQUIRED_VARS; do + if [ -z "${!VAR:-}" ] || [[ "${!VAR}" == change-me* ]]; then + echo "ERROR: $VAR no está configurado en .env" + exit 1 + fi +done + +SERVER_IP="${CLOUDFLARE_SERVER_IP:-}" +if [ "$MODE" = "direct" ] && [ -z "$SERVER_IP" ]; then + # Autodetectar IP pública del servidor + SERVER_IP=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null || true) + if [ -z "$SERVER_IP" ]; then + echo "ERROR: No se pudo detectar la IP del servidor. Define CLOUDFLARE_SERVER_IP en .env" + exit 1 + fi + echo "→ IP del servidor autodetectada: $SERVER_IP" +fi + +# ── Modo tunnel: obtener ingress actual ───────────────────────────────────── +TUNNEL_CNAME="${CLOUDFLARE_TUNNEL_ID:-}.cfargotunnel.com" +CURRENT="" +if [ "$MODE" = "tunnel" ]; then + echo "→ Obteniendo configuración actual del tunnel..." + CURRENT=$(curl -s \ + "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${CLOUDFLARE_TUNNEL_ID}/configurations" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}") + if ! echo "$CURRENT" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('success') else 1)" 2>/dev/null; then + echo "ERROR: No se pudo obtener la config del tunnel" + echo "$CURRENT" + exit 1 + fi +fi + +# ── Por cada subdominio ────────────────────────────────────────────────────── +for SUB in "$@"; do + FQDN="${SUB}.${CLOUDFLARE_DOMAIN}" + echo "" + echo "=== $FQDN ===" + + # Tunnel: añadir al ingress + if [ "$MODE" = "tunnel" ]; then + CURRENT=$(echo "$CURRENT" | python3 -c " +import sys, json +data = json.load(sys.stdin) +ingress = data['result']['config']['ingress'] +hostname = '${FQDN}' +if any(r.get('hostname') == hostname for r in ingress): + print(json.dumps(data), end='') + print(' tunnel: ya existe, sin cambios', file=sys.stderr) + sys.exit(0) +catch_all = ingress.pop() +ingress.append({'hostname': hostname, 'service': 'https://traefik:443', 'originRequest': {'noTLSVerify': True}}) +ingress.append(catch_all) +data['result']['config']['ingress'] = ingress +print(json.dumps(data), end='') +print(' tunnel: añadido', file=sys.stderr) +") + DNS_PAYLOAD="{\"type\":\"CNAME\",\"name\":\"${SUB}\",\"content\":\"${TUNNEL_CNAME}\",\"ttl\":1,\"proxied\":true,\"comment\":\"Cloudflare Tunnel\"}" + DNS_TARGET="$TUNNEL_CNAME (CNAME proxied)" + else + DNS_PAYLOAD="{\"type\":\"A\",\"name\":\"${SUB}\",\"content\":\"${SERVER_IP}\",\"ttl\":1,\"proxied\":false,\"comment\":\"Direct\"}" + DNS_TARGET="$SERVER_IP (A record)" + fi + + # DNS: crear o actualizar + EXISTING=$(curl -s \ + "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records?name=${FQDN}" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}") + RECORD_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result'][0]['id'])" 2>/dev/null || true) + + if [ -n "$RECORD_ID" ]; then + RESULT=$(curl -s -X PUT \ + "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${RECORD_ID}" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$DNS_PAYLOAD") + MSG="actualizado" + else + RESULT=$(curl -s -X POST \ + "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$DNS_PAYLOAD") + MSG="creado" + fi + + if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('success') else 1)" 2>/dev/null; then + echo " DNS: $MSG → $DNS_TARGET" + else + echo " DNS ERROR:" + echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('errors'))" + fi +done + +# ── Guardar ingress del tunnel ─────────────────────────────────────────────── +if [ "$MODE" = "tunnel" ]; then + echo "" + echo "→ Guardando configuración del tunnel..." + NEW_CONFIG=$(echo "$CURRENT" | python3 -c " +import sys, json +data = json.load(sys.stdin) +print(json.dumps({'config': data['result']['config']})) +") + RESULT=$(curl -s -X PUT \ + "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${CLOUDFLARE_TUNNEL_ID}/configurations" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$NEW_CONFIG") + if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('success') else 1)" 2>/dev/null; then + echo " Tunnel ingress actualizado ✓" + else + echo " ERROR al actualizar tunnel:" + echo "$RESULT" + exit 1 + fi +fi + +echo "" +echo "Listo. DNS activo en ~30s." diff --git a/cloudflared/docker-compose.yml b/cloudflared/docker-compose.yml new file mode 100644 index 0000000..7e5dea1 --- /dev/null +++ b/cloudflared/docker-compose.yml @@ -0,0 +1,20 @@ +services: + cloudflared: + image: ${CLOUDFLARED_IMAGE} + container_name: cloudflared + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN} + TZ: ${TZ:-America/Mexico_City} + networks: + - proxy + healthcheck: + test: ["CMD", "cloudflared", "tunnel", "info"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + proxy: + external: true diff --git a/cloudflared/stack.env b/cloudflared/stack.env new file mode 100644 index 0000000..554f59d --- /dev/null +++ b/cloudflared/stack.env @@ -0,0 +1,20 @@ +CLOUDFLARED_IMAGE=cloudflare/cloudflared:latest + +# Token del tunnel — dash.cloudflare.com → Zero Trust → Networks → Conectores +CLOUDFLARE_TUNNEL_TOKEN=change-me + +# Credenciales API — dash.cloudflare.com → My Profile → API Tokens +# Permisos necesarios: Account > Cloudflare Tunnel > Edit, Zone > DNS > Edit +CLOUDFLARE_API_TOKEN=change-me +CLOUDFLARE_ACCOUNT_ID=change-me +CLOUDFLARE_TUNNEL_ID=change-me +CLOUDFLARE_ZONE_ID=change-me + +# Dominio raíz +CLOUDFLARE_DOMAIN=sherlockhomeless.net + +# IP pública del servidor (para registros A directos sin tunnel) +# Si no se define, el script la autodetecta via api.ipify.org +CLOUDFLARE_SERVER_IP=1.2.3.4 + +TZ=America/Mexico_City