CLI

Tres subcomandos. La config por defecto es condor.yaml en el cwd; se cambia con -c/--config.

$ condor check  -c condor.yaml   # valida y sale (exit 0 si es válida)
$ condor start  -c condor.yaml   # inicia listeners + endpoints de gestión
$ condor reload -c condor.yaml   # POST /reload al admin_addr de la config

El reload también se dispara con SIGHUP al proceso (systemctl reload condor).

global

Ajustes de proceso: log y endpoints de gestión, más los límites de robustez (anti-DoS) y el apagado ordenado. Todos los límites traen defaults sanos.

global:
  log_level: info            # debug | info | warn | error
  log_format: json           # json | pretty
  metrics_addr: "127.0.0.1:9090" # /health, /metrics (bindealo local salvo scrape externo)
  admin_addr:   "127.0.0.1:9091" # + /config, /reload (solo local)
  watch_config: true           # vigila el archivo y recarga en caliente al cambiar

  # --- límites de conexión / robustez (anti-DoS) ---
  max_connections: 10000           # tope global de conexiones simultáneas
  max_connections_per_ip: 100      # tope por IP de ORIGEN (socket TCP, no XFF)
  tls_handshake_timeout_secs: 10   # completar el handshake TLS
  header_read_timeout_secs: 30     # enviar headers completos (anti-slowloris)
  max_request_body_mb: 100          # cuerpo máximo de request → 413
  max_header_count: 100             # nº máximo de headers (header flood)
  request_timeout_secs: 60         # tiempo hasta la respuesta del upstream → 408
  graceful_shutdown_enabled: true  # drenar conexiones en vuelo al apagar
  shutdown_timeout_secs: 30        # máximo para drenar antes de forzar
RUST_LOG sobreescribe log_level si está presente. Con watch_config: true (por defecto) Condor vigila tanto condor.yaml como los certificados en modo file, y recarga sin reiniciar. Los límites de robustez no se recargan en caliente (requieren reinicio).
  • max_connections_per_ip cuenta la IP del socket TCP, no X-Forwarded-For. Condor se asume en el borde (ve la IP real). Detrás de un CDN/LB que no preserve la IP, el cupo se contabiliza contra la IP del balanceador — súbelo o desactívalo.
  • request_timeout_secs acota el tiempo hasta la cabecera de la respuesta del upstream, no el streaming del cuerpo: SSE y descargas largas no se cortan.

Compresión (estilo nginx gzip), opt-in bajo global.compression:

compression:
  enable: true                # default false
  min_length: 1024            # no comprimir respuestas más chicas (bytes)
  level: default            # fast | default | best
  algorithms: [gzip]        # [gzip, br] requiere compilar --features brotli
  content_types: [text/, application/json, …]

Streaming (no bufferiza el cuerpo). Salta automáticamente respuestas ya comprimidas, 204/304/206, HEAD, text/event-stream (SSE) y Cache-Control: no-transform. Respeta los q-values de Accept-Encoding y añade Vary. gRPC (application/grpc) no se comprime por defecto.

listeners & TLS

Cada listener enlaza una dirección. La presencia de la clave tls activa HTTPS. Sin ella, es HTTP en claro. El listener detecta HTTP/1.1 y HTTP/2 automáticamente.

listeners:
  - addr: "0.0.0.0:80"          # HTTP en claro

  - addr: "0.0.0.0:443"
    tls:
      mode: file              # file | acme | self_signed
      cert: /etc/condor/tls/fullchain.pem
      key:  /etc/condor/tls/privkey.pem
  • mode: file — carga cert + key en PEM. Soporta SNI multi-dominio. Hot-reload: con watch_config: true, al reescribir los archivos Condor recarga el certificado sin reiniciar el listener (rotación sin downtime).
  • mode: self_signed — genera un cert autofirmado en memoria (dev / interno).
  • mode: acme — emite con Let's Encrypt (ver abajo).

ACME / Let's Encrypt

Con mode: acme, Condor obtiene y renueva certificados vía desafío HTTP-01. Requiere un listener en el puerto 80 accesible públicamente (sirve /.well-known/acme-challenge/) y DNS apuntando al host.

tls:
  mode: acme
  acme_email: ops@example.com
  acme_domains: ["api.example.com", "www.example.com"]
  cache_dir: /var/lib/condor/certs   # account.json + cert.pem + key.pem

El certificado se cachea en cache_dir y se renueva en segundo plano cuando le quedan menos de 30 días de validez (según el notAfter real del certificado, parseado del x509). El resolver SNI se actualiza en caliente cuando llega el cert nuevo.

mTLS (autenticación de cliente)

Por listener TLS, client_auth exige que el cliente presente un certificado firmado por una CA de confianza. La verificación es 100% rustls (sin OpenSSL).

tls:
  mode: file
  cert: /etc/condor/tls/fullchain.pem
  key:  /etc/condor/tls/privkey.pem
  client_auth:
    ca: /etc/condor/tls/clients-ca.pem   # CA(s) que firman los certs de cliente
    optional: false     # false = exige cert | true = lo permite
  • optional: false — el handshake falla si el cliente no presenta un cert válido firmado por la CA. Ninguna petición sin cert llega al backend.
  • optional: true — admite conexiones sin cert; el backend decide leyendo X-Client-Cert-Verify (útil para migraciones graduales).

Tras un handshake correcto, Condor reenvía la identidad del certificado al backend en headers autoritativos. Anti-spoofing: antes elimina cualquier X-Client-Cert-* que el cliente intente falsificar, en todos los listeners (en claro incluido, donde Verify queda en NONE).

  • X-Client-Cert-VerifySUCCESS si presentó cert verificado, NONE si no.
  • X-Client-Cert-Subject — subject DN del cert (RFC 2253), ej. CN=alice,O=falp.
  • X-Client-Cert-Fingerprint — huella SHA-256 del cert (DER), en hexadecimal.

routes

Las rutas se resuelven por especificidad: host exacto → prefijo de path (el más largo gana) → host wildcard (*.dominio) → catch-all.

routes:
  - name: api
    match_host: api.example.com     # opcional; admite *.example.com
    match_path: /v1/*     # exacto (/health) o prefijo (/x/*)
    upstream: app-pool    # debe existir en upstreams (o usar split, ver abajo)
    middleware: [ ... ]

Al reenviar, Condor agrega X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host y X-Request-Id (ULID), elimina los headers hop-by-hop, y propaga o genera un traceparent W3C (correlación de trazas con el backend).

traffic splitting (canary)

Una ruta puede repartir su tráfico entre varios upstreams por peso, en lugar de apuntar a uno fijo. split y upstream son excluyentes.

routes:
  - name: app
    match_path: /app/*
    split:
      - upstream: app-estable
        weight: 90
      - upstream: app-canario
        weight: 10          # weight: 0 = preparado, sin tráfico
  • El reparto usa smooth weighted round-robin: proporciones exactas (9 de cada 10 al estable) e intercaladas, sin ráfagas.
  • weight: 0 deja la entrada lista sin recibir tráfico: para activar el canary o hacer el cutover basta editar los pesos y hacer hot reload — sin reiniciar ni cortar conexiones.
  • El upstream que atendió cada petición queda registrado en las métricas (condor_requests_total{upstream}) y en el audit log: el canary se observa por separado.
  • La elección ocurre después de la auth y el rate limit de la ruta (una petición rechazada no consume turno del reparto).

middleware

Lista ordenada por ruta. Un middleware que rechaza corta el flujo con el status correspondiente.

middleware:
  - type: auth
    mode: jwt                  # jwt | api_key
    jwks_url: "https://auth.example.com/.well-known/jwks.json"
    issuer: "https://auth.example.com"   # valida iss (recomendado)
    audience: "api"                      # valida aud (recomendado)
    algorithms: ["RS256", "ES256"]        # permitidos; default = set asimétrico
    # mode: api_key → api_keys: ["k1", "k2"]  (header X-Api-Key)
    # mode: basic   → realm + users: {alice: "plain:..", bob: "$2b$.."} (bcrypt)

  - type: ip_acl               # control de acceso por IP (ponlo PRIMERO)
    allow: ["10.0.0.0/8"]       # CIDR v4/v6; default-deny fuera del allow
    deny:  ["10.6.6.6"]         # deny gana siempre → 403

  - type: ratelimit
    requests_per_second: 500
    burst: 100                  # token bucket por IP → 429 + Retry-After

  - type: audit                 # registra la petición (ver audit)

  - type: response_headers      # headers de RESPUESTA al cliente
    set:
      Strict-Transport-Security: "max-age=31536000"
      X-Content-Type-Options: "nosniff"
    remove: [server]              # oculta el Server del backend

  - type: redirect             # respuesta 3xx (no toca el upstream)
    to: "https://nuevo.example.com/"
    status: 308                # 301 | 302 | 307 | 308 (default 308)

  - type: rewrite              # reescribe el prefijo del path
    strip_prefix: /api        # /api/users → /users
    add_prefix: /v2          # → /v2/users (tras el strip)

  - type: waf                  # firewall de aplicación (ver sección WAF)
    mode: block                # detect (audita) | block (403)
    rulesets: [sqli, xss, path_traversal, cmd_injection]
  • auth jwt — valida el Bearer contra el JWKS (refrescado en background, con timeout y límite de tamaño); el sub queda como user_id en el audit. El algoritmo se fija por config (no se toma del token → sin confusión de algoritmo); si configurás issuer/audience se exigen y validan. Falla → 401/403.
  • auth api_key — compara el header X-Api-Key contra la lista.
  • auth basic — HTTP Basic (RFC 7617). Credenciales por usuario: plain:<pass> (dev) o hash bcrypt $2… (htpasswd, prod). Comparación en tiempo constante; 401 con WWW-Authenticate.
  • ip_aclallow/deny por IP de origen con CIDR (IPv4/IPv6). deny gana; un allow no vacío hace default-deny. Usa la IP del socket TCP (no XFF); las IPv4-mapeadas se canonicalizan. 403 al denegar.
  • waf — firmas de ataque sobre path/query/headers (ver sección WAF). Rulesets integrados + reglas propias; detect/block.
  • ratelimit — token bucket por IP de origen; excedido → 429.
  • redirect — emite 301/302/307/308 con Location sin reenviar al upstream (la auth de la ruta se evalúa antes).
  • rewrite — reescribe el prefijo del path (strip_prefix/add_prefix) antes de reenviar.
  • headers — set/remove sobre los headers de la petición al upstream.
  • response_headers — set/remove sobre los headers de la respuesta al cliente (HSTS, nosniff, ocultar Server…).

WAF

El middleware waf inspecciona la línea de petición (path, query) y las cabeceras contra firmas de ataque, y rechaza (o solo audita) las coincidencias. Nativo, no un plugin dinámico: las reglas se compilan en el binario, sin cargar código de terceros en runtime.

middleware:
  - type: waf
    mode: block                # detect (solo audita) | block (rechaza 403)
    rulesets: [sqli, xss, path_traversal, cmd_injection]
    rules:                    # reglas propias (opcional), pattern = regex
      - name: bloquea-shell
        pattern: "/bin/(ba)?sh"
        targets: [path, query]  # path | query | headers (default: los 3)
  • Rulesets integrados, curados para alta precisión (pocas firmas de alta confianza, no recall exhaustivo): sqli, xss, path_traversal, cmd_injection.
  • Motor regex de tiempo lineal, sin backtracking → el WAF no puede volverse vulnerable a ReDoS. Un patrón propio que no compila falla al cargar (fail-closed).
  • Normalización anti-evasión: path y query se percent-decodifican una vez antes de inspeccionar, así un ../ escrito %2e%2e%2f se detecta igual.
  • Modos: detect cuenta y audita dejando pasar la petición (para estrenar sin romper tráfico); block además responde 403. Recomendado: arrancar en detect, revisar falsos positivos en el audit, pasar a block.
  • Cada match emite condor_waf_matched_total{route,rule,action} y una línea de log con la regla y la IP de origen.
Inspecciona path/query/headers, no el cuerpo de la petición (evita bufferizarlo y romper el streaming) — cubre la superficie de URL y cabeceras, por donde llega la mayoría de las inyecciones. Como todo middleware, el orden importa: declaralo temprano (idealmente tras ip_acl).

upstreams

Pool de targets con estrategia de balanceo, health checks y circuit breaker.

upstreams:
  - name: app-pool
    strategy: round_robin   # round_robin (ponderado) | least_conn | random | ip_hash
    http2: false             # true = HTTP/2 hacia el upstream (h2c, o h2 vía ALPN si tls)
    tls: false               # true = conectar por https:// al upstream
    tls_server_name: backend.internal  # SNI/verificación si el target es IP (requiere tls)
    tls_insecure: false      # true = no verificar el cert (solo backends internos)
    timeout_ms: 30000        # timeout por petición al upstream
    retries: 0                # reintentos ante fallo de transporte (idempotentes)
    targets:
      - { addr: "10.0.0.11:8080", weight: 1 }
      - { addr: "10.0.0.12:8080", weight: 2 }
    health_check:
      path: /health
      interval_secs: 5
      timeout_ms: 2000
      healthy_threshold: 2
      unhealthy_threshold: 3
    circuit_breaker:
      failure_threshold: 50     # % de error en la ventana para abrir
      recovery_secs: 30         # tiempo en Open antes de HalfOpen
    outlier_detection:           # eyección pasiva per-target (ver abajo)
      consecutive_5xx: 5
  • tls / tls_server_name / tls_insecure — HTTPS hacia el upstream; tls_server_name fija el SNI y el nombre a verificar cuando el target es una IP.
  • timeout_ms — corta la petición al upstream si excede; retries reintenta solo en fallos de transporte (conexión rechazada / timeout) y solo en métodos idempotentes, eligiendo otro target del pool.

outlier detection (eyección pasiva)

Complementa al health check activo y al circuit breaker (que opera sobre el upstream completo): observa las respuestas del tráfico real y saca de la rotación al target que acumula errores consecutivos (5xx o fallo de transporte).

outlier_detection:
  consecutive_5xx: 5        # errores seguidos para eyectar (default 5)
  base_ejection_secs: 30    # duración base; crece linealmente al reincidir
  max_ejection_secs: 300    # tope del backoff
  max_ejection_percent: 50  # % máximo del pool eyectado a la vez
  • Pasada la eyección, el target se readmite automáticamente; si reincide, la siguiente eyección dura más (backoff lineal, acotado por max_ejection_secs).
  • Dos guardas evitan que la detección empeore un incidente: nunca se eyecta el último target servible (mejor degradado que apagado) y max_ejection_percent acota cuántos targets pueden estar fuera a la vez.
  • Estado visible en GET /health (el target figura como "ejected") y en las métricas condor_outlier_*.
  • Los errores atribuibles al cliente (p. ej. cuerpo que excede el límite → 413) no cuentan contra el target ni contra el circuit breaker.

WebSocket / Upgrade

Automático, sin configuración. Toda petición HTTP/1.1 con Connection: Upgrade (WebSocket u otro protocolo) se tuneliza: Condor preserva los headers Connection y Upgrade hacia el upstream y, ante un 101 Switching Protocols, establece un túnel bidireccional crudo entre cliente y backend. La auth y el rate limit de la ruta se aplican igual al handshake. El túnel no está sujeto a request_timeout (que solo acota el tiempo hasta el 101). Soportado sobre upstreams HTTP/1.1; no hacia upstreams con http2: true.

audit

Log append-only en JSONL, una línea por petición. Nunca modifica líneas existentes (compatible con sinks WORM).

audit:
  enabled: true
  path: /var/log/condor/audit.jsonl
  rotate: daily             # daily | never → audit-YYYY-MM-DD.jsonl
  fields: [timestamp, request_id, client_ip, method, host,
           path, status, upstream, duration_ms, user_id, trace_id]

Cada línea, con claves compactas (tid = trace-id W3C, para correlacionar con el tracing del backend):

{"ts":"2026-05-24T03:14:15.926Z","rid":"01J...","cip":"1.2.3.4",
 "m":"GET","host":"api.example.com","path":"/v1/patients",
 "st":200,"up":"app-pool","dur_ms":12,"uid":"alice","tid":"0af765..."}
Cambiar audit.fields requiere reiniciar el servicio (no se recarga en caliente). El resto (rutas, upstreams, middleware, certificados file) sí.

endpoints de gestión

  • GET /health — estado y salud de upstreams (JSON). En metrics_addr y admin_addr.
  • GET /metrics — formato de texto Prometheus.
  • GET /config — config actual sin secretos. Solo en admin_addr.
  • POST /reload — dispara hot reload. Solo en admin_addr.

métricas

condor_requests_total{route,upstream,status,method}
condor_request_duration_seconds{route,upstream}
condor_upstream_health{upstream,target}
condor_circuit_state{upstream}            # 0=closed 1=open 2=half
condor_active_connections{listener}
condor_tls_handshakes_total{listener,result}
condor_ratelimit_rejected_total{route}
condor_upstream_pool_size{upstream}
condor_connections_rejected_total{listener,scope}  # scope=global|per_ip
condor_http_limit_rejected_total{kind}             # kind=body|timeout
condor_active_upgrades{upstream}                    # túneles WebSocket activos
condor_ipacl_rejected_total{route}                  # rechazos por IP ACL (403)
condor_redirects_total{route,status}                # redirects 3xx emitidos
condor_responses_compressed_total{encoding}         # respuestas comprimidas
condor_outlier_ejections_total{upstream,target}     # eyecciones por outlier detection
condor_outlier_ejected{upstream,target}             # 1 = eyectado ahora mismo
condor_waf_matched_total{route,rule,action}         # coincidencias del WAF (detect|block)