Descripción
Servicio que implementa Trust-On-First-Use (TOFU) por organización para las conexiones SSH server-side que ScrapliManager establece hacia dispositivos de red. Cierra el hallazgo de seguridad Auditoría Suprema Etapa 3, network A9/sa1 + M3/sa2: antes, ScrapliManager conectaba con auth_strict_key=False, deshabilitando completamente la verificación de identidad del host → un atacante en el camino podía suplantar el dispositivo y capturar credenciales.
Ubicación y cobertura
- Archivo:
network/services/ssh_host_keys.py(~99 LOC) - Tests:
tests/api/test_network_ssh_tofu.py(6 tests) - Sin modelo Django, sin migración: el almacén es un
known_hostspor organización bajoMEDIA_ROOT/ssh_known_hosts/<org_id>/
Diseño
TOFU por organización
Dos tenants distintos pueden tener legitimately la misma IP RFC1918 para dispositivos diferentes. Por eso:
- Almacén separado por
org_id:MEDIA_ROOT/ssh_known_hosts/<org_id>/known_hosts - Si
org_id=None(sin tenant), comportamiento antiguo (no-check) - La clave se captura una sola vez en el primer intento; después se exige en todas las conexiones
Nunca reduce conectividad
Refleja el mismo principio que el Agent (terminal/agent/network/host_keys.py, s124):
- Si no hay registro para el host+puerto y no se puede captar la clave (timeout, fallo de red, servidor no responde), degrada a
auth_strict_key=Falsepara ese único intento - Así no bloquea nuevas conexiones a equipos que llegan por primera vez
- Pero si ya hay una clave registrada, la verifica con
ssh_known_hosts_file— una clave distinta hace fallar la conexión (MITM detectado)
Funciones públicas
scrapli_strict_args(org_id, host: str, port: int = 22) -> dict
Firma: Async, retorna dict de kwargs para ScrapliManager.connect.
Lógica:
- Si
org_ides falsy → retorna{"auth_strict_key": False}(sin tenant disponible, comportamiento antiguo) - Si el host+puerto está registrado en el known_hosts de
org_id:- Retorna
{"auth_strict_key": True, "ssh_known_hosts_file": "<path>"}(stricto)
- Retorna
- Si no está registrado:
- Intenta captar la clave con
_fetch_key(host, port)(timeout 10s) - Si se captura: la registra con
_record(...)y retorna dict stricto - Si falla: retorna dict no-stricto (degrada, no bloquea)
- Intenta captar la clave con
Datos de entrada (origen):
org_id: del request (enscripts.py) o del device (enbackups.py)host,port: pasados desdeScrapliManager.connect/execute_script
Sub-componentes privados
_store_path(org_id) -> Path
Retorna MEDIA_ROOT/ssh_known_hosts/<org_id>/known_hosts.
_is_recorded(path, host, port) -> bool
Lee el archivo y busca si ya existe una línea para el host (con el token SSH estándar: host o [host]:port si puerto != 22).
_fetch_key(host, port) -> key_object
Usa asyncssh.get_server_host_key() para captar la clave pública del servidor (timeout 10s). Retorna None si falla.
_record(path, host, port, key) -> None
Exporta la clave en formato OpenSSH y la añade al archivo (con escritura thread-safe mediante lock). Crea el directorio si no existe.
_token(host, port) -> str
Genera el primer campo del known_hosts: host (puerto 22) o [host]:port (otro puerto).
Integración
Modificaciones en ScrapliManager (network/services/scrapli_manager.py)
connect(..., org_id=None): nuevo parámetro, pasado ascrapli_strict_args()- Reemplaza hardcoded
auth_strict_key=Falsecon**strict_argsque incluye host-key file si aplica
Propagación de org_id
scripts.py(endpointrun_script): obtieneorg_iddel request y lo pasa aexecute_script(..., org_id=org.id)backups.py(tareafetch_config): obtieneorg_iddel device y lo pasa aconnect(..., org_id=device.organization_id)ssh_reader.py(lectura de puertos): anotado conorg_id=None(default seguro), a propagar en próximas sesiones
Casos de test
| Test | Escenario |
|---|---|
test_no_org_means_no_checking | Sin org_id → no-check (comportamiento antiguo) |
test_tofu_records_first_time_and_enforces | Primera conexión: captura clave, registra, retorna stricto |
test_recorded_host_is_not_refetched | Host ya registrado: no refetch, retorna stricto sin llamar a _fetch_key |
test_fetch_failure_degrades_to_no_check | No hay registro y no se puede captar → degrada a no-check |
test_per_org_isolation | RFC1918 IP en 2 orgs → almacenes separados |
test_non_standard_port_token | Puerto ≠ 22 → token con [host]:port |
Referencias en el código
network/services/scrapli_manager.py: líneas ~88–91 (import y llamada ascrapli_strict_args)network/api/scripts.py: línea ~318 (propagación deorg.id)network/api/backups.py: línea ~199 (propagación dedevice.organization_id)
Véase también
- [[entity—network—service—scrapli-manager]]
- [[feature—security—ssh-tofu-server-side]]
- [[decision—20260611—ssh-tofu-auditoria-suprema-a9-m3]]
- [[concept—network—ssh-host-verification]]
- [[concept—saas—multi-tenancy]]