Proxy con curl: configuración, autenticación y errores

15 min de lectura

Configura curl con un proxy HTTP o SOCKS5, separa -U de -u, lee CONNECT, http_proxy y los códigos 407, 7, 28 y 60.

Esta guía es para quien lanza curl por un proxy HTTP o SOCKS5 y necesita separar autenticación, CONNECT y variables de entorno. Las comprobaciones de este texto usaron orígenes en loopback, no una cuenta de proveedor. No hay claves gratis y Top Proxy no vende IP. Un navegador en Mi IP muestra otra ruta.

01Cómo indicar un proxy HTTP en curl

-x / --proxy apunta al proxy. La URL final es el destino. Son esquemas distintos. Un proxy HTTP delante de un sitio HTTPS suele verse así, y no convierte el proxy en un "proxy HTTPS" solo porque el destino lleva https://.

El binario comprobado fue curl 8.7.1 (27 de marzo de 2024), libcurl/8.7.1 con SecureTransport, LibreSSL 3.3.6, nghttp2 y la lista de features que incluye HTTPS-proxy, MultiSSL y AsynchDNS. Un manual en línea puede describir flags que este binario no tiene. Empieza cada invocación con -q para que un curlrc heredado no meta proxy, CA ni usuario. Pasa argumentos en un array, no en un string que el shell expanda.

everything curl, HTTP proxy (consultado el 14 de octubre de 2026) dice que -x / --proxy asume un proxy HTTP y que, sin puerto, el valor histórico es 1080. En verbose curl se conecta al proxy, no al origen, y la línea de petición cambia.

  1. Fija el binario con curl -q --version. Esta corrida usó curl 8.7.1.
  2. Arranca con curl -q. El resto de flags va detrás.
  3. Pon -x http://127.0.0.1:PUERTO y un destino HTTP que controles.
  4. Lee el verbose y, si puedes, el log del proxy. El estado 200 solo no dice por qué puerto salió el GET.

En el laboratorio del 14 de octubre de 2026, con entorno hijo limpio, la línea

# PLACEHOLDERS. No es el laboratorio ejecutado.
curl -q -sS -v --max-time 8 --noproxy '' \
  -x http://127.0.0.1:PUERTO_PROXY \
  -U 'USUARIO:CONTRASEÑA' \
  http://destino.ejemplo/

conectó al puerto del proxy, no al origen, y envió forma absoluta:

* Connected to 127.0.0.1 (127.0.0.1) port PUERTO_PROXY
> GET http://curl-es-lab.origin:PUERTO_ORIGEN/ HTTP/1.1
> Host: curl-es-lab.origin:PUERTO_ORIGEN

El cuerpo del origen devolvió origin-http|/. Eso prueba esa ruta de laboratorio. No es un exit residencial ni móvil. El GET directo, sin -x, fue a 127.0.0.1 del origen con GET / relativo y el proxy se quedó quieto. Ese control importa. Sin él, un 200 "parece que funciona" y no sabes si el proxy existió.

--noproxy '' en los casos que deben usar proxy evita que un bypass de loopback esconda un aserto fallido. Si el destino es 127.0.0.1 y tu entorno trae NO_PROXY amplio, curl puede ir directo y tú celebras un falso positivo.

El puerto HTTP y el puerto SOCKS de un proveedor casi nunca coinciden. Mezclar -x http:// contra un puerto SOCKS es un error de protocolo. Esta corrida no capturó ese cruce: no hay un caso de reset ni de basura en los 23 resultados. Mira en el panel qué puerto está etiquetado como HTTP CONNECT y cuál como SOCKS5. El checker de proxy sirve para ver si un host responde, no para certificar que tu proceso curl usa esa misma salida.

Protocolo e IP son ejes distintos. HTTP o SOCKS5 no convierten un datacenter en residencial.

02Cómo tunelizar HTTPS con CONNECT

La gente llama "proxy HTTPS" al hecho de abrir https://. El salto del medio sigue siendo, casi siempre, un proxy HTTP que habla CONNECT. everything curl lo describe así: HTTPS pide un túnel, curl manda CONNECT, el proxy abre TCP y el cifrado corre entre cliente y origen. El proxy no debería leer ese TLS si el túnel es real.

No hace falta https:// en la URL del proxy para un destino HTTPS. Distingue TLS hacia el origen de TLS hacia un proxy HTTPS. --cacert en esta prueba controló el origen de laboratorio. Un proxy con TLS propio es otra capa. Este laboratorio no la midió. Trátala como hueco, no como éxito.

  1. Deja el proxy en http://127.0.0.1:PUERTO aunque el destino sea https://.
  2. Confía el origen con --cacert apuntando a tu CA. No uses -k.
  3. Confirma CONNECT en verbose o en el log del proxy antes de cantar victoria por un 200.
  4. Repite sin CA. Quieres un fallo TLS que aun así haya hecho CONNECT.

Sin CA, CONNECT salió autorizado y curl cerró con código 60. El túnel existió. El certificado no. Eso no es "el proxy caído". Un 200 en el navegador tampoco sustituye esta ruta: Mi IP habla del navegador.

El verbose del caso bueno, ya redactado, mostró:

* Establish HTTP proxy tunnel to curl-es-lab.origin:PUERTO_TLS
> CONNECT curl-es-lab.origin:PUERTO_TLS HTTP/1.1

y luego el cuerpo origin-https|/. El fixture reenvió bytes TLS de verdad. Un 200 falso de CONNECT sin túnel no habría entregado ese origen.

Si el proxy HTTP exige usuario y CONNECT falla, este curl 8.7.1 no devolvió un HTML 407 con exit 0. Devolvió 56 (failure in receiving network data) con HTTP/1.1 407 en el túnel. HTTP forwarding y CONNECT no son el mismo tipo de error. Anota el código de salida, el estado del proxy y la excepción de transporte por separado. No copies un recetario de 407 de otro cliente.

03Cómo separar -U y -u

-U / --proxy-user es el proxy. -u / --user es el origen. everything curl, autenticación de proxy (14 de octubre de 2026) lo dice sin rodeos: fallar o equivocar las credenciales del proxy produce 407, y eso es independiente de la autenticación del servidor.

La contraseña dummy del laboratorio incluía @, :, /, # y %. Esos caracteres van en -U como argumento aparte, o en userinfo con codificación por componente. No las pongas crudas en una URL que luego pegas en un ticket. Los volcados de esta guía ocultan Basic. No imprimas Proxy-Authorization cuando depuras.

Faltan credenciales, están mal, o están bien. Son tres casos.

En HTTP forwarding, sin -U, el fixture contestó 407 y curl salió 0. El cuerpo fue proxy-407. Verbose vio Proxy-Authenticate: Basic realm="lab". El log del proxy marcó authReason: missing. Con contraseña incorrecta, el mismo 407 y authReason: wrong. Curl no "falló" en el sentido de exit distinto de cero. Si tu script mira solo $?, se traga el 407.

Con --fail, el mismo 407 HTTP salió 22. Eso es este binario, este flag, este 407 de forwarding. No universalices "todo 407 es 22". CONNECT con 407, sin --fail, fue 56. Mezclarlos es el error que más tiempo pierde.

Con -U correcto, HTTP y HTTPS devolvieron origen 200. El origen no vio Proxy-Authorization. Esa cabecera se queda en el salto del proxy. Si el sitio también pide Basic, añade -u. En /origin-auth, -U bueno y -u malo dejó el proxy autorizado y el origen en 401. Al revés, el proxy ni abre el origen.

  1. Pon usuario y contraseña del proxy en -U.
  2. Reserva -u para el sitio.
  3. Trata 407 HTTP (exit 0 o 22 según --fail) y CONNECT 56 como desenlaces distintos.
  4. No asumas que "en el navegador entra" prueba el header que mandó curl.

04Cómo curl lee el entorno y NOPROXY

curl no tiene trust_env. Eso es de otros clientes Python. Aquí el aislamiento es -q (nada de curlrc), un proceso hijo sin HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY/FTP_PROXY en ambos casos, sin REQUEST_METHOD ni resto CGI, sin CURL_CA_BUNDLE, SSL_CERT_FILE, SSL_CERT_DIR, SSLKEYLOGFILE ni REQUESTS_CA_BUNDLE, y sin --netrc. HOME no se reescribe. Un netrc vacío privado existió en el temporal y se borró al terminar. -q como primera opción es lo que evita el curlrc del usuario, no un truco de directorio.

everything curl, variables de entorno (14 de octubre de 2026) es claro: el nombre [scheme]_proxy sigue el esquema del destino. http_proxy se acepta solo en minúsculas. El resto puede ir en mayúsculas, HTTPS_PROXY incluido. CGI crea HTTP_* a partir de cabeceras. Un header Proxy: se volvería HTTP_PROXY y un script que llama curl saldría hacia un proxy ajeno. Por eso curl rechaza HTTP_PROXY.

En macOS las variables distinguen mayúsculas. Con HTTP_PROXY apuntando al proxy de laboratorio y un GET HTTP, verbose no escribió Uses proxy env variable HTTP_PROXY. curl se conectó al origen, GET /, exit 0, proxy inactivo. Con http_proxy en minúsculas, verbose dijo Uses proxy env variable http_proxy == 'http://127.0.0.1:…' y el GET absoluto pasó por alpha.

Para HTTPS, https_proxy y HTTPS_PROXY sí abrieron CONNECT. ALL_PROXY queda por debajo de la variable específica del esquema. No infieras precedencia por un 200. Este laboratorio usó dos proxies instrumentados, alpha y bravo. Con http_proxy en alpha y -x en bravo, bravo recibió el GET y alpha no vio nada. -x ganó al entorno. El status 200 no lo habría dicho.

--noproxy / NO_PROXY es el bypass deliberado. Con http_proxy puesto y --noproxy 127.0.0.1 hacia http://127.0.0.1:origen/, el origen contestó 200 y alpha no vio el GET. Ese es un caso propio, no un efecto secundario que asumas. Para forzar proxy en loopback, --noproxy ''.

CURL_CA_BUNDLE no es "trust_env". En esta corrida, con SecureTransport, CURL_CA_BUNDLE apuntando a la CA de laboratorio y un HTTPS directo a 127.0.0.1 salió 0 y verbose mostró CAfile: de esa CA. --cacert hizo lo mismo. Eso es lo medido en curl 8.7.1 de este equipo. No lo conviertas en una ley para LibreSSL puro u OpenSSL en Linux. Un trust_env=False de Requests también deja de leer CA y netrc. curl reparte esas palancas: -q, no --netrc, --cacert o CURL_CA_BUNDLE, y el entorno de proxy por otro lado.

05Dónde resuelve el nombre SOCKS5 y socks5h

SOCKS5 no es "el proxy más anónimo". Es otro protocolo. everything curl, SOCKS (14 de octubre de 2026) parte las versiones: socks5 resuelve curl, socks5h / --socks5-hostname manda el hostname al proxy.

Un GET SOCKS exitoso no localiza el DNS. Hace falta el ATYP del handshake.

  1. -x socks5://127.0.0.1:PUERTO hacia http://127.0.0.1:origen/.
  2. Lee el verbose: este curl escribió SOCKS5 connect to 127.0.0.1:origen (locally resolved).
  3. El fixture SOCKS registró ATYP ipv4 y host 127.0.0.1. Eso es un destino con IP literal. No ejercita un resolver local de hostname.
  4. Repite con socks5h:// y un hostname de laboratorio. Verbose: SOCKS5 connect to curl-es-lab.origin:origen (remotely resolved). El fixture vio ATYP domain y ese hostname.

El mapeo del hostname a loopback lo hizo el proxy de laboratorio, no un DNS público. El proceso Python instala un getaddrinfo acotado para su propio código. curl 8.7.1 declara AsynchDNS y el hijo de curl no hereda ese parche. No toques /etc/hosts. Si en tu máquina socks5h falla porque el proxy no sabe resolver, eso es configuración del proxy, no "curl no soporta SOCKS".

La feature HTTPS-proxy del binario no se usó como salto TLS hacia el proxy. SOCKS y HTTP CONNECT se midieron en loopback. Un producto residencial que anuncia SOCKS5 sigue siendo otro origen de IP. Mídelo aparte.

06Cómo leer timeouts, 407 y la denegación de conexión

--connect-timeout limita el arranque: DNS, TCP, TLS. --max-time / -m limita toda la operación, incluso a mitad de cuerpo. everything curl, timeouts (14 de octubre de 2026) asigna el código 28 a ambos si se agota el reloj, y avisa de que por defecto curl no pone tope.

Una denegación de conexión no es un timeout. El exit 7 de esta corrida vino de un listener que se cerró en un puerto efímero. curl escribió Connection refused en 0 ms. Un segundo socket se dejó en bind sin listen hasta el finally; es otro puerto, no retiene el puerto rehusado. Un bind sin listen en Darwin a veces no manda RST; esa rama no está entre los 23 casos capturados.

El cuerpo lento es otra historia. /slow-body mandó cabeceras 200, escribió y flusheó el primer byte H, señaló un evento, y se quedó más tiempo que --max-time 0.8 antes del resto. curl había visto headers y ese byte. Salió 28. Eso es un timeout de transferencia después de empezar el cuerpo, no un read timeout estilo Requests y no un retraso antes de headers. Si tu prueba duerme antes de end_headers, no estás midiendo lo mismo.

Qué visteCódigo de este curl 8.7.1Qué no es
GET HTTP sin -U, fixture 407exit 0, HTTP 407Fallo de CONNECT
Mismo 407 con --failexit 22Ley universal de todos los 407
CONNECT sin credenciales o malexit 56, 407 en el túnelOrigen caído
CONNECT bien, CA ausenteexit 60Proxy que no abre túnel
Puerto sin listener (RST)exit 7Timeout medido
Cuerpo que se para tras el primer byte, --max-timeexit 28Timeout de connect

No prometas el texto exacto del error entre versiones. Guarda stdout, stderr, estado HTTP, estado CONNECT y exit.

Tabla de asertos del laboratorio curl 8.7.1 en loopback, 14 de octubre de 2026

La figura sale de la corrida real: 23 casos capturados y 47 filas de aserto. El original entregó esas 47 filas como 45 comprobaciones condicionales más 2 observaciones incondicionales (True). Esta corrección sustituye esas dos por predicados reales: --fail HTTP exit 22 y CURL_CA_BUNDLE con cuerpo de origen y CAfile. CONNECT 407 es exit 56. CA ausente tras CONNECT es exit 60. Capturar 23 filas no equivale a 23 asertos. La inyección Python no es DNS público.

07Cómo ejecutar el laboratorio incluido

Copia el bloque Python de esta sección a un archivo lab.py. No hay URL pública de descarga y este texto no afirma un adjunto publicado. El padre puede integrar el mismo fuente más adelante; el contrato de entrega es este bloque, no un path de máquina.

  1. Python 3.14 o compatible, curl, openssl, Pillow 12.1.1 y cwebp en el PATH. cwebp es obligatorio: el laboratorio llama a cwebp al pintar la figura y el exit 0 prometido incluye ese paso.
  2. python3 -m pip install Pillow==12.1.1
  3. Extrae el bloque a lab.py en un directorio vacío. Ejecuta python3 lab.py desde ese directorio.
  4. El padre limpia HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY, FTP_PROXY y las variantes en minúsculas, más variables CGI y de CA. Relanza un hijo. Cada curl lleva -q como primera opción.
  5. Salida esperada: exit 0, 23 capturas, 47 predicados reales, 0 fallidos, 1 inyección de resolver Python (no DNS público). Históricamente el original entregó 47 filas como 45 comprobaciones más 2 observaciones incondicionales.
  6. Limitación: solo 127.0.0.1. No prueba un proveedor de pago.

El recuadro PLACEHOLDERS más arriba ilustra -x y -U. El laboratorio ejecutable es el bloque siguiente.

#!/usr/bin/env python3
"""Self-contained curl proxy lab. Loopback fixtures only. One entry point."""

from __future__ import annotations

import base64
import hashlib
import json
import os
import re
import select
import shutil
import socket
import ssl
import stat
import subprocess
import sys
import tempfile
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import quote, urlsplit

HERE = Path(__file__).resolve().parent
if HERE.name == "tests":
    ART_ROOT = HERE.parent
    RESULTS_DIR = HERE
else:
    ART_ROOT = HERE
    RESULTS_DIR = HERE
ASSETS = ART_ROOT / "assets"
EVIDENCE = ART_ROOT / "evidence"
RAW = EVIDENCE / "raw"
RESULTS_JSON = RESULTS_DIR / "results.json"
RESULTS_TXT = RESULTS_DIR / "results.txt"
FIGURE_WEBP = ASSETS / "curl-results.webp"
FIGURE_PNG = ASSETS / "curl-results.png"
CA_PUBLIC = EVIDENCE / "ca.pem"

CHILD_FLAG = "CURL_ES_LAB_CHILD"
PARTIAL_HTTPS_FAIL = "CURL_ES_LAB_PARTIAL_HTTPS_FAIL"
BRAVO_START_FAIL = "CURL_ES_LAB_BRAVO_START_FAIL"
CLEANUP_PROBE = "CURL_ES_LAB_CLEANUP_PROBE"
LAB_HOST = "curl-es-lab.origin"
PROXY_USER = "lab-user"
PROXY_PASS = "Aa1@:/#%"
ORIGIN_USER = "origin-user"
ORIGIN_PASS = "origin-lab"
WRONG_PASS = "wrong-lab"
ORIGIN_WRONG_USER = "nobody"
ORIGIN_WRONG_PASS = "nope"
SLOW_BODY = b"H" + b"ELLO-SLOW-BODY!"
SLOW_WAIT_S = 2.4
MAX_TIME_S = "0.8"
CURL_BIN = shutil.which("curl") or "/usr/bin/curl"

CLEAR_KEYS = [
    "HTTP_PROXY",
    "HTTPS_PROXY",
    "ALL_PROXY",
    "NO_PROXY",
    "FTP_PROXY",
    "http_proxy",
    "https_proxy",
    "all_proxy",
    "no_proxy",
    "ftp_proxy",
    "REQUESTS_CA_BUNDLE",
    "CURL_CA_BUNDLE",
    "SSL_CERT_FILE",
    "SSL_CERT_DIR",
    "SSLKEYLOGFILE",
    "REQUEST_METHOD",
    "GATEWAY_INTERFACE",
    "CONTENT_TYPE",
    "CONTENT_LENGTH",
    "QUERY_STRING",
    "SCRIPT_NAME",
    "PATH_INFO",
    "SERVER_PROTOCOL",
    "REMOTE_ADDR",
    "SERVER_NAME",
    "SERVER_PORT",
    "NETRC",
]


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def b64_basic(user: str, password: str) -> str:
    return base64.b64encode(f"{user}:{password}".encode()).decode()


def _secret_list() -> list[str]:
    pairs = [
        (PROXY_USER, PROXY_PASS),
        (ORIGIN_USER, ORIGIN_PASS),
        (PROXY_USER, WRONG_PASS),
        (ORIGIN_WRONG_USER, ORIGIN_WRONG_PASS),
    ]
    secrets = [
        PROXY_PASS,
        ORIGIN_PASS,
        WRONG_PASS,
        ORIGIN_WRONG_PASS,
        PROXY_USER,
        ORIGIN_USER,
        ORIGIN_WRONG_USER,
    ]
    for user, password in pairs:
        secrets.append(f"{user}:{password}")
        secrets.append(quote(password, safe=""))
        secrets.append(quote(f"{user}:{password}", safe=""))
        secrets.append(b64_basic(user, password))
    return [s for s in secrets if s]


def redact_text(value: str) -> str:
    out = value
    for secret in _secret_list():
        out = out.replace(secret, "[redacted]")
    out = re.sub(r"(?im)^([>\s]*)(Proxy-Authorization:)\s*.*$", r"\1\2 [redacted]", out)
    out = re.sub(r"(?im)^([>\s]*)(Authorization:)\s*.*$", r"\1\2 [redacted]", out)
    out = re.sub(r"(?i)(Proxy-Authorization:\s*)\S+", r"\1[redacted]", out)
    out = re.sub(r"(?i)(Authorization:\s*)\S+", r"\1[redacted]", out)
    out = re.sub(r"://[^/\s]+@", "://[redacted]@", out)
    return out


def redact_argv(cmd: list[str]) -> list[str]:
    out: list[str] = []
    hide_next = False
    auth_flags = {"-U", "--proxy-user", "-u", "--user"}
    for part in cmd:
        if hide_next:
            out.append("[redacted-auth]")
            hide_next = False
            continue
        if part in auth_flags:
            out.append(part)
            hide_next = True
            continue
        out.append(redact_text(part))
    return out


def persisted_text_is_safe(text: str) -> bool:
    for secret in _secret_list():
        if secret and secret in text:
            return False
    if re.search(r"(?i)Authorization:\s*Basic\s+[A-Za-z0-9+/=]{8,}", text):
        return False
    return True


def close_sock(sock: socket.socket | None) -> None:
    if sock is None:
        return
    try:
        sock.shutdown(socket.SHUT_RDWR)
    except OSError:
        pass
    try:
        sock.close()
    except OSError:
        pass


def curl_version_blob() -> str:
    proc = subprocess.run([CURL_BIN, "-q", "--version"], capture_output=True, text=True)
    return (proc.stdout or proc.stderr or "").strip()


class OriginHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *_args: object) -> None:
        return

    def _leaked_proxy_auth(self) -> bool:
        return any(k.lower() == "proxy-authorization" for k in self.headers.keys())

    def _mark(self) -> str:
        return str(getattr(self.server, "origin_id", "origin"))

    def _write(self, code: int, body: bytes, extra: dict[str, str] | None = None) -> None:
        leaked = self._leaked_proxy_auth()
        getattr(self.server, "leak_flags").append(leaked)
        self.send_response(code)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("X-Origin-Id", self._mark())
        self.send_header("X-Proxy-Auth-Leaked", "1" if leaked else "0")
        self.send_header("Connection", "close")
        if extra:
            for key, value in extra.items():
                self.send_header(key, value)
        self.end_headers()
        self.wfile.write(body)
        self.wfile.flush()

    def do_GET(self) -> None:  # noqa: N802
        path = urlsplit(self.path).path
        shared = getattr(self.server, "shared")
        if path == "/slow-body":
            leaked = self._leaked_proxy_auth()
            getattr(self.server, "leak_flags").append(leaked)
            self.send_response(200)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(SLOW_BODY)))
            self.send_header("X-Origin-Id", self._mark())
            self.send_header("X-Slow-Body", "1")
            self.send_header("Connection", "close")
            self.end_headers()
            self.wfile.write(SLOW_BODY[:1])
            self.wfile.flush()
            shared["first_byte"].set()
            shared["slow_continue"].wait(timeout=SLOW_WAIT_S)
            try:
                self.wfile.write(SLOW_BODY[1:])
                self.wfile.flush()
            except OSError:
                pass
            return
        if path == "/origin-auth":
            header = self.headers.get("Authorization", "")
            expected = "Basic " + b64_basic(ORIGIN_USER, ORIGIN_PASS)
            if header != expected:
                self._write(401, b"ORIGIN-401", {"WWW-Authenticate": 'Basic realm="origin"'})
                return
            self._write(200, f"{self._mark()}|origin-auth-ok".encode())
            return
        self._write(200, f"{self._mark()}|{path}".encode())


def stop_origin(server: ThreadingHTTPServer) -> None:
    thread = getattr(server, "_thread", None)
    if thread is not None and thread.is_alive():
        try:
            server.shutdown()
        except Exception:
            pass
        thread.join(timeout=2)
    try:
        server.server_close()
    except Exception:
        pass


def start_origin(origin_id: str, shared: dict[str, Any], tls: dict[str, Path] | None) -> ThreadingHTTPServer:
    server = ThreadingHTTPServer(("127.0.0.1", 0), OriginHandler)
    server.origin_id = origin_id
    server.shared = shared
    server.leak_flags = []
    server.daemon_threads = False
    try:
        if tls is not None:
            ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
            ctx.load_cert_chain(str(tls["cert"]), str(tls["key"]))
            server.socket = ctx.wrap_socket(server.socket, server_side=True)
        thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.2}, name=origin_id)
        thread.start()
        server._thread = thread
        return server
    except Exception:
        stop_origin(server)
        raise


class HttpForwardProxy:
    def __init__(self, name: str, http_port: int, https_port: int, user: str | None, password: str | None):
        self.name = name
        self.http_port = http_port
        self.https_port = https_port
        self.user = user
        self.password = password
        self.events: list[dict[str, Any]] = []
        self._lock = threading.Lock()
        self._stop = threading.Event()
        self._workers: list[threading.Thread] = []
        self._active: list[socket.socket] = []
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.bind(("127.0.0.1", 0))
        self.sock.listen(64)
        self.sock.settimeout(0.25)
        self.port = int(self.sock.getsockname()[1])
        self._thread = threading.Thread(target=self._accept, name=f"proxy-{name}")

    def _track(self, sock: socket.socket) -> None:
        with self._lock:
            self._active.append(sock)

    def workers_alive(self) -> int:
        n = 1 if self._thread.is_alive() else 0
        n += sum(1 for w in self._workers if w.is_alive())
        return n

    def start(self) -> None:
        self._thread.start()

    def _record(self, row: dict[str, Any]) -> None:
        with self._lock:
            self.events.append(row)

    def snapshot(self) -> list[dict[str, Any]]:
        with self._lock:
            return list(self.events)

    def _auth_reason(self, headers: dict[str, str]) -> str:
        if self.user is None:
            return "open"
        raw = headers.get("proxy-authorization", "")
        if not raw:
            return "missing"
        if not raw.lower().startswith("basic "):
            return "wrong"
        try:
            decoded = base64.b64decode(raw.split(" ", 1)[1]).decode("utf-8")
            user, passwd = decoded.split(":", 1)
        except Exception:
            return "wrong"
        if user == self.user and passwd == self.password:
            return "ok"
        return "wrong"

    def _accept(self) -> None:
        while not self._stop.is_set():
            try:
                conn, _addr = self.sock.accept()
            except socket.timeout:
                continue
            except OSError:
                break
            worker = threading.Thread(target=self._handle, args=(conn,), name=f"{self.name}-conn")
            self._workers.append(worker)
            worker.start()

    def _recv_headers(self, conn: socket.socket) -> bytes:
        buf = b""
        conn.settimeout(0.8)
        while b"\r\n\r\n" not in buf:
            chunk = conn.recv(4096)
            if not chunk:
                break
            buf += chunk
            if len(buf) > 65536:
                break
        return buf

    def _map_dest(self, host: str, port: int) -> tuple[str, int] | None:
        key = host.lower()
        if key not in ("127.0.0.1", "localhost", LAB_HOST):
            return None
        if port == self.http_port:
            return ("127.0.0.1", self.http_port)
        if port == self.https_port:
            return ("127.0.0.1", self.https_port)
        return None

    def _handle(self, conn: socket.socket) -> None:
        upstream: socket.socket | None = None
        self._track(conn)
        try:
            raw = self._recv_headers(conn)
            if not raw:
                return
            head, _, _rest = raw.partition(b"\r\n\r\n")
            lines = head.decode("iso-8859-1", "replace").split("\r\n")
            if not lines:
                return
            parts = lines[0].split(" ")
            if len(parts) < 2:
                return
            method, target = parts[0], parts[1]
            headers: dict[str, str] = {}
            for line in lines[1:]:
                if ":" in line:
                    key, value = line.split(":", 1)
                    headers[key.strip().lower()] = value.strip()
            reason = self._auth_reason(headers)
            authorized = reason in ("ok", "open")
            self._record(
                {
                    "proxyId": self.name,
                    "method": method,
                    "target": target,
                    "authReason": reason,
                    "authorized": authorized,
                }
            )
            if not authorized:
                body = b"proxy-407"
                msg = (
                    "HTTP/1.1 407 Proxy Authentication Required\r\n"
                    'Proxy-Authenticate: Basic realm="lab"\r\n'
                    "Content-Length: %d\r\n"
                    "Connection: close\r\n\r\n" % len(body)
                ).encode() + body
                conn.sendall(msg)
                return
            if method == "CONNECT":
                host, port_s = target.rsplit(":", 1)
                mapped = self._map_dest(host, int(port_s))
                if mapped is None:
                    conn.sendall(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
                    return
                upstream = socket.create_connection(mapped, timeout=5)
                self._track(upstream)
                conn.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
                self._tunnel(conn, upstream)
                return
            split = urlsplit(target)
            if split.scheme != "http" or not split.hostname:
                conn.sendall(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
                return
            dest_port = split.port or 80
            mapped = self._map_dest(split.hostname, dest_port)
            if mapped is None:
                conn.sendall(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
                return
            path = split.path or "/"
            if split.query:
                path += "?" + split.query
            upstream = socket.create_connection(mapped, timeout=5)
            self._track(upstream)
            skip = {"proxy-authorization", "proxy-connection"}
            out = [f"{method} {path} HTTP/1.1"]
            out.append(f"Host: {split.hostname}:{dest_port}" if dest_port != 80 else f"Host: {split.hostname}")
            for key, value in headers.items():
                if key in skip or key == "host":
                    continue
                out.append(f"{key}: {value}")
            out.append("Connection: close")
            upstream.sendall(("\r\n".join(out) + "\r\n\r\n").encode("iso-8859-1"))
            self._tunnel(conn, upstream)
        except Exception:
            pass
        finally:
            close_sock(conn)
            close_sock(upstream)

    def _tunnel(self, a: socket.socket, b: socket.socket) -> None:
        sockets = [a, b]
        try:
            a.settimeout(None)
            b.settimeout(None)
            while True:
                ready, _, _ = select.select(sockets, [], [], 8)
                if not ready:
                    break
                for sock in ready:
                    other = b if sock is a else a
                    data = sock.recv(16384)
                    if not data:
                        return
                    other.sendall(data)
        except OSError:
            return
        finally:
            close_sock(a)
            close_sock(b)

    def stop(self) -> None:
        self._stop.set()
        with self._lock:
            active = list(self._active)
        for sock in active:
            close_sock(sock)
        close_sock(self.sock)
        if self._thread.is_alive():
            self._thread.join(timeout=2)
        for worker in self._workers:
            if worker.is_alive():
                worker.join(timeout=3)


class Socks5Proxy:
    def __init__(self, name: str, allowed_ports: set[int]):
        self.name = name
        self.allowed_ports = allowed_ports
        self.events: list[dict[str, Any]] = []
        self._lock = threading.Lock()
        self._stop = threading.Event()
        self._workers: list[threading.Thread] = []
        self._active: list[socket.socket] = []
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.bind(("127.0.0.1", 0))
        self.sock.listen(32)
        self.sock.settimeout(0.25)
        self.port = int(self.sock.getsockname()[1])
        self._thread = threading.Thread(target=self._accept, name="socks5")

    def _track(self, sock: socket.socket) -> None:
        with self._lock:
            self._active.append(sock)

    def workers_alive(self) -> int:
        n = 1 if self._thread.is_alive() else 0
        n += sum(1 for w in self._workers if w.is_alive())
        return n

    def start(self) -> None:
        self._thread.start()

    def snapshot(self) -> list[dict[str, Any]]:
        with self._lock:
            return list(self.events)

    def _accept(self) -> None:
        while not self._stop.is_set():
            try:
                conn, _addr = self.sock.accept()
            except socket.timeout:
                continue
            except OSError:
                break
            worker = threading.Thread(target=self._handle, args=(conn,))
            self._workers.append(worker)
            worker.start()

    def _handle(self, conn: socket.socket) -> None:
        upstream: socket.socket | None = None
        self._track(conn)
        try:
            conn.settimeout(0.8)
            hello = conn.recv(2)
            if len(hello) < 2 or hello[0] != 5:
                return
            nmethods = hello[1]
            methods = conn.recv(nmethods) if nmethods else b""
            if b"\x00" not in methods and nmethods:
                conn.sendall(b"\x05\xff")
                return
            conn.sendall(b"\x05\x00")
            req = conn.recv(4)
            if len(req) < 4 or req[0] != 5 or req[1] != 1:
                conn.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
                return
            atyp = req[3]
            if atyp == 1:
                addr_b = conn.recv(4)
                host = socket.inet_ntoa(addr_b)
                atyp_name = "ipv4"
            elif atyp == 3:
                ln = conn.recv(1)[0]
                host = conn.recv(ln).decode("ascii", "replace")
                atyp_name = "domain"
            else:
                conn.sendall(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00")
                return
            port_b = conn.recv(2)
            port = int.from_bytes(port_b, "big")
            with self._lock:
                self.events.append({"proxyId": self.name, "atyp": atyp_name, "host": host, "port": port})
            if port not in self.allowed_ports:
                conn.sendall(b"\x05\x02\x00\x01\x00\x00\x00\x00\x00\x00")
                return
            if host not in ("127.0.0.1", "localhost", LAB_HOST):
                conn.sendall(b"\x05\x02\x00\x01\x00\x00\x00\x00\x00\x00")
                return
            upstream = socket.create_connection(("127.0.0.1", port), timeout=5)
            self._track(upstream)
            conn.sendall(b"\x05\x00\x00\x01\x7f\x00\x00\x01" + (0).to_bytes(2, "big"))
            sockets = [conn, upstream]
            conn.settimeout(None)
            upstream.settimeout(None)
            while True:
                ready, _, _ = select.select(sockets, [], [], 8)
                if not ready:
                    break
                for sock in ready:
                    other = upstream if sock is conn else conn
                    data = sock.recv(16384)
                    if not data:
                        return
                    other.sendall(data)
        except Exception:
            return
        finally:
            close_sock(conn)
            close_sock(upstream)

    def stop(self) -> None:
        self._stop.set()
        with self._lock:
            active = list(self._active)
        for sock in active:
            close_sock(sock)
        close_sock(self.sock)
        if self._thread.is_alive():
            self._thread.join(timeout=2)
        for worker in self._workers:
            if worker.is_alive():
                worker.join(timeout=3)


def make_certs(tmp: Path) -> dict[str, Path]:
    ca_key = tmp / "ca.key"
    ca_pem = tmp / "ca.pem"
    origin_key = tmp / "origin.key"
    origin_csr = tmp / "origin.csr"
    origin_crt = tmp / "origin.crt"
    ca_cnf = tmp / "ca.cnf"
    origin_cnf = tmp / "origin.cnf"
    origin_ext = tmp / "origin.ext"
    ca_cnf.write_text(
        "[req]\n"
        "distinguished_name=dn\n"
        "x509_extensions=v3\n"
        "prompt=no\n"
        "[dn]\n"
        "CN=curl-es Lab CA\n"
        "[v3]\n"
        "basicConstraints=critical,CA:TRUE\n"
        "keyUsage=critical,keyCertSign,cRLSign\n"
        "subjectKeyIdentifier=hash\n"
    )
    origin_cnf.write_text("[req]\ndistinguished_name=dn\nprompt=no\n[dn]\nCN=localhost\n")
    origin_ext.write_text(
        f"subjectAltName=DNS:localhost,DNS:{LAB_HOST},IP:127.0.0.1\n"
        "basicConstraints=CA:FALSE\n"
        "keyUsage=digitalSignature,keyEncipherment\n"
        "extendedKeyUsage=serverAuth\n"
        "subjectKeyIdentifier=hash\n"
        "authorityKeyIdentifier=keyid,issuer\n"
    )
    subprocess.run(
        [
            "openssl",
            "req",
            "-new",
            "-x509",
            "-days",
            "1",
            "-nodes",
            "-newkey",
            "rsa:2048",
            "-keyout",
            str(ca_key),
            "-out",
            str(ca_pem),
            "-config",
            str(ca_cnf),
        ],
        check=True,
        capture_output=True,
        text=True,
    )
    subprocess.run(
        [
            "openssl",
            "req",
            "-new",
            "-nodes",
            "-newkey",
            "rsa:2048",
            "-keyout",
            str(origin_key),
            "-out",
            str(origin_csr),
            "-config",
            str(origin_cnf),
        ],
        check=True,
        capture_output=True,
        text=True,
    )
    subprocess.run(
        [
            "openssl",
            "x509",
            "-req",
            "-in",
            str(origin_csr),
            "-CA",
            str(ca_pem),
            "-CAkey",
            str(ca_key),
            "-CAcreateserial",
            "-out",
            str(origin_crt),
            "-days",
            "1",
            "-extfile",
            str(origin_ext),
        ],
        check=True,
        capture_output=True,
        text=True,
    )
    for key in (ca_key, origin_key):
        key.chmod(stat.S_IRUSR | stat.S_IWUSR)
    return {"ca_key": ca_key, "ca_pem": ca_pem, "origin_key": origin_key, "origin_crt": origin_crt}


def render_figure(rows: list[dict[str, Any]], versions: dict[str, Any], ran_at: str) -> dict[str, Any]:
    from PIL import Image, ImageDraw, ImageFont

    ASSETS.mkdir(parents=True, exist_ok=True)
    width = 1400
    row_h = 26
    header_h = 118
    height = header_h + row_h * (len(rows) + 3) + 40
    img = Image.new("RGB", (width, height), "#0f172a")
    draw = ImageDraw.Draw(img)
    try:
        font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 15)
        font_b = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 22)
        font_s = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 13)
    except OSError:
        font = ImageFont.load_default()
        font_b = font
        font_s = font
    draw.text((32, 22), "Resultados del laboratorio curl (loopback)", fill="#e2e8f0", font=font_b)
    sub = f"{versions.get('curl')} / Python {versions.get('python')} / {ran_at}"
    draw.text((32, 56), sub[:110], fill="#c4b5fd", font=font_s)
    draw.text(
        (32, 78),
        "Fixtures locales. Contrasenas y claves omitidas. No es un gateway de pago.",
        fill="#94a3b8",
        font=font_s,
    )
    y = header_h
    for x, label in ((32, "aserto"), (520, "tipo"), (720, "resultado"), (980, "detalle")):
        draw.text((x, y), label, fill="#a78bfa", font=font)
    y += row_h
    draw.line((32, y, width - 32, y), fill="#4c1d95")
    y += 6
    for row in rows:
        fill = "#86efac" if row.get("passed") else "#fca5a5"
        draw.text((32, y), str(row.get("name", ""))[:48], fill="#e2e8f0", font=font)
        draw.text((520, y), str(row.get("kind", ""))[:16], fill="#cbd5e1", font=font)
        flag = "PASS" if row.get("passed") else "FAIL"
        draw.text((720, y), flag, fill=fill, font=font)
        draw.text((980, y), str(row.get("detail", ""))[:36], fill="#e2e8f0", font=font)
        y += row_h
    img.save(FIGURE_PNG, "PNG")
    subprocess.run(["cwebp", "-q", "78", str(FIGURE_PNG), "-o", str(FIGURE_WEBP)], check=True, capture_output=True)
    data = FIGURE_WEBP.read_bytes()
    with Image.open(FIGURE_WEBP) as webp:
        dims = webp.size
    return {"bytes": len(data), "dimensions": list(dims), "sha256": hashlib.sha256(data).hexdigest()}


def write_results(payload: dict[str, Any]) -> None:
    RAW.mkdir(parents=True, exist_ok=True)
    text = json.dumps(payload, indent=2, sort_keys=True)
    RESULTS_JSON.write_text(text)
    lines = [
        f"ranAt {payload.get('ranAt')}",
        f"finishedAt {payload.get('finishedAt')}",
        f"ok {payload.get('ok')}",
        f"exitStatus {payload.get('exitStatus')}",
        f"captured {payload['counts']['captured']}",
        f"asserted {payload['counts']['asserted']}",
        f"passed {payload['counts']['passed']}",
        f"failed {payload['counts']['failed']}",
        f"injected {payload['counts']['injected']}",
        f"curl {payload['versions']['curl']}",
        f"features {payload['versions']['curlFeatures']}",
        "",
    ]
    for row in payload["assertions"]:
        flag = "PASS" if row["passed"] else "FAIL"
        lines.append(f"{flag} {row['name']} :: {row.get('detail', '')}")
    RESULTS_TXT.write_text("\n".join(lines) + "\n")
    (RAW / "results.json").write_text(text)
    (RAW / "results.txt").write_text(RESULTS_TXT.read_text())


def launch_child() -> int:
    env = {
        "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
        "HOME": os.environ.get("HOME", ""),
        "USER": os.environ.get("USER", ""),
        "LANG": "C",
        "LC_ALL": "C",
        "TMPDIR": os.environ.get("TMPDIR", "/tmp"),
        CHILD_FLAG: "1",
        "PYTHONDONTWRITEBYTECODE": "1",
    }
    for key in (PARTIAL_HTTPS_FAIL, BRAVO_START_FAIL, CLEANUP_PROBE):
        if os.environ.get(key):
            env[key] = os.environ[key]
    proc = subprocess.run([sys.executable, str(Path(__file__).resolve())], env=env)
    return int(proc.returncode)


def run_curl(
    extra_args: list[str],
    env_inject: dict[str, str] | None = None,
    timeout: float = 12.0,
) -> dict[str, Any]:
    env = {k: v for k, v in os.environ.items() if k not in CLEAR_KEYS}
    if env_inject:
        env.update(env_inject)
    cmd = [CURL_BIN, "-q", *extra_args]
    if cmd[1] != "-q":
        raise RuntimeError("curl -q must be the first option")
    started = time.monotonic()
    proc = subprocess.run(cmd, env=env, capture_output=True, timeout=timeout)
    elapsed = time.monotonic() - started
    stdout = redact_text(proc.stdout.decode("utf-8", "replace"))
    stderr = redact_text(proc.stderr.decode("utf-8", "replace"))
    return {
        "argv": redact_argv(cmd),
        "exit": int(proc.returncode),
        "stdout": stdout,
        "stderr": stderr,
        "elapsedSec": round(elapsed, 3),
        "firstOpt": cmd[1],
        "httpCode": parse_http_code(stderr, stdout),
        "connectStatus": parse_connect_status(stderr),
    }


def parse_http_code(stderr: str, stdout: str) -> str | None:
    for blob in (stdout, stderr):
        m = re.search(r"< HTTP/1\.[01] (\d{3})", blob)
        if m:
            return m.group(1)
        m = re.search(r"HTTP/1\.[01] (\d{3})", blob)
        if m:
            return m.group(1)
    return None


def parse_connect_status(stderr: str) -> str | None:
    m = re.search(
        r"Establish HTTP proxy tunnel[\s\S]*?< HTTP/1\.[01] (\d{3})",
        stderr,
    )
    if m:
        return m.group(1)
    if "CONNECT" in stderr:
        m = re.search(r"< HTTP/1\.[01] (\d{3})", stderr)
        if m:
            return m.group(1)
    return None


def cleanup(
    owned: dict[str, Any],
    tmp: Path | None,
    real_gai: Any,
    shared: dict[str, Any] | None,
    prior_netrc: str | None,
) -> dict[str, Any]:
    evidence: dict[str, Any] = {
        "stopped": [],
        "deleted": [],
        "resolverRestored": False,
        "slowReleased": False,
        "workersAliveAfterStop": {},
        "netrcRestored": False,
    }
    if shared is not None:
        shared.get("slow_continue", threading.Event()).set()
        evidence["slowReleased"] = True
    for key in ("alpha", "bravo", "socks"):
        obj = owned.get(key)
        if obj is not None:
            try:
                obj.stop()
                evidence["stopped"].append(key)
                evidence["workersAliveAfterStop"][key] = int(obj.workers_alive())
            except Exception as exc:
                evidence["stopped"].append(f"{key}:error:{type(exc).__name__}")
    for key in ("http_origin", "https_origin"):
        obj = owned.get(key)
        if obj is not None:
            try:
                stop_origin(obj)
                evidence["stopped"].append(key)
            except Exception as exc:
                evidence["stopped"].append(f"{key}:error:{type(exc).__name__}")
    held = owned.get("held")
    if held is not None:
        close_sock(held)
        evidence["stopped"].append("held-refusal-socket")
    if real_gai is not None:
        socket.getaddrinfo = real_gai
        evidence["resolverRestored"] = True
    if prior_netrc is None:
        os.environ.pop("NETRC", None)
    else:
        os.environ["NETRC"] = prior_netrc
    evidence["netrcRestored"] = True
    if tmp is not None:
        for name in ("ca.key", "origin.key", "netrc"):
            path = tmp / name
            if path.exists():
                path.unlink()
                evidence["deleted"].append(name)
        try:
            shutil.rmtree(tmp, ignore_errors=True)
            evidence["deleted"].append(str(tmp.name))
        except Exception:
            pass
        evidence["privateKeysPresent"] = any((tmp / n).exists() for n in ("ca.key", "origin.key"))
    else:
        evidence["privateKeysPresent"] = False
    return evidence


def run_lab() -> int:
    started = utc_now()
    blob = curl_version_blob()
    first = blob.splitlines()[0] if blob else ""
    features = ""
    for line in blob.splitlines():
        if line.startswith("Features:"):
            features = line.split(":", 1)[1].strip()
    versions = {
        "python": sys.version.split()[0],
        "curl": first,
        "curlFeatures": features,
        "curlBin": CURL_BIN,
        "openssl": subprocess.run(["openssl", "version"], capture_output=True, text=True).stdout.strip(),
        "os": sys.platform,
        "platform": os.uname().sysname + "-" + os.uname().release + "-" + os.uname().machine,
        "interpreter": sys.executable,
        "tlsBackend": "SecureTransport" if "SecureTransport" in blob else "unknown",
    }
    captured: list[dict[str, Any]] = []
    assertions: list[dict[str, Any]] = []
    injected: list[dict[str, Any]] = []
    owned: dict[str, Any] = {}
    tmp: Path | None = None
    real_gai: Any = None
    shared: dict[str, Any] | None = None
    prior_netrc = os.environ.get("NETRC")

    def capture(name: str, **fields: Any) -> None:
        captured.append({"name": name, **fields})

    def assert_true(name: str, cond: bool, detail: str, kind: str = "assert") -> None:
        assertions.append({"name": name, "passed": bool(cond), "detail": redact_text(detail), "kind": kind})

    cleanup_info: dict[str, Any] = {}
    figure_info: dict[str, Any] = {}
    ok = False
    exit_status = 1
    try:
        tmp = Path(tempfile.mkdtemp(prefix="curl-es-lab-"))
        netrc_path = tmp / "netrc"
        netrc_path.write_text("")
        netrc_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
        os.environ["NETRC"] = str(netrc_path)
        real_gai = socket.getaddrinfo

        def patched_gai(host: Any, port: Any, *args: Any, **kwargs: Any) -> Any:
            if host in (LAB_HOST, b"curl-es-lab.origin"):
                host = "127.0.0.1"
            return real_gai(host, port, *args, **kwargs)

        socket.getaddrinfo = patched_gai  # type: ignore[assignment]
        injected.append(
            {
                "branch": "resolver",
                "label": "scoped Python getaddrinfo maps curl-es-lab.origin to 127.0.0.1; curl AsynchDNS does not inherit this; not public DNS",
            }
        )
        certs = make_certs(tmp)
        ASSETS.mkdir(parents=True, exist_ok=True)
        EVIDENCE.mkdir(parents=True, exist_ok=True)
        RAW.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(certs["ca_pem"], CA_PUBLIC)
        shared = {"first_byte": threading.Event(), "slow_continue": threading.Event()}
        http_origin = start_origin("origin-http", shared, None)
        owned["http_origin"] = http_origin
        if os.environ.get(PARTIAL_HTTPS_FAIL) == "1":
            start_origin(
                "origin-https",
                shared,
                {"cert": certs["origin_crt"], "key": tmp / "missing-origin.key"},
            )
        https_origin = start_origin("origin-https", shared, {"cert": certs["origin_crt"], "key": certs["origin_key"]})
        owned["https_origin"] = https_origin
        http_port = int(http_origin.server_address[1])
        https_port = int(https_origin.server_address[1])
        alpha = HttpForwardProxy("alpha", http_port, https_port, PROXY_USER, PROXY_PASS)
        owned["alpha"] = alpha
        alpha.start()
        bravo = HttpForwardProxy("bravo", http_port, https_port, PROXY_USER, PROXY_PASS)
        owned["bravo"] = bravo
        if os.environ.get(BRAVO_START_FAIL) == "1":
            raise RuntimeError("bravo-start-fail")
        bravo.start()
        socks = Socks5Proxy("socks", {http_port})
        owned["socks"] = socks
        socks.start()
        held = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        held.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        held.bind(("127.0.0.1", 0))
        owned["held"] = held
        # Darwin drops SYN on bind-without-listen (connect timeout), not RST.
        # listen-then-close yields ECONNREFUSED while we still hold `held`.
        closed_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        closed_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        closed_listener.bind(("127.0.0.1", 0))
        refuse_port = int(closed_listener.getsockname()[1])
        closed_listener.listen(1)
        closed_listener.close()
        http_url = f"http://{LAB_HOST}:{http_port}/"
        https_url = f"https://{LAB_HOST}:{https_port}/"
        http_ip = f"http://127.0.0.1:{http_port}/"
        https_ip = f"https://127.0.0.1:{https_port}/"
        slow_url = f"http://127.0.0.1:{http_port}/slow-body"
        auth_url = f"http://{LAB_HOST}:{http_port}/origin-auth"
        proxy_alpha = f"http://127.0.0.1:{alpha.port}"
        proxy_bravo = f"http://127.0.0.1:{bravo.port}"
        ca = str(certs["ca_pem"])
        common = ["-sS", "-v", "--max-time", "8", "--noproxy", ""]

        probe = os.environ.get(CLEANUP_PROBE, "")
        if probe:
            probe_row: dict[str, Any] = {"probe": probe, "alphaPort": alpha.port, "socksPort": socks.port}
            client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            client.settimeout(2)
            try:
                if probe == "partial-header":
                    client.connect(("127.0.0.1", alpha.port))
                    client.sendall(b"GET ")
                    time.sleep(0.15)
                    probe_row["sent"] = "partial-GET"
                elif probe == "active-tunnel":
                    client.connect(("127.0.0.1", socks.port))
                    client.sendall(b"\x05\x01\x00")
                    time.sleep(0.15)
                    probe_row["sent"] = "socks-hello"
                else:
                    probe_row["sent"] = "none"
            finally:
                close_sock(client)
            RAW.mkdir(parents=True, exist_ok=True)
            (RAW / f"cleanup-probe-{probe}.json").write_text(json.dumps(probe_row, indent=2))
            raise RuntimeError(f"cleanup-probe:{probe}")

        # 1 direct HTTP
        before = len(alpha.snapshot())
        r = run_curl([*common, http_ip])
        capture("direct_http", **r, proxyDelta=alpha.snapshot()[before:])
        assert_true("direct_http_first_opt", r["firstOpt"] == "-q", "first option -q")
        assert_true("direct_http_200", r["exit"] == 0 and "origin-http" in r["stdout"], f"exit={r['exit']}")
        assert_true("direct_http_idle_proxy", len(alpha.snapshot()) == before, "alpha idle")

        # 2 explicit HTTP correct auth via -U
        before = len(alpha.snapshot())
        r = run_curl([*common, "-x", proxy_alpha, "-U", f"{PROXY_USER}:{PROXY_PASS}", http_url])
        ev = alpha.snapshot()[before:]
        capture("explicit_http_ok", **r, events=ev)
        assert_true("explicit_http_ok_200", r["exit"] == 0 and "origin-http" in r["stdout"], f"exit={r['exit']}")
        assert_true(
            "explicit_http_absolute_form",
            any(e["method"] == "GET" and e["authorized"] and LAB_HOST in e["target"] for e in ev),
            "GET absolute-form via alpha",
        )
        assert_true(
            "explicit_http_no_leak",
            "X-Proxy-Auth-Leaked: 1" not in r["stderr"] and not any(http_origin.leak_flags),
            "no origin leak in verbose or fixture",
        )

        # 3 missing proxy auth HTTP
        before = len(alpha.snapshot())
        r = run_curl([*common, "-x", proxy_alpha, http_url])
        ev = alpha.snapshot()[before:]
        code = parse_http_code(r["stderr"], r["stdout"])
        capture("http_auth_missing", **r, events=ev)
        assert_true("http_auth_missing_407", code == "407" or "407" in r["stderr"] or "407" in r["stdout"], f"code={code}")
        assert_true("http_auth_missing_reason", any(e["authReason"] == "missing" for e in ev), "fixture missing")
        assert_true(
            "http_auth_missing_exit_without_fail",
            r["exit"] == 0,
            f"exit={r['exit']} without --fail",
        )

        # 4 wrong proxy auth HTTP
        before = len(alpha.snapshot())
        r = run_curl([*common, "-x", proxy_alpha, "-U", f"{PROXY_USER}:{WRONG_PASS}", http_url])
        ev = alpha.snapshot()[before:]
        code = parse_http_code(r["stderr"], r["stdout"])
        capture("http_auth_wrong", **r, events=ev)
        assert_true("http_auth_wrong_407", code == "407" or "407" in r["stderr"] or "407" in r["stdout"], f"code={code}")
        assert_true("http_auth_wrong_reason", any(e["authReason"] == "wrong" for e in ev), "fixture wrong")

        # 5 HTTP 407 with --fail
        r = run_curl([*common, "--fail", "-x", proxy_alpha, http_url])
        capture("http_auth_missing_fail", **r)
        assert_true("http_407_fail_nonzero", r["httpCode"] == "407", f"httpCode={r['httpCode']}")
        assert_true("http_407_fail_not_universal_22_claim", r["exit"] == 22, f"observed_exit={r['exit']}")

        # 6 HTTPS CONNECT correct
        before = len(alpha.snapshot())
        r = run_curl([*common, "--cacert", ca, "-x", proxy_alpha, "-U", f"{PROXY_USER}:{PROXY_PASS}", https_url])
        ev = alpha.snapshot()[before:]
        capture("https_connect_ok", **r, events=ev)
        assert_true("https_connect_ok_200", r["exit"] == 0 and "origin-https" in r["stdout"], f"exit={r['exit']}")
        assert_true(
            "https_connect_method",
            any(e["method"] == "CONNECT" and e["authorized"] and str(https_port) in e["target"] for e in ev),
            "CONNECT then TLS origin",
        )
        assert_true("https_connect_no_k", "-k" not in r["argv"] and "--insecure" not in r["argv"], "verify on")

        # 7 HTTPS CONNECT missing auth
        before = len(alpha.snapshot())
        r = run_curl([*common, "--cacert", ca, "-x", proxy_alpha, https_url])
        ev = alpha.snapshot()[before:]
        capture("https_auth_missing", **r, events=ev)
        assert_true("https_auth_missing_nonzero", r["exit"] == 56 and r.get("connectStatus") == "407", f"exit={r['exit']} connect={r.get('connectStatus')}")
        assert_true(
            "https_connect_missing_reason",
            any(e["method"] == "CONNECT" and e["authReason"] == "missing" for e in ev),
            "CONNECT missing",
        )

        # 8 HTTPS CONNECT wrong auth
        before = len(alpha.snapshot())
        r = run_curl([*common, "--cacert", ca, "-x", proxy_alpha, "-U", f"{PROXY_USER}:{WRONG_PASS}", https_url])
        ev = alpha.snapshot()[before:]
        capture("https_auth_wrong", **r, events=ev)
        assert_true("https_auth_wrong_nonzero", r["exit"] == 56 and r.get("connectStatus") == "407", f"exit={r['exit']} connect={r.get('connectStatus')}")
        assert_true(
            "https_connect_wrong_reason",
            any(e["method"] == "CONNECT" and e["authReason"] == "wrong" for e in ev),
            "CONNECT wrong",
        )

        # 9 untrusted CA, still CONNECT
        before = len(alpha.snapshot())
        r = run_curl([*common, "-x", proxy_alpha, "-U", f"{PROXY_USER}:{PROXY_PASS}", https_url])
        ev = alpha.snapshot()[before:]
        capture("https_untrusted_ca", **r, events=ev)
        assert_true("https_untrusted_nonzero", r["exit"] == 60, f"exit={r['exit']}")
        assert_true(
            "https_untrusted_connected",
            any(e["method"] == "CONNECT" and e["authorized"] for e in ev),
            "CONNECT happened; TLS failed",
        )

        # 10 env http_proxy lowercase
        before = len(alpha.snapshot())
        r = run_curl(
            [*common, "-U", f"{PROXY_USER}:{PROXY_PASS}", http_url],
            env_inject={"http_proxy": proxy_alpha},
        )
        ev = alpha.snapshot()[before:]
        capture("env_http_proxy", **r, events=ev)
        assert_true("env_http_proxy_used", any(e["proxyId"] == "alpha" and e["method"] == "GET" for e in ev), "alpha GET")
        assert_true("env_http_proxy_verbose", "http_proxy" in r["stderr"], r["stderr"][:80])

        # 11 uppercase HTTP_PROXY ignored
        before_a = len(alpha.snapshot())
        r = run_curl(
            [*common, http_ip],
            env_inject={"HTTP_PROXY": proxy_alpha},
        )
        capture("env_HTTP_PROXY_ignored", **r, events=alpha.snapshot()[before_a:])
        assert_true("env_HTTP_PROXY_idle", len(alpha.snapshot()) == before_a, "alpha idle")
        assert_true("env_HTTP_PROXY_direct", "origin-http" in r["stdout"] and r["exit"] == 0, f"exit={r['exit']}")
        assert_true("env_HTTP_PROXY_no_uses", "HTTP_PROXY" not in r["stderr"] or "Uses proxy env variable HTTP_PROXY" not in r["stderr"], "not used")

        # 12 https_proxy lowercase CONNECT
        before = len(alpha.snapshot())
        r = run_curl(
            [*common, "--cacert", ca, "-U", f"{PROXY_USER}:{PROXY_PASS}", https_url],
            env_inject={"https_proxy": proxy_alpha},
        )
        ev = alpha.snapshot()[before:]
        capture("env_https_proxy", **r, events=ev)
        assert_true(
            "env_https_proxy_connect",
            any(e["method"] == "CONNECT" and e["authorized"] for e in ev) and r["exit"] == 0,
            f"exit={r['exit']}",
        )

        # 13 HTTPS_PROXY uppercase CONNECT
        before = len(alpha.snapshot())
        r = run_curl(
            [*common, "--cacert", ca, "-U", f"{PROXY_USER}:{PROXY_PASS}", https_url],
            env_inject={"HTTPS_PROXY": proxy_alpha},
        )
        ev = alpha.snapshot()[before:]
        capture("env_HTTPS_PROXY", **r, events=ev)
        assert_true(
            "env_HTTPS_PROXY_connect",
            any(e["method"] == "CONNECT" and e["authorized"] for e in ev) and r["exit"] == 0,
            f"exit={r['exit']}",
        )

        # 14 explicit -x bravo wins over env alpha
        before_a = len(alpha.snapshot())
        before_b = len(bravo.snapshot())
        r = run_curl(
            [*common, "-x", proxy_bravo, "-U", f"{PROXY_USER}:{PROXY_PASS}", http_url],
            env_inject={"http_proxy": proxy_alpha},
        )
        ev_a = alpha.snapshot()[before_a:]
        ev_b = bravo.snapshot()[before_b:]
        capture("explicit_overrides_env", **r, alpha=ev_a, bravo=ev_b)
        assert_true("precedence_bravo_used", any(e["method"] == "GET" and e["authorized"] for e in ev_b), "bravo GET")
        assert_true("precedence_alpha_idle", not any(e["method"] == "GET" for e in ev_a), "alpha idle")

        # 15 bypass --noproxy host
        before = len(alpha.snapshot())
        r = run_curl(
            ["-sS", "-v", "--max-time", "8", "--noproxy", "127.0.0.1", "-U", f"{PROXY_USER}:{PROXY_PASS}", http_ip],
            env_inject={"http_proxy": proxy_alpha},
        )
        ev = alpha.snapshot()[before:]
        capture("noproxy_bypass", **r, events=ev)
        assert_true("noproxy_bypass_direct", r["exit"] == 0 and "origin-http" in r["stdout"], f"exit={r['exit']}")
        assert_true("noproxy_bypass_idle", not any(e["method"] == "GET" for e in ev), "alpha idle")

        # 16 origin -u vs proxy -U
        before = len(alpha.snapshot())
        r = run_curl(
            [
                *common,
                "-x",
                proxy_alpha,
                "-U",
                f"{PROXY_USER}:{PROXY_PASS}",
                "-u",
                f"{ORIGIN_USER}:{ORIGIN_PASS}",
                auth_url,
            ]
        )
        ev = alpha.snapshot()[before:]
        capture("origin_and_proxy_auth", **r, events=ev)
        assert_true("dual_auth_200", r["exit"] == 0 and "origin-auth-ok" in r["stdout"], f"exit={r['exit']}")
        assert_true("dual_auth_proxy_ok", any(e["authorized"] for e in ev), "proxy authorized")

        r = run_curl(
            [*common, "-x", proxy_alpha, "-U", f"{PROXY_USER}:{PROXY_PASS}", "-u", f"{ORIGIN_WRONG_USER}:{ORIGIN_WRONG_PASS}", auth_url]
        )
        capture("origin_wrong_proxy_ok", **r)
        assert_true("origin_401_distinct", "401" in r["stderr"] or "ORIGIN-401" in r["stdout"], "origin 401")

        # 17 owned refusal, not timeout
        r = run_curl(["-sS", "-v", "--max-time", "4", f"http://127.0.0.1:{refuse_port}/"])
        capture("connection_refusal", **r, refusePort=refuse_port)
        assert_true("refusal_exit7", r["exit"] == 7, f"exit={r['exit']}")
        assert_true("refusal_not_timeout28", r["exit"] != 28, "connection error not transfer timeout")

        # 18 delayed body / --max-time transfer timeout
        shared["first_byte"].clear()
        shared["slow_continue"].clear()
        r = run_curl(["-sS", "-v", "--max-time", MAX_TIME_S, "--noproxy", "", slow_url], timeout=8)
        first = shared["first_byte"].is_set()
        capture("slow_body_max_time", **r, firstByte=first)
        assert_true("slow_first_byte", first, "headers and first byte flushed")
        assert_true("slow_exit28", r["exit"] == 28, f"exit={r['exit']} transfer timeout")
        shared["slow_continue"].set()

        # 19 SOCKS5 ATYP ipv4
        before = len(socks.snapshot())
        r = run_curl([*common, "-x", f"socks5://127.0.0.1:{socks.port}", http_ip])
        ev = socks.snapshot()[before:]
        capture("socks5_ipv4", **r, events=ev)
        assert_true("socks5_ok", r["exit"] == 0 and "origin-http" in r["stdout"], f"exit={r['exit']}")
        assert_true("socks5_atyp_ipv4", any(e["atyp"] == "ipv4" and e["host"] == "127.0.0.1" for e in ev), str(ev))

        # 20 SOCKS5h ATYP domain
        before = len(socks.snapshot())
        r = run_curl([*common, "-x", f"socks5h://127.0.0.1:{socks.port}", http_url])
        ev = socks.snapshot()[before:]
        capture("socks5h_domain", **r, events=ev)
        assert_true("socks5h_ok", r["exit"] == 0 and "origin-http" in r["stdout"], f"exit={r['exit']}")
        assert_true(
            "socks5h_atyp_domain",
            any(e["atyp"] == "domain" and e["host"] == LAB_HOST for e in ev),
            str(ev),
        )

        # 21 CURL_CA_BUNDLE vs --cacert (measure, do not invent)
        r_bundle = run_curl(
            ["-sS", "-v", "--max-time", "8", "--noproxy", "", https_ip],
            env_inject={"CURL_CA_BUNDLE": ca},
        )
        r_cacert = run_curl(["-sS", "-v", "--max-time", "8", "--noproxy", "", "--cacert", ca, https_ip])
        capture("ca_bundle_env", **r_bundle)
        capture("cacert_flag", **r_cacert)
        assert_true("cacert_direct_https", r_cacert["exit"] == 0 and "origin-https" in r_cacert["stdout"], f"exit={r_cacert['exit']}")
        assert_true(
            "ca_bundle_measured",
            r_bundle["exit"] == 0 and "origin-https" in r_bundle["stdout"] and "CAfile:" in r_bundle["stderr"],
            f"CURL_CA_BUNDLE_exit={r_bundle['exit']} cafile_logged={'CAfile:' in r_bundle['stderr']}",
        )

        # 22 no --netrc in argv
        captured_blob = json.dumps(captured)
        assert_true(
            "no_netrc_flag",
            all("--netrc" not in c.get("argv", []) and "--netrc-optional" not in c.get("argv", []) for c in captured)
            and persisted_text_is_safe(captured_blob),
            "curl invoked without --netrc; persisted captures redacted",
        )

        failed = [a for a in assertions if not a["passed"]]
        ok = len(failed) == 0
        exit_status = 0 if ok else 1
        figure_rows = [
            {
                "name": a["name"],
                "kind": a.get("kind", "assert"),
                "passed": a["passed"],
                "detail": a.get("detail", ""),
                "result": "PASS" if a["passed"] else "FAIL",
            }
            for a in assertions
        ]
        figure_info = render_figure(figure_rows, versions, started)
    except Exception as exc:
        ok = False
        exit_status = 2
        capture("lab_exception", error=redact_text(f"{type(exc).__name__}: {exc}"))
        assert_true("lab_exception", False, redact_text(f"{type(exc).__name__}: {exc}"))
        failed = [a for a in assertions if not a["passed"]]
        if os.environ.get(PARTIAL_HTTPS_FAIL) == "1":
            exit_status = 2
    finally:
        cleanup_info = cleanup(owned, tmp, real_gai, shared, prior_netrc)
        failed = [a for a in assertions if not a["passed"]]
        meaningful = [a for a in assertions if a.get("kind") != "observation"]
        payload = {
            "ranAt": started,
            "finishedAt": utc_now(),
            "ok": ok and len(failed) == 0,
            "exitStatus": exit_status,
            "versions": versions,
            "counts": {
                "captured": len(captured),
                "asserted": len(assertions),
                "passed": sum(1 for a in assertions if a["passed"]),
                "failed": sum(1 for a in assertions if not a["passed"]),
                "injected": len(injected),
                "meaningfulChecks": len(meaningful),
                "observations": sum(1 for a in assertions if a.get("kind") == "observation"),
                "historicalOriginal": {
                    "asserted": 47,
                    "meaningfulChecks": 45,
                    "unconditionalObservations": 2,
                },
            },
            "failedNames": [a["name"] for a in assertions if not a["passed"]],
            "injected": injected,
            "captured": captured,
            "assertions": assertions,
            "cleanup": cleanup_info,
            "figure": figure_info,
            "notes": {
                "trust_env": "curl has no trust_env; -q isolates curlrc; env CA measured separately",
                "https_proxy_scheme": "HTTP proxy URL used for HTTPS CONNECT; not an HTTPS-proxy TLS hop",
                "passwords": "redacted",
                "verifyFalse": False,
            },
        }
        skip_main_results = bool(
            os.environ.get(PARTIAL_HTTPS_FAIL) == "1"
            or os.environ.get(BRAVO_START_FAIL) == "1"
            or os.environ.get(CLEANUP_PROBE)
        )
        if not skip_main_results:
            try:
                write_results(payload)
            except Exception:
                pass
        RAW.mkdir(parents=True, exist_ok=True)
        if os.environ.get(PARTIAL_HTTPS_FAIL) == "1":
            (RAW / "partial-https-fail.json").write_text(
                json.dumps(
                    {
                        "exitStatus": exit_status,
                        "error": captured[-1] if captured else None,
                        "cleanup": cleanup_info,
                        "httpOriginStopped": "http_origin" in cleanup_info.get("stopped", []),
                        "workersAliveAfterStop": cleanup_info.get("workersAliveAfterStop"),
                    },
                    indent=2,
                )
            )
        if os.environ.get(BRAVO_START_FAIL) == "1":
            (RAW / "bravo-start-fail.json").write_text(
                json.dumps(
                    {
                        "exitStatus": exit_status,
                        "error": captured[-1] if captured else None,
                        "cleanup": cleanup_info,
                        "alphaRegistered": "alpha" in owned,
                        "bravoRegistered": "bravo" in owned,
                        "workersAliveAfterStop": cleanup_info.get("workersAliveAfterStop"),
                    },
                    indent=2,
                )
            )
        if os.environ.get(CLEANUP_PROBE):
            name = os.environ.get(CLEANUP_PROBE)
            path = RAW / f"cleanup-probe-{name}.json"
            prior = {}
            if path.exists():
                try:
                    prior = json.loads(path.read_text())
                except Exception:
                    prior = {}
            prior.update(
                {
                    "exitStatus": exit_status,
                    "cleanup": cleanup_info,
                    "workersAliveAfterStop": cleanup_info.get("workersAliveAfterStop"),
                }
            )
            path.write_text(json.dumps(prior, indent=2))
    return exit_status


def main() -> int:
    if os.environ.get(CHILD_FLAG) != "1":
        return launch_child()
    return run_lab()


if __name__ == "__main__":
    raise SystemExit(main())

08Cómo elegir un endpoint documentado

El laboratorio no pagó Oxylabs ni Decodo. Lo que sigue es forma de endpoint según su documentación, no latencia ni países.

La guía de Residential Proxies de Oxylabs (14 de octubre de 2026) declara soporte HTTP, HTTPS, HTTP/3 (MASQUE) y SOCKS5, autenticación por usuario/contraseña o por IP en lista, y el nodo pr.oxylabs.io:7777 con customer-USERNAME:PASSWORD. El ejemplo oficial de cURL usa -x y -U. Eso encaja con el modelo de esta guía. No prueba su pool.

El quick start residencial de Decodo (mismo día) muestra gate.decodo.com:7000, cURL con -U y -x, y en Python un mapa http/https hacia la misma URL HTTP del proxy. En el catálogo en español de este sitio esa ficha sigue el slug smartproxy-com. El nombre público actual es Decodo. El slug no se inventa.

Comprueba el host con el checker y mira la IP del navegador en Mi IP si estás depurando el browser. Ni uno ni otro sustituyen curl -q -v.

Oxylabs: proxies residenciales y de datacenter
Oxylabs: proxies residenciales y de datacenter4.0770

Oxylabs vende proxies residenciales, de datacenter y móviles. El residencial de autoservicio empieza en $6/GB para 5 GB/mes. Pagos del comprador: tarjetas, transferencia, AliPay, PayPal.

10 países
0,59 US$
por 1 GB
Decodo (Smartproxy): proxies residenciales y de datacenter
Decodo (Smartproxy): proxies residenciales y de datacenter4.22106

Decodo (antes Smartproxy) vende proxies residenciales y de datacenter. Residencial: $4/GB sin plan mensual, o paquetes mensuales desde $3.75/GB por 3 GB. Pagos: tarjetas, PayPal, Alipay, Google/Apple Pay, cripto (con límites).

83 países
0,38 US$
por 1 GB

Las fichas públicas Oxylabs y Decodo (smartproxy-com) respondieron 200 el 14 de octubre de 2026. Eso es presencia pública, no un test de cuenta pagada.

Si eliges residencial o móvil, filtra por protocolo en el catálogo y vuelve a curl. El laboratorio de esta página solo demuestra loopback.

¿Cómo configuro un proxy en curl?

Empieza con curl -q y pasa -x o --proxy con la URL del proxy. El destino va al final. Para loopback que debe usar proxy, añade --noproxy con valor vacío. No confíes en un 200 sin mirar a qué puerto conectó verbose.

¿Por qué un destino HTTPS puede usar un proxy HTTP?

El proxy habla HTTP y abre CONNECT hacia el host TLS. El cifrado sigue entre curl y el origen. No hace falta escribir https:// en la URL del proxy. TLS hacia un proxy HTTPS es otra capa y aquí no se midió.

¿En qué se diferencian -U y -u?

-U es el proxy. -u es el sitio. Un 407 es el proxy. Un 401 es el origen. Puedes acertar uno y fallar el otro en la misma línea.

¿Por qué curl ignora HTTP_PROXY?

CGI copia cabeceras HTTP a variables HTTP_*. Un header Proxy se volvería HTTP_PROXY y un script sería redirigido. curl solo acepta http_proxy en minúsculas. HTTPS_PROXY en mayúsculas sí aplica a destinos https://.

¿Dónde resuelve el nombre socks5h?

socks5h y --socks5-hostname mandan el hostname al proxy. En el laboratorio el ATYP fue domain. socks5 hacia 127.0.0.1 envió ATYP ipv4 y verbose dijo locally resolved. Un GET SOCKS 200 no localiza el DNS.

¿Qué limitan --connect-timeout y --max-time?

--connect-timeout cubre el arranque de conexión. --max-time cubre toda la transferencia, también después del primer byte. Una denegación RST es código 7, no 28. El 28 de --max-time con cuerpo lento es timeout de transferencia, no un read timeout de Requests.