Proxy con Túnel Cloudflare: VLESS+WebSocket+TLS auto-alojado

Tutorial completo para un proxy autenticado auto-alojado que permite que tu teléfono salga a internet con la IP de tu casa, incluso cuando estás en una WiFi hostil o viajando al extranjero. La máquina de casa no tiene IP pública ni puertos abiertos. El Túnel de Cloudflare transporta el tráfico; Xray (VLESS sobre WebSocket sobre TLS 1.3) maneja autenticación y enrutamiento; v2RayTun en Android es el cliente. Incluye arquitectura, modelo de amenazas, pasos de instalación, cinco pruebas de verificación, blindaje always-on, guía de viaje, rotación de secretos, desmontaje, solución de problemas, costo y las alternativas que rechacé (Tailscale, WireGuard, VPN comercial, Cloudflare WARP).

Cloudflare TunnelcloudflaredXrayVLESSWebSocketTLS 1.3v2RayTunHiddifySing-BoxDocker ComposeRootless DockerWSL2systemdUUID authDNS leakTravelBanking IPNo Public IP

1. Qué es esto y qué no lo es

Instalación

El problema

Cuando viajas, cada app de banca, contenido geo-bloqueado o detección de fraude ve la IP del WiFi o de la red celular en la que estás físicamente. Eso dispara MFA adicional, bloquea transacciones o simplemente no carga. La solución es hacer que esas apps salgan a internet con la misma IP residencial que ven siempre desde casa — sin exponer la máquina de casa al internet público.

Objetivos

  • Todo el tráfico del teléfono sale por la IP del ISP de tu casa, sin importar dónde esté el teléfono.
  • Cero puertos abiertos en la máquina de casa. Sin redirección de puertos en el router. Sin IP pública (funciona detrás de CGNAT).
  • TLS 1.3 extremo a extremo entre teléfono y proxy. Autenticación por UUID y un path de WebSocket secreto de 128 bits, así los escaneos aleatorios solo ven un 404.
  • Gratis: plan Free de Cloudflare Tunnel + tu ISP actual + un dominio que ya tengas.
  • Sobrevive reinicios, cortes de luz y rarezas de WSL2. Un cron watchdog levanta el stack en menos de 5 minutos.

No-objetivos

  • No es anti-censura. Si vives detrás del Gran Cortafuegos chino, necesitas Reality, no VLESS+WS+TLS clásico — aunque el mismo binario de Xray soporta ambos.
  • No es VPN corporativa. No hay SSO, ni postura de dispositivo, ni DLP. Es un electrodoméstico de un solo usuario.
  • No es un kill switch. Si el túnel muere, el teléfono cae a la red local. Corre las pruebas de la Sección 6 todos los días mientras viajas.

2. Arquitectura y modelo de amenazas

Arquitectura

Camino de datos extremo a extremo

Tres máquinas, dos fronteras de confianza, cero puertos entrantes en ningún lado. El teléfono habla VLESS-sobre-WebSocket-sobre-TLS contra un edge anycast de Cloudflare. El edge reenvía por un Cloudflare Tunnel que la máquina de casa marcó hacia afuera — no hay puerto a la escucha del lado de casa. Dentro de la máquina, cloudflared entrega HTTP plano por un bridge privado de Docker a Xray, que autentica el UUID, sniffea el destino y finalmente sale a internet con la IP de tu casa.

 +------------------------+     wss://proxy.example.com/<random-path>     +----------------------+
 | Android phone          |     ------ VLESS over WebSocket over TLS --- | Cloudflare edge      |
 | v2RayTun (TUN mode,    | -->                                          | (anycast, ~20 ms     |
 | captures all traffic)  |                                              |  from anywhere)      |
 +------------------------+                                              +----------+-----------+
                                                                                    |
                                                       Cloudflare Tunnel (outbound  |
                                                       dialed by cloudflared, no    |
                                                       inbound port at home)        |
                                                                                    v
 +-------------------------------------------------------------------------------------------------+
 |  Home machine  (WSL2 / Linux)                                                                   |
 |                                                                                                 |
 |   +---------------------------+   plain HTTP on docker bridge   +-----------------------------+ |
 |   | proxy-cloudflared         | ------------------------------> | proxy-xray                  | |
 |   | (cloudflare/cloudflared)  |   (proxy-net, host-only)        | (ghcr.io/xtls/xray-core)    | |
 |   | outbound dial to CF edge  |                                 | VLESS+WS server, UUID auth, | |
 |   | no inbound port           |                                 | blocks RFC1918 egress       | |
 |   +---------------------------+                                 +--------------+--------------+ |
 |                                                                                |                |
 |                                                                                v                |
 |                                                                  outbound TCP/UDP to internet — |
 |                                                                  egress IP is your residential  |
 |                                                                  IP. Banks, country-locked      |
 |                                                                  apps, etc., see this address.  |
 +-------------------------------------------------------------------------------------------------+

Modelo del adversario

Adversario = quien observe la red WiFi o celular en la que está el teléfono, portales cautivos, resolutores DNS hostiles, fisgones oportunistas en el camino entre el teléfono y Cloudflare. El modelo asume que la máquina de casa es confiable; si te sacan root en el host, se acabó el juego.

Defensas

  • Todo el tráfico teléfono↔Cloudflare va en TLS 1.3 (AES-256-GCM o CHACHA20-POLY1305).
  • Todo el tráfico Cloudflare↔casa va en cifrado de túnel mutuamente autenticado (token de cloudflared).
  • La autenticación es un UUID de 128 bits — la fuerza bruta es inviable.
  • El path del WebSocket es un secreto de 128 bits — los escaneos aleatorios de proxy.example.com/ reciben 404 y nunca ven el endpoint WebSocket.
  • La regla de routing de Xray bloquea cualquier salida a rangos RFC1918 — previene SSRF hacia tu LAN de casa.

Lo que NO se defiende

  • Malware en el teléfono (vive dentro del túnel).
  • Compromiso de la máquina de casa (root en el host = juego perdido).
  • Apps de banca que detectan la huella digital de la propia interfaz TUN (raro, pero posible).

3. Prerrequisitos

Instalación

Cuentas e infraestructura

  • Un dominio real en tu propia cuenta de Cloudflare. El pages.dev gratuito no sirve — el hostname del túnel debe vivir en una zona de Cloudflare. Usa un dominio personal (.com, .co, .dev, etc.). No reutilices una cuenta corporativa de Cloudflare.
  • Máquina Linux que esté en línea casi siempre. Esta guía asume Fedora en WSL2 sobre Windows; Linux nativo funciona idéntico; macOS funciona con pequeños ajustes de rutas.
  • Global API Key de Cloudflare desde dash.cloudflare.com/profile/api-tokens → "Global API Key" → View. Trátala como contraseña. Un API token con scopes también funciona; la guía usa la Global Key por simplicidad.

Software en el host

  • Docker (rootless preferido — más seguro, instalación un poco más larga; rootful con sudo docker funciona igual).
  • Plugin docker compose. Viene en las instalaciones modernas de Docker.
  • Herramientas: openssl, uuidgen, qrencode, curl, python3. En Fedora: sudo dnf install -y qrencode si falta.

Teléfono

Probado en Samsung S24 Ultra con Android 16. Cualquier Android 7+ funciona. iOS funciona igual de bien con Streisand, Shadowrocket o Hiddify — el mismo share-link vless://.

4. Constrúyelo desde cero

Paso a paso

4.1 Genera dos secretos

El stack lleva dos secretos criptográficos: un UUID que autentica al cliente y un path hex de 128 bits que esconde el endpoint WebSocket detrás del mecanismo "los escaneos aleatorios reciben 404". Ambos se generan localmente con herramientas estándar — sin servicios de terceros.

PROXY_UUID=$(uuidgen)
PROXY_WSPATH=$(openssl rand -hex 16)
echo "UUID=$PROXY_UUID"
echo "WSPATH=$PROXY_WSPATH"

Mantén ambos a la vista; van al config de Xray y al share-link vless://.

4.2 Crea el config de Xray

VLESS habla sin cifrado de capa de aplicación a propósito (decryption: none) porque TLS ya hace ese trabajo; el bloque de routing manda a blackhole los destinos RFC1918 para prevenir SSRF hacia la LAN de casa.

mkdir -p ~/proxy-stack/xray && cd ~/proxy-stack
cat > xray/config.json <<EOF
{
  "log": { "loglevel": "warning" },
  "inbounds": [
    {
      "tag": "vless-ws-in",
      "listen": "0.0.0.0",
      "port": 8080,
      "protocol": "vless",
      "settings": {
        "clients": [
          { "id": "${PROXY_UUID}", "level": 0, "email": "[email protected]" }
        ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "ws",
        "security": "none",
        "wsSettings": { "path": "/${PROXY_WSPATH}", "headers": {} }
      },
      "sniffing": {
        "enabled": true,
        "destOverride": ["http", "tls", "quic"],
        "routeOnly": false
      }
    }
  ],
  "outbounds": [
    { "tag": "direct",  "protocol": "freedom",   "settings": { "domainStrategy": "UseIPv4v6" } },
    { "tag": "blocked", "protocol": "blackhole", "settings": {} },
    { "tag": "dns-out", "protocol": "dns" }
  ],
  "dns": {
    "servers": ["1.1.1.1", "1.0.0.1", "8.8.8.8"],
    "queryStrategy": "UseIPv4v6"
  },
  "routing": {
    "domainStrategy": "AsIs",
    "rules": [
      { "type": "field", "ip": ["geoip:private"], "outboundTag": "blocked" },
      { "type": "field", "protocol": ["bittorrent"], "outboundTag": "blocked" }
    ]
  }
}
EOF

4.3 Crea el stack docker-compose

Dos servicios en un bridge solo-host: cloudflared marca al edge de Cloudflare (sin puerto entrante) y proxiea hacia xray en el puerto 8080 dentro del bridge. Los logs están acotados para que esto no llene el disco durante un viaje largo.

cat > docker-compose.yml <<'EOF'
name: proxy-stack

services:
  xray:
    image: ghcr.io/xtls/xray-core:latest
    container_name: proxy-xray
    restart: unless-stopped
    networks: [ proxy-net ]
    volumes:
      - ./xray/config.json:/etc/xray/config.json:ro
    command: ["run", "-c", "/etc/xray/config.json"]
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }

  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: proxy-cloudflared
    restart: unless-stopped
    networks: [ proxy-net ]
    depends_on: [ xray ]
    command: ["tunnel", "--no-autoupdate", "--metrics", "0.0.0.0:2000", "run", "--token", "${CF_TUNNEL_TOKEN}"]
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }

networks:
  proxy-net:
    name: proxy-net
    driver: bridge
EOF

cat > .env <<EOF
PROXY_UUID=${PROXY_UUID}
PROXY_WSPATH=${PROXY_WSPATH}
PROXY_HOSTNAME=proxy.example.com
CF_TUNNEL_TOKEN=PASTE_TOKEN_AFTER_4.4
EOF
chmod 600 .env

4.4 Crea el Cloudflare Tunnel vía API

Esto se puede hacer también en el dashboard (Zero Trust → Networks → Tunnels → Create), pero la API es más rápida, scriptable y reproducible. El flujo: validar la key, descubrir zona y cuenta, crear el túnel, obtener el token del conector, configurar el hostname público y la regla de ingress, y crear un CNAME que apunte al túnel.

CF_KEY="<your Global API Key from dash.cloudflare.com/profile/api-tokens>"
CF_EMAIL="[email protected]"
DOMAIN="example.com"      # your zone
SUB="proxy"               # subdomain
HEAD=(-H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" -H "Content-Type: application/json")

# 1. Verify the key works
curl -sS "${HEAD[@]}" "https://api.cloudflare.com/client/v4/user" \
  | python3 -c "import sys,json;d=json.load(sys.stdin);print('user:', d['result']['email']) if d.get('success') else print('auth FAILED:', d.get('errors'))"

# 2. Find the zone and the account it belongs to
ZONE_JSON=$(curl -sS "${HEAD[@]}" "https://api.cloudflare.com/client/v4/zones?name=$DOMAIN")
ZONE_ID=$(echo "$ZONE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
ACCOUNT_ID=$(echo "$ZONE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['account']['id'])")
ACCOUNT_NAME=$(echo "$ZONE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['account']['name'])")
echo "Zone:    $ZONE_ID"
echo "Account: $ACCOUNT_ID  ($ACCOUNT_NAME)   <-- confirm this is YOUR personal account"

# 3. Create the tunnel
TUNNEL_SECRET=$(openssl rand -base64 32)
TUNNEL_JSON=$(curl -sS "${HEAD[@]}" -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel" \
  --data "{\"name\":\"proxy-stack\",\"tunnel_secret\":\"$TUNNEL_SECRET\",\"config_src\":\"cloudflare\"}")
TUNNEL_ID=$(echo "$TUNNEL_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin)['result']['id'])")

# 4. Fetch the connector token
CF_TUNNEL_TOKEN=$(curl -sS "${HEAD[@]}" \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/token" \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['result'])")

# 5. Configure ingress: $SUB.$DOMAIN -> http://xray:8080
curl -sS "${HEAD[@]}" -X PUT \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/configurations" \
  --data "{\"config\":{\"ingress\":[
    {\"hostname\":\"$SUB.$DOMAIN\",\"service\":\"http://xray:8080\"},
    {\"service\":\"http_status:404\"}
  ]}}" > /dev/null

# 6. Create the CNAME
curl -sS "${HEAD[@]}" -X POST \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
  --data "{\"type\":\"CNAME\",\"name\":\"$SUB\",\"content\":\"$TUNNEL_ID.cfargotunnel.com\",\"proxied\":true,\"ttl\":1}" > /dev/null

# 7. Persist values for tear-down / watchdog
echo "$CF_KEY" > cf_api_key.secret && chmod 600 cf_api_key.secret
sed -i "s|^CF_TUNNEL_TOKEN=.*|CF_TUNNEL_TOKEN=$CF_TUNNEL_TOKEN|"  .env
{ echo "CF_EMAIL=$CF_EMAIL"; echo "CF_ACCOUNT_ID=$ACCOUNT_ID"; echo "CF_ZONE_ID=$ZONE_ID"; echo "CF_TUNNEL_ID=$TUNNEL_ID"; } >> .env

4.5 Levanta el stack

Pull, start, y observa a cloudflared registrar cuatro conexiones HA contra cuatro POPs distintos de Cloudflare. Luego verifica que el hostname público devuelve 404 (prueba que la cadena es alcanzable) y que el path WebSocket secreto devuelve HTTP/1.1 101 Switching Protocols.

docker compose pull
docker compose up -d
sleep 5
docker compose ps

# Watch cloudflared register its 4 HA connections to the CF edge
docker logs proxy-cloudflared 2>&1 | grep -i "Registered tunnel connection"
# Expected: 4 lines, each with a different CF POP (mia05, bog01, etc.)

# Catch-all 404 from xray (proves CF -> tunnel -> xray is reachable)
curl -sI https://proxy.example.com/ | head -3

# WS upgrade at the secret path -- must return 101
curl -i --http1.1 \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  https://proxy.example.com/$PROXY_WSPATH 2>&1 | head -5
# Expected: HTTP/1.1 101 Switching Protocols

5. Configuración del teléfono (Android)

Móvil

5.1 Elige cliente

v2rayNG es el cliente canónico pero no está en Google Play. Cualquiera de los siguientes funciona — todos aceptan el mismo share-link vless://.

AppOrigenPor qué
v2RayTunplay.google.com/store/apps/details?id=com.v2raytun.androidEl más liviano, lo más cercano a v2rayNG. Recomendado.
Hiddify Nextplay.google.com/store/apps/details?id=app.hiddify.comUI más limpia.
Sing-Boxplay.google.com/store/apps/details?id=io.nekohasekai.sfaMotor más nuevo, configuración JSON.

iOS: instala Streisand, Shadowrocket o Hiddify desde la App Store — el mismo share-link funciona.

5.2 Genera el share-link

El enlace codifica UUID, host, puerto, transporte y el path WebSocket secreto. Renderízalo como QR para que el teléfono lo escanee.

LABEL=$(printf 'proxy-stack@%s' "proxy.example.com" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read()))')
PATH_ENC=$(printf '/%s' "$PROXY_WSPATH" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read(),safe=""))')
SHARELINK="vless://${PROXY_UUID}@proxy.example.com:443?encryption=none&security=tls&sni=proxy.example.com&type=ws&host=proxy.example.com&path=${PATH_ENC}#${LABEL}"
echo "$SHARELINK" > share-link.txt
cat share-link.txt

# Render as QR for easy phone scan
qrencode -t UTF8i -m 2 -s 1 "$(cat share-link.txt)"

5.3 Importa en el teléfono

  1. Abre v2RayTun → toca el + arriba a la derecha → Escanear código QR.
  2. Apunta la cámara al QR de la terminal. El perfil se importa como [email protected].
  3. Abre Ajustes → DNS: pon Remote DNS = 1.1.1.1, Domestic DNS = 1.1.1.1 y activa Fake DNS. Es la protección más fuerte contra fugas.
  4. De vuelta en la pantalla principal, toca el botón de play. Android pide permiso para la conexión VPN — acepta.
  5. Corre las cinco pruebas de la Sección 6.

6. Verificar la instalación — cinco pruebas reales

Pruebas

Visita cada URL con el proxy conectado

Abre Chrome en el teléfono con el proxy activo y visita cada URL siguiente. Las cinco deben mostrar tu país, IP y ASN de casa — nunca el país de la red WiFi/celular en la que estás físicamente.

#URLEsperado
1https://ifconfig.co/jsonip = tu IP pública de casa, country = tu país, asn_org = el ISP de tu casa.
2https://1.1.1.1/cdn-cgi/traceloc = código de tu país, colo = el POP de CF más cercano a casa (mia05, bog01, etc.).
3https://browserleaks.com/dnsLa lista de DNS Servers muestra Cloudflare o el ISP de tu casa — nunca el resolutor de la red WiFi.
4https://www.dnsleaktest.com → Standard testMismo resultado que la prueba 3.
5https://ipv6-test.com/Solo IPv4 (enrutamos IPv4) y sin fugas.

Si alguna prueba muestra la ubicación equivocada, salta a la Sección 13 — Solución de problemas.

7. Operaciones

Operación

Helper proxyctl.sh

Guarda este pequeño wrapper junto a docker-compose.yml. Expone una UX basada en verbos sobre las cuatro operaciones diarias: levantar, estado, logs y los generadores de share-link / QR.

cat > proxyctl.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
export DOCKER_HOST=${DOCKER_HOST:-unix:///run/user/1000/docker.sock}
[ -f .env ] && set -a && . .env && set +a

case "${1:-help}" in
  up)        docker compose pull && docker compose up -d && docker compose ps ;;
  down)      docker compose down ;;
  restart)   docker compose restart && docker compose ps ;;
  logs)      docker compose logs -f --tail=100 "${2:-}" ;;
  status)    docker compose ps ;;
  share-link)
    : "${PROXY_UUID:?}"; : "${PROXY_HOSTNAME:?}"; : "${PROXY_WSPATH:?}"
    LABEL=$(printf 'proxy-stack@%s' "$PROXY_HOSTNAME" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read()))')
    PATH_ENC=$(printf '/%s' "$PROXY_WSPATH" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read(),safe=""))')
    echo "vless://${PROXY_UUID}@${PROXY_HOSTNAME}:443?encryption=none&security=tls&sni=${PROXY_HOSTNAME}&type=ws&host=${PROXY_HOSTNAME}&path=${PATH_ENC}#${LABEL}"
    ;;
  qr) "$0" share-link | qrencode -t UTF8i -o - ;;
  *) echo "Usage: $0 {up|down|restart|logs [svc]|status|share-link|qr}" ;;
esac
EOF
chmod +x proxyctl.sh

Operaciones comunes: ./proxyctl.sh up, status, logs, logs xray, share-link, qr, restart, down.

Watchdog (cron, cada 5 minutos)

El contenedor cloudflared puede quedarse Up mientras su túnel está muerto — raro, pero pasa. La verdad autoritativa vive en la API de Cloudflare. El watchdog le pregunta directo a Cloudflare, y si el túnel reporta cualquier cosa distinta a healthy con al menos una conexión, reinicia el stack.

cat > watchdog.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
[ -f .env ] && set -a && . .env && set +a
export DOCKER_HOST=${DOCKER_HOST:-unix:///run/user/1000/docker.sock}
log() { echo "$(date -u +%FT%TZ) $*"; }
restart_stack() { log "restarting: $1"; docker compose restart >/dev/null 2>&1 || docker compose up -d >/dev/null 2>&1; }

docker ps --filter name=proxy-cloudflared --filter status=running --format '{{.Names}}' | grep -q proxy-cloudflared \
  || { restart_stack "cloudflared container down"; exit 0; }
docker ps --filter name=proxy-xray --filter status=running --format '{{.Names}}' | grep -q proxy-xray \
  || { restart_stack "xray container down"; exit 0; }

[ -z "${CF_ACCOUNT_ID:-}" ] || [ -z "${CF_TUNNEL_ID:-}" ] && exit 0
[ -f cf_api_key.secret ] || exit 0
CF_KEY=$(cat cf_api_key.secret)
STATUS=$(curl -sS --max-time 8 \
  -H "X-Auth-Email: ${CF_EMAIL:[email protected]}" -H "X-Auth-Key: $CF_KEY" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$CF_TUNNEL_ID" \
  | python3 -c "import sys,json;d=json.load(sys.stdin)['result'];print(f\"{d.get('status','?')}|{len(d.get('connections',[]))}\")")
health=${STATUS%|*}; conns=${STATUS#*|}
if [ "$health" != "healthy" ] || [ "$conns" -lt 1 ]; then
  restart_stack "CF says status=$health conns=$conns"
fi
EOF
chmod +x watchdog.sh

# Install the cron entry (every 5 minutes, append output to watchdog.log)
( crontab -l 2>/dev/null; echo "*/5 * * * * $HOME/proxy-stack/watchdog.sh >> $HOME/proxy-stack/watchdog.log 2>&1" ) | crontab -

8. Blindaje always-on

Operación

8.1 La cadena de arranque (específica de WSL2)

Windows boots
  -- Windows Task Scheduler runs "wsl --boot" task at SYSTEM startup
      -- WSL distro starts (because vmIdleTimeout=-1 in ~/.wslconfig)
          -- /etc/wsl.conf systemd=true -- systemd starts inside WSL
              -- [email protected] starts (because loginctl enable-linger)
                  -- ~/.config/systemd/user/docker.service starts (rootless dockerd)
                      -- proxy-xray + proxy-cloudflared resurrect (restart: unless-stopped)

8.2 Configura cada eslabón de la cadena

# (a) WSL2 doesn't sleep
mkdir -p /mnt/c/Users/$USER 2>/dev/null
cat > /mnt/c/Users/$USER/.wslconfig <<'EOF'
[wsl2]
memory=64GB           # adjust to your RAM
processors=24         # adjust to your cores
vmIdleTimeout=-1      # never auto-suspend
EOF

# (b) systemd inside WSL (needed for rootless docker.service)
sudo tee /etc/wsl.conf >/dev/null <<EOF
[boot]
systemd=true

[user]
default = $USER
EOF

# (c) lingering -- user services run without an interactive session
sudo loginctl enable-linger $USER

# (d) install rootless docker (skip if you already have it)
curl -fsSL https://get.docker.com/rootless | sh
systemctl --user enable --now docker

# (e) restart WSL to pick everything up
# From PowerShell on Windows:    wsl --shutdown
# Then re-open WSL and verify:
systemctl --user is-enabled docker      # -> enabled
systemctl --user is-active  docker      # -> active
loginctl show-user $USER | grep Linger  # -> Linger=yes

8.3 Haz que Windows arranque WSL al boot, no solo al login

Corre esto una vez en una PowerShell de Administrador. La tarea programada se dispara al arranque del SISTEMA, antes de que cualquier humano inicie sesión, y deja un proceso ocioso sleep infinity dentro de WSL para que la distro se mantenga caliente.

$action  = New-ScheduledTaskAction  -Execute "wsl.exe" -Argument "-d <YOUR-DISTRO> -u <YOUR-USER> -e bash -c 'sleep infinity'"
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
Register-ScheduledTask -TaskName "WSL2-KeepAlive" -Action $action -Trigger $trigger -Principal $principal

Reemplaza <YOUR-DISTRO> con la salida de wsl -l -q y <YOUR-USER> con tu usuario de WSL.

8.4 BIOS — encendido automático tras restablecer la red

Si la máquina de casa pierde corriente mientras estás fuera, Windows debe arrancar solo cuando vuelva la red. En la BIOS / UEFI, busca uno de estos ajustes (el nombre varía por board) y actívalo:

  • AC Power Recovery = Power On
  • Restore on AC/Power Loss = Last State or Power On
  • State After Power Failure = Always On

Verifícalo antes de viajar. Desenchufa, vuelve a enchufar; la máquina debe arrancar en unos 30 segundos.

9. Modelo de seguridad — qué va cifrado y dónde

Seguridad

Capas de cifrado, en orden

  1. Teléfono ↔ edge de Cloudflare: TLS 1.3, autenticado por el servidor con el certificado SSL universal que Cloudflare emite para tu zona. AES-256-GCM o CHACHA20-POLY1305, llaves ECDHE.
  2. Edge de Cloudflare ↔ cloudflared en casa: el propio Cloudflare Tunnel, autenticado mutuamente con el token del conector (32 bytes secretos).
  3. cloudflared ↔ Xray dentro de Docker: HTTP plano sobre un bridge de Docker solo-host. Este es el único salto sin cifrar, por diseño — ambos endpoints viven en la misma máquina, detrás de un namespace de red privado.
  4. Xray ↔ el internet abierto: lo que negocie el destino. Las apps HTTPS siguen HTTPS; el proxy nunca ve su texto plano.

"encryption: none" en VLESS — explicado

El share-link contiene encryption=none&security=tls. La gente entra en pánico con la primera parte. Está bien. VLESS es el sucesor de VMess; remueve a propósito el cifrado de capa de aplicación porque la capa de transporte (TLS) ya lo provee. Hacer cripto encima de TLS solía ser una huella que los sistemas de censura detectaban — VLESS lo eliminó. La mitad security=tls del share-link es lo que garantiza el cifrado.

echo | openssl s_client -connect proxy.example.com:443 \
  -servername proxy.example.com -tls1_3 2>/dev/null \
  | grep -E 'Protocol|Cipher|subject|issuer' | head -6
# Expect: Protocol: TLSv1.3, ECDHE+AES-256-GCM cipher

Cifrado y fugas de DNS

Las consultas DNS UDP de las apps del teléfono entran al TUN de v2RayTun, se encapsulan en VLESS-mux (que lleva UDP dentro del WebSocket), atraviesan hasta Xray, y Xray las resuelve por la conexión de casa. Los resolutores DNS ven consultas desde tu IP de casa, no desde la red en la que está el teléfono.

En v2RayTun Ajustes → DNS: Remote DNS = 1.1.1.1, Domestic DNS = 1.1.1.1 (forzar el mismo — no hacer bypass), Enable Fake DNS = ON (asigna IPs sintéticas; el sniffing del lado servidor recupera el dominio real — la protección más fuerte contra fugas).

10. Guía de viaje

Operación

Modos de falla y mitigaciones

RiesgoMitigación
La casa pierde corrienteAuto-encendido por BIOS (Sección 8.4) + el módem del ISP en UPS si es posible
El router WiFi de casa se reiniciaEl cron watchdog levanta los contenedores; Cloudflare se reconecta automáticamente
El hostname de Cloudflare queda zombieEl watchdog detecta "status≠healthy" y reinicia el stack
Al teléfono se le acaba la bateríaPower bank en el equipaje de mano
Un portal cautivo bloquea WSSUsa datos móviles; el túnel solo necesita HTTPS, así que casi cualquier red funciona
Te roban el teléfonoRota UUID + WSPATH (Sección 11) y re-impórtalo en el teléfono nuevo
Olvidas el share-link estando fueraGuárdalo en tu gestor de contraseñas ANTES de viajar. O corre ./proxyctl.sh share-link por SSH/Tailscale dentro de la máquina de casa.

Antes de salir

  1. Prueba de arranque: wsl --shutdown en PowerShell, espera 30 segundos, abre WSL de nuevo, corre docker ps — ambos contenedores deben estar Up.
  2. Reinicio total de Windows. Espera 5 minutos. No inicies sesión. Desde el teléfono (todavía en casa, en otra WiFi/datos móviles), conecta v2RayTun y verifica https://ifconfig.co.
  3. Desconecta el cable de la pared. Espera 30 segundos. Vuelve a conectar. Repite la prueba.
  4. Guarda tu share-link en un gestor de contraseñas. Guarda también el ID del túnel y el ID de cuenta de CF — útiles si necesitas recuperar algo de forma remota.

Ya estando fuera

  • Conecta v2RayTun antes de abrir apps sensibles.
  • Corre la prueba de fugas #2 (https://1.1.1.1/cdn-cgi/trace) una vez al día. Si loc alguna vez muestra el país en el que estás, el proxy no está cargando ese tráfico.
  • Si un portal cautivo exige una página de login, desconecta el proxy un momento, completa el portal cautivo en el navegador y vuelve a conectar.

11. Rotación de secretos

Seguridad

Rota UUID + path de WebSocket

Hazlo si crees que un secreto se filtró — share-link enviado por email plano, teléfono perdido, etc. El túnel de Cloudflare y el registro DNS se mantienen iguales; solo se regenera la autenticación del lado de Xray, así que no hacen falta llamadas a la API.

cd ~/proxy-stack
NEW_UUID=$(uuidgen)
NEW_PATH=$(openssl rand -hex 16)
OLD_UUID=$(grep '^PROXY_UUID=' .env | cut -d= -f2)
OLD_PATH=$(grep '^PROXY_WSPATH=' .env | cut -d= -f2)

sed -i "s|$OLD_UUID|$NEW_UUID|g" .env xray/config.json
sed -i "s|$OLD_PATH|$NEW_PATH|g" .env xray/config.json
./proxyctl.sh restart
./proxyctl.sh share-link    # re-import on the phone

Rota el túnel mismo

Si se filtró el token del conector, borra el túnel y recréalo vía API (corre de nuevo la sección 4.4 con el mismo dominio y subdominio). El CNAME de DNS se actualiza solo porque cambia el ID del túnel.

12. Desmontaje

Operación

Borrar todo limpiamente

Detén los contenedores, quita el cron watchdog, borra el registro DNS, borra el túnel, y luego limpia el directorio de trabajo para que todos los secretos desaparezcan del disco.

# 1. Stop containers
cd ~/proxy-stack && docker compose down

# 2. Remove cron watchdog
crontab -l | grep -v "proxy-stack/watchdog.sh" | crontab -

# 3. Delete the CF tunnel + DNS record (use the .env values)
. ./.env
HEAD=(-H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $(cat cf_api_key.secret)" -H "Content-Type: application/json")

# Find and delete the DNS record
DNS_ID=$(curl -sS "${HEAD[@]}" \
  "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?name=$PROXY_HOSTNAME" \
  | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['result'][0]['id'] if d['result'] else '')")
[ -n "$DNS_ID" ] && curl -sS "${HEAD[@]}" -X DELETE \
  "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$DNS_ID" >/dev/null

# Delete the tunnel
curl -sS "${HEAD[@]}" -X DELETE \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$CF_TUNNEL_ID?cascade=true" >/dev/null

# 4. Wipe the working dir (kills all secrets)
cd ~ && rm -rf ~/proxy-stack

En el teléfono: borra el perfil de v2RayTun. Listo.

13. Recetario de solución de problemas

Operación

"v2RayTun dice ‘connection refused’ / ‘EOF’"

Algo rechazó el handshake TLS. Revisa cómo se ve el túnel desde afuera:

curl -vI https://proxy.example.com/ 2>&1 | grep -E 'subject|issuer|HTTP'
# Cert subject must mention proxy.example.com or *.example.com

Si el certificado es para otro nombre, el "Universal SSL" de tu zona aún no cubre el subdominio — espera 10 minutos.

"El upgrade WS devuelve 400 en lugar de 101"

Estás mandando HTTP/2 con cabeceras Connection: Upgrade, lo cual es ilegal. Fuerza HTTP/1.1 explícitamente:

curl --http1.1 -H "Connection: Upgrade" ...

El cliente del teléfono ya usa HTTP/1.1; esto solo importa en pruebas manuales con curl.

"El teléfono conecta, pero ifconfig.co muestra el país de la WiFi"

v2RayTun se conectó, pero el enrutamiento está fugando. Revisa las reglas de ruteo en el teléfono — el modo VPN debe ser Global / TUN, no split-tunnel. Desactiva cualquier whitelist por app. Re-corre las cinco pruebas de la Sección 6.

"Funcionaba ayer, hoy en la mañana está muerto"

El cron watchdog debería haberlo capturado. Revisa:

tail -50 ~/proxy-stack/watchdog.log
docker ps --filter name=proxy-

Si WSL mismo se murió (raro), wsl --shutdown desde PowerShell, luego re-abre una terminal WSL — la tarea KeepAlive debería resucitarlo.

"El túnel de Cloudflare aparece como ‘down’ en el dashboard"

Inspecciona los logs de cloudflared y confirma que el token del conector siga coincidiendo con el del dashboard. Si alguna vez lo revocaste, regenera el token vía API y actualiza .env:

docker logs proxy-cloudflared --tail 100 | grep -iE 'error|unauth|registered'
docker compose restart cloudflared

14. Costo

Operación

Desglose de costo mensual

ÍtemMensual
Cloudflare Tunnel (plan Free)$0
DNS / proxy de Cloudflare en la zona$0
Ancho de banda por el edge de Cloudflare$0 (lo cubre el plan de tu ISP; CF no mide el ancho de banda del túnel)
Cómputo en la máquina de casaElectricidad de la máquina siempre encendida
Renovación del dominio~$10/año según el TLD

Total recurrente: básicamente gratis, más electricidad. El mayor costo es el tiempo de la instalación inicial, que esta guía está diseñada para comprimir a más o menos una noche.

15. Por qué rechacé las alternativas obvias

Arquitectura

Consideradas y descartadas

AlternativaRazón del descarte
Cloudflare WARP / Zero Trust + WARP ConnectorRequiere un servidor Linux para el Connector; te encierra en la UI de Zero Trust; consume uno de los 50 asientos gratis; el WARP puro del teléfono enruta a cualquier lado pero no sale por la IP de tu casa.
WireGuard via cloudflaredcloudflared no transporta UDP; necesitarías Cloudflare Spectrum, que es de pago.
OpenVPN over TCP via cloudflaredFunciona, pero exige la app OpenVPN Connect (más pesada) y el modo TCP de OpenVPN es lento.
Plain SOCKS5 / HTTP proxySin soporte UDP; no es system-wide en Android sin herramientas extra.
TailscaleExcelente producto, pero es Tailscale, no Cloudflare — frontera de confianza distinta, red anycast distinta, modelo de amenazas distinto.
Native Android VPN (IKEv2 / L2TP)La UI nativa es genial, pero estos protocolos requieren exposición directa por UDP (puerto 500/4500), lo cual rompe el requisito "sin IP pública".
Self-hosted Headscale + WireGuardLo más flexible a largo plazo; más piezas móviles que CF Tunnel + Xray. Vale la pena si superas esta arquitectura.
Commercial VPN (NordVPN, Mullvad, etc.)Sale por la IP del proveedor de VPN — los bancos lo tratan como sospechoso y disparan MFA / bloqueos. El punto entero aquí es salir por tu IP residencial de casa.

El stack elegido (cloudflared + Xray VLESS+WS+TLS + v2RayTun) es el conjunto más pequeño de piezas móviles que entrega TCP/UDP/DNS completo, sin IP pública, con clientes móviles maduros y costo recurrente cero.