CLI
La config por defecto es condor.yaml en el cwd; se cambia con -c/--config. Los comandos operativos hablan con el admin_addr de una instancia viva.
$ 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 $ condor targets # estado por target: salud, drenaje, en vuelo $ condor drain app-pool nodo1 # drena (--full = retiro total) $ condor undrain app-pool nodo1 # reactiva $ condor purge [ruta] # vacía el cache de respuestas
El reload también se dispara con SIGHUP al proceso (systemctl reload condor).
secretos y variables de entorno
Cualquier valor del YAML admite ${VAR} y ${VAR:-default}, que se expanden desde el entorno del proceso antes de parsear. Los secretos viven en el entorno del servicio, no en el archivo versionado.
middleware: - type: auth mode: api_key api_keys: - "${CONDOR_API_KEY}" # obligatoria: sin ella, Condor no arranca upstreams: - name: app timeout_ms: ${APP_TIMEOUT_MS:-30000} # con default
Con systemd, los valores se cargan desde un archivo fuera del repositorio:
# /etc/condor/condor.service EnvironmentFile=-/etc/condor/condor.env
- Una variable ausente o vacía sin default es un error de arranque, no una cadena vacía: una API key vacía dejaría la ruta abierta de par en par.
- El
-deEnvironmentFile=-hace opcional el archivo, así que los despliegues sin secretos arrancan igual. - Un
$que no abre${se deja intacto: los paths y regex de las rutas no se ven afectados. condor checkno hereda el entorno de systemd; para validar a mano hay que cargarlo primero:set -a; . /etc/condor/condor.env; set +a.
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_LOGsobreescribelog_levelsi está presente. Conwatch_config: true(por defecto) Condor vigila tantocondor.yamlcomo los certificados en modofile, y recarga sin reiniciar. Los límites de robustez no se recargan en caliente (requieren reinicio).
max_connections_per_ipcuenta la IP del socket TCP, noX-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_secsacota 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— cargacert+keyen PEM. Soporta SNI multi-dominio. Hot-reload: conwatch_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 leyendoX-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-Verify—SUCCESSsi presentó cert verificado,NONEsi 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: 0deja 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).
afinidad de sesión
Para aplicaciones con estado en memoria (Tomcat/JSF/ZK, apps legacy): con strategy: session_affinity una sesión existente conserva su target y las sesiones nuevas se asignan con la estrategia fallback.
upstreams: - name: app-cluster strategy: session_affinity session_affinity: cookie: CONDOR_ROUTE # nombre de la cookie (default) secret: "compartido-entre-instancias-ha" # firma HMAC-SHA256 (mín. 16 chars) fallback: least_conn # estrategia para sesiones NUEVAS on_lost: reassign # target caído/drenado: reassign | error (503) same_site: lax # lax | strict | none · + secure, http_only, path jvm_route_compat: true # leer sufijo `.{id}` del JSESSIONID (Tomcat) targets: - addr: "10.0.1.11:8080" id: nodo1 # identidad estable (alias: route) - addr: "10.0.1.12:8080" id: nodo2
- La cookie lleva el id del target firmado con HMAC-SHA256: una cookie manipulada se rechaza (verificación en tiempo constante) y la sesión se reasigna con cookie nueva. El secreto compartido hace que la afinidad funcione igual entre varias instancias de Condor en HA.
ides la identidad estable del target: sobrevive a cambios de addr y es la clave del drenaje. Default: el addr como texto.on_lostdefine qué pasa si el target de una sesión ya no está disponible:reassign(default) la mueve a otro nodo y refresca la cookie;errorresponde 503 explícito.jvm_route_compatlee el sufijo.{id}delJSESSIONID(formatojvmRoutede Tomcat) cuando aún no hay cookie propia — migración transparente desde Apache/mod_jk. La primera respuesta fija la cookie firmada.- La afinidad aplica también a túneles WebSocket/Upgrade.
- Outcome por petición en
condor_affinity_total{upstream,outcome}:hit,new,reassigned,tampered,lost.
drenaje de targets
Cada target tiene un estado administrativo controlado por API/CLI, para retirar nodos sin cortar sus sesiones vivas. El estado sobrevive al hot reload.
| Estado | Sesiones existentes | Sesiones nuevas |
|---|---|---|
active | sí | sí |
draining | sí | no |
drained | no | no |
$ condor drain app-cluster nodo1 # mantenimiento: las sesiones vivas se quedan $ condor targets # observar salud, drenaje y peticiones en vuelo $ condor drain app-cluster nodo1 --full # retiro total (reassign o 503 según on_lost) $ condor undrain app-cluster nodo1 # reactivar
- El target se identifica por su
idestable o por suaddr(ip:puerto). - En pools sin afinidad,
drainingequivale a sacar el target de la rotación (cada petición cuenta como nueva). - Estado visible en
GET /health("healthy (draining)"), enGET /targets(detalle con peticiones en vuelo) y en la métricacondor_target_admin_state.
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); elsubqueda comouser_iden el audit. El algoritmo se fija por config (no se toma del token → sin confusión de algoritmo); si configurásissuer/audiencese exigen y validan. Falla → 401/403.auth api_key— compara el headerX-Api-Keycontra 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 conWWW-Authenticate.ip_acl—allow/denypor IP de origen con CIDR (IPv4/IPv6).denygana; unallowno 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— emite301/302/307/308conLocationsin 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, ocultarServer…).
cache de respuestas
Cache en memoria por ruta, estilo proxy_cache de nginx: LRU por bytes con TTL y captura en streaming (el cliente no espera al cache). Semántica conservadora por defecto.
middleware: - type: cache ttl_secs: 60 # vida de cada entrada max_size_mb: 64 # presupuesto de la ruta (eviction LRU) max_object_kb: 1024 # objetos mayores pasan de largo (streaming) # respect_cache_control: true # el Cache-Control del upstream manda
- Solo se cachea
GET+ 200 sinSet-Cookie, sinContent-Encodingy sinVary(salvoAccept-Encoding).no-store/privatedel upstream nunca se cachean; sumax-ageacota el TTL. - Peticiones con
Authorizationhacen bypass completo (contenido potencialmente por-usuario). La auth y el rate limit de la ruta corren igual en los hits. - Se cachea el cuerpo crudo: la compresión gzip sigue negociándose por petición según el
Accept-Encodingde cada cliente. - Respuestas marcadas
X-Cache: HIT|MISS+ headerAge. La clave eshost + path + query. - Purga:
condor purge [ruta]oPOST /cache/purge[/{ruta}]en el admin. Un hot reload arranca con el cache frío. - Métricas:
condor_cache_events_total{route,event}(hit/miss/store/evict/purge) ycondor_cache_bytes{route}.
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
regexde 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%2fse detecta igual. - Modos:
detectcuenta y audita dejando pasar la petición (para estrenar sin romper tráfico);blockademás responde403. Recomendado: arrancar endetect, revisar falsos positivos en el audit, pasar ablock. - 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 | least_conn | random | ip_hash | session_affinity (ver abajo) 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_namefija el SNI y el nombre a verificar cuando el target es una IP.timeout_ms— corta la petición al upstream si excede;retriesreintenta solo en fallos de transporte (conexión rechazada / timeout) y solo en métodos idempotentes, eligiendo otro target del pool.
estrategias de balanceo
strategy | reparto |
|---|---|
round_robin | Smooth weighted round-robin (algoritmo de nginx), ponderado por weight |
least_conn | Menos peticiones en vuelo, normalizado por weight |
random | Aleatorio ponderado |
ip_hash | Rendezvous hashing ponderado por IP de cliente |
session_affinity | Cookie firmada (ver afinidad de sesión) |
ip_hash — rendezvous hashing (HRW)
Una IP cae siempre en el mismo target, sin estado compartido entre instancias de Condor. Para cada target sano se calcula -weight / ln(u), donde u es el hash del par (IP, target.id) normalizado a (0,1), y gana el máximo.
Eso da la propiedad que importa: el ganador depende sólo del par. Cuando un target cae —o vuelve, o se agrega uno nuevo— únicamente se remapean las IPs que lo tenían como ganador, y el resto conserva su destino. Un hash(ip) % n reasignaría casi todas las claves ante cualquier cambio del pool, que es justo lo que ip_hash existe para evitar.
- El score se ancla al
iddel target (el mismo que usan afinidad y drenaje), no a su posición: reordenar la listatargets:en el YAML no remapea nada. - Los pesos se respetan: la probabilidad de ganar es proporcional a
weight. - Es O(n) por decisión, sin memoria adicional. Con pools de decenas de targets son unos pocos hashes frente a un round-trip TCP; el anillo de réplicas virtuales estilo ketama sólo compensa con miles de targets.
- Peticiones sin IP de cliente conocida caen al reparto aleatorio ponderado.
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_percentacota cuántos targets pueden estar fuera a la vez. - Estado visible en
GET /health(el target figura como"ejected") y en las métricascondor_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..."}
Cambiaraudit.fieldsrequiere reiniciar el servicio (no se recarga en caliente). El resto (rutas, upstreams, middleware, certificadosfile) sí.
endpoints de gestión
GET /health— estado y salud de upstreams (JSON). Enmetrics_addryadmin_addr.GET /metrics— formato de texto Prometheus.GET /config— config actual sin secretos. Solo enadmin_addr.POST /reload— dispara hot reload. Solo enadmin_addr.GET /targets— detalle por target: id, salud, eyección, drenaje y peticiones en vuelo. Solo admin.POST /drain/{upstream}/{target}— drena el target (?full=true⇒drained). Solo admin.POST /undrain/{upstream}/{target}— reactiva el target. Solo admin.POST /cache/purge[/{ruta}]— vacía el cache de respuestas. Solo admin.
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)
condor_affinity_total{upstream,outcome} # afinidad: hit|new|reassigned|tampered|lost
condor_target_admin_state{upstream,target} # 0=active 1=draining 2=drained
condor_cache_events_total{route,event} # cache: hit|miss|store|evict|purge
condor_cache_bytes{route} # bytes en el cache de respuestas