# Multiplexación de conexiones SSH: riesgos de los sockets de control

La multiplexación de conexiones SSH puede dejar abierta una ruta autenticada hacia un host después de que termine el comando que la creó. Es un comportamiento deliberado. También es fácil interpretarlo mal cuando una tarea automatizada, una ejecución de un agente o un trabajo de despliegue afirma haber terminado.

He visto a equipos investigar una ventana de mantenimiento supuestamente cerrada y descubrir que un master local de `ssh` todavía mantenía un transporte activo, que un socket aún aceptaba nuevos clientes y que un comando posterior lo reutilizaba sin otra autenticación interactiva. No ocurrió nada extraño. La configuración hacía exactamente lo que documenta OpenSSH. El equipo había confundido el final de un comando de shell con el final del acceso.

La diferencia importante está entre un **comando del cliente** completado y un **transporte autenticado** terminado. La multiplexación convierte ambos en eventos separados. Si ejecutas trabajo autónomo mediante SSH, la limpieza y las evidencias deben tener en cuenta esa separación.

## Un socket de control puede durar más que el comando que lo abrió

La multiplexación SSH usa una única conexión SSH de larga duración, llamada master, para transportar el trabajo de procesos cliente SSH posteriores. En la documentación de OpenSSH, esos clientes posteriores suelen llamarse slaves. Cuando un slave contacta correctamente con el master local a través de su socket de control, no repite la configuración normal de la conexión ni la autenticación del usuario.

Una configuración típica parece inofensiva:

```sshconfig
Host build-box
    HostName 192.0.2.44
    User deploy
    ControlMaster auto
    ControlPath ~/.ssh/cm/%C
    ControlPersist 20m
```

El primer `ssh build-box` crea una conexión de red y un socket de dominio Unix en `~/.ssh/cm/`. Los comandos posteriores, como `ssh build-box 'uname -a'`, `scp` y `sftp`, pueden usar ese socket. Con `ControlPersist 20m`, el master permanece disponible durante veinte minutos después de cerrar su última sesión.

Por eso esta secuencia es normal:

1. Una tarea ejecuta `ssh build-box 'apply-change'` y termina con el estado cero.
2. El master sigue conectado porque `ControlPersist` le indica que permanezca disponible.
3. Otro proceso local ejecuta `ssh build-box 'read-status'` diecinueve minutos después.
4. Ese proceso abre un canal a través del transporte ya autenticado.

El comando posterior puede estar autorizado por el sistema operativo local y por los permisos del socket, pero no hace que el host remoto vea una nueva autenticación SSH. Un revisor que solo mire el registro del inicio de sesión inicial puede suponer fácilmente que la actividad terminó mucho antes.

OpenSSH describe `ControlMaster` en el manual de `ssh_config` como una opción que permite varias sesiones sobre una sola conexión de red, y describe `ControlPersist` como la opción que mantiene el master abierto en segundo plano. Lee ambas descripciones juntas. `ControlPersist` no es solo una opción de rendimiento. Cambia el periodo durante el cual un proceso local puede solicitar nuevos canales en una conexión autenticada.

Para una persona que trabaja en un equipo, ese intercambio puede ser razonable. Para una automatización limitada a una tarea, necesita un responsable y una acción explícita de cierre.

## Un evento de autenticación no es un evento de comando

Los equipos suelen mezclar autenticación, conexión, canal y comando. En SSH son cosas distintas, y la multiplexación deja clara esa diferencia.

El master inicial crea una conexión TCP con el servidor, verifica el host, negocia la criptografía y autentica la cuenta. Una vez autenticado, puede abrir canales SSH. Un comando de shell, un shell interactivo, una transferencia SFTP, una solicitud de reenvío de puerto local y una solicitud de reenvío de puerto remoto usan canales o solicitudes relacionadas con canales dentro de ese transporte.

Una nueva invocación local de `ssh` que encuentra un socket de control utilizable envía una solicitud a través de él. El master decide si abre el canal solicitado. No necesita volver a recibir la clave privada. No necesita otra solicitud del agente. El servidor tampoco recibe otro intento de inicio de sesión.

Esto no significa que OpenSSH guarde una contraseña reutilizable en el socket. Esa explicación es imprecisa y conduce a análisis deficientes. El problema de seguridad es un transporte ya autenticado con una interfaz local que puede pedirle que actúe. Un atacante que pueda usar el socket quizá no necesite la credencial original.

La diferencia cambia las preguntas de un incidente. «¿La clave seguía en el disco?» importa, pero no determina si el acceso continuaba. Pregunta lo siguiente:

- ¿Quedó un proceso master conectado al destino remoto?
- ¿Podía otro proceso local alcanzar su socket de control?
- ¿El master aceptó sesiones adicionales, transferencias de archivos o solicitudes de reenvío?
- ¿Quién podía ejecutarse como propietario del socket o atravesar el directorio del socket?
- ¿Cuándo terminó realmente el master?

Un ejecutor de tareas que informa del código de salida de un comando no responde por sí solo a ninguna de esas preguntas. El código solo describe el proceso hijo que esperó.

## Los sockets compartidos convierten los límites entre procesos locales en límites de acceso

Un socket de control es un socket de dominio Unix local. Su ubicación y sus permisos en el sistema de archivos determinan qué procesos locales pueden intentar comunicarse con el master. Por eso un `ControlPath` demasiado amplio puede unir trabajos independientes aunque los alias de los hosts remotos parezcan ordenados.

Considera una cuenta de un ejecutor de CI con esta configuración:

```sshconfig
Host *
    ControlMaster auto
    ControlPath /tmp/ssh-%r@%h:%p
    ControlPersist 1h
```

El trabajo A se conecta como `deploy` a `app.internal`. Crea un master y un socket en `/tmp`. Después termina. El trabajo B, que se ejecuta con la misma cuenta local, se conecta al mismo destino. Puede reutilizar el transporte autenticado del trabajo A si puede conocer y acceder a ese socket.

Esta configuración es popular porque acelera los comandos repetidos casi sin cambiar la aplicación. Es incorrecta para trabajos independientes porque el límite de reutilización se basa en el host, el puerto y el usuario, no en el trabajo que posee la autorización. Una cuenta compartida del ejecutor convierte el error en acceso habitual entre tareas.

El directorio importa tanto como el archivo de socket. En los sistemas Unix, un proceso necesita permiso de búsqueda en el directorio para alcanzar una ruta. Un socket guardado en un directorio privado, con propietario restringido y modo 0700, ofrece un límite mucho más claro que un directorio temporal compartido. Los permisos del socket también importan, pero no debes tratarlos como el único control.

Usa un directorio creado para la tarea y propiedad de la cuenta que la ejecuta. Un wrapper de shell puede hacerlo sin depender de una configuración SSH global:

```sh
set -eu
run_id="release-4821"
cm_dir="$HOME/.ssh/task-control/$run_id"
mkdir -p "$cm_dir"
chmod 700 "$cm_dir"

socket="$cm_dir/%C"
ssh -o ControlMaster=auto \
    -o ControlPersist=5m \
    -o ControlPath="$socket" \
    build-box 'id && hostname'
```

El token `%C` evita un nombre literal demasiado largo y reduce las colisiones entre conjuntos distintos de parámetros de conexión. El manual de `ssh_config` de OpenSSH lo define como un hash de los detalles de la conexión. Es útil, pero no incluye el ticket de despliegue, la sesión del agente ni el ID de la tarea. En este ejemplo, el directorio padre aporta ese límite que falta.

No coloques un socket de control literal en un checkout de un repositorio, en un espacio de trabajo con permisos de escritura amplios ni en `/tmp` solo porque esos lugares sean cómodos. La comodidad es la razón por la que un socket local se convierte en una capacidad compartida por accidente.

## ControlPersist es una política de retención, no un plan de limpieza

`ControlPersist` indica a SSH que mantenga disponible un master después de cerrar las sesiones cliente. Acepta `yes`, que lo mantiene ejecutándose indefinidamente en segundo plano, o un periodo como `10m`. Ambas opciones son decisiones de retención.

Un tiempo de espera ayuda cuando un cliente se bloquea antes de la limpieza. No demuestra que la tarea terminó al mismo tiempo que el acceso. Durante ese periodo, un proceso con acceso al socket puede solicitar nuevo trabajo. Una hora es especialmente difícil de justificar cuando una tarea de despliegue duró dos minutos.

Hay casos en los que un tiempo breve es razonable. Un proceso de automatización controlado puede ejecutar varios comandos seguidos y beneficiarse de una conexión ya preparada. En ese caso, haz que el tiempo sea menor que el intervalo inactivo esperado, aísla el socket para esa ejecución y termina el master cuando la tarea concluya correctamente. Así, el tiempo de espera funciona como respaldo ante un fallo, no como mecanismo normal de cierre.

La opción `ControlPersist yes` necesita un responsable con un motivo duradero para mantener el acceso. Un equipo de trabajo interactivo de un administrador puede tenerlo. Una tarea desechable no.

Otro error frecuente es suponer que un fallo del comando cierra el master. Un shell puede regresar antes después de que falle un comando remoto mientras el master en segundo plano sigue activo. Una cancelación de la tarea puede producir el mismo resultado. La limpieza debe ejecutarse después del éxito, del fallo y de una interrupción, y debe registrar si el cierre funcionó.

Si tu automatización usa un trap, mantén la limpieza limitada y verifica su objetivo. No elimines primero la ruta del socket sin comprobar nada. Quitar una ruta puede dificultar la investigación mientras el proceso master y la conexión siguen existiendo.

```sh
cleanup() {
    ssh -S "$socket" -O exit build-box >/dev/null 2>&1 || true
    rmdir "$cm_dir" 2>/dev/null || true
}
trap cleanup EXIT HUP INT TERM
```

Este patrón pide al master que termine antes de intentar eliminar el directorio. Si `ssh -O exit` falla, conserva el directorio e investiga en lugar de borrar las evidencias. En código de producción, escribe el fallo, el ID del proceso y la ruta del socket en el registro de la tarea.

## `exit` y `stop` tienen significados operativos distintos

OpenSSH expone comandos de control mediante `ssh -O`. Los operadores suelen usar el incorrecto porque ambos nombres parecen relacionados con la limpieza.

`ssh -O check host` pregunta si hay un master en ejecución e informa de su ID de proceso cuando responde uno. `ssh -O exit host` pide al master que termine. Para una tarea completada, `exit` suele ser el comando adecuado porque termina el transporte reutilizable.

`ssh -O stop host` indica al master que deje de aceptar nuevas sesiones multiplexadas. Las sesiones existentes continúan. Puede ser útil cuando un operador necesita dejar que el trabajo activo termine, pero no permite afirmar que todo el acceso SSH terminó al completarse la tarea. Un master activo con canales activos sigue teniendo una conexión de red activa.

Usa explícitamente la ruta del socket cuando el diagnóstico no deba depender de la configuración actual del usuario:

```sh
ssh -S "$socket" -O check build-box
# Master running (pid=41782)

ssh -S "$socket" -O exit build-box
# Exit request sent.

ssh -S "$socket" -O check build-box
# Control socket connect(...): No such file or directory
```

Las palabras y el texto exacto de los errores varían según la plataforma y la versión de OpenSSH. Captura tanto la salida estándar como la de error en lugar de interpretar una frase como un contrato. La forma de las evidencias importa: una comprobación correcta identifica un master activo, una salida correcta envía la solicitud de terminación y una comprobación posterior fallida respalda la afirmación de que ningún socket de control respondió.

Hay un caso incómodo que conviene tener en cuenta. El socket puede permanecer después de que el master se bloquee, y un ID de proceso puede desaparecer entre dos comprobaciones. Una ruta obsoleta no demuestra que el acceso siga existiendo. A la inversa, una comprobación sin respuesta no demuestra que la conexión remota terminó si eliminaste la ruta antes de examinarla. Comprueba la tabla de procesos y los sockets Unix abiertos antes de borrar nada.

En macOS, `lsof` suele ser la herramienta local de inspección más rápida:

```sh
lsof -nP -U | grep '/.ssh/task-control/'
ps -p 41782 -o pid=,ppid=,lstart=,etime=,command=
```

Guarda la salida en el registro de la tarea. El primer comando relaciona un socket Unix con un proceso. El segundo aporta el proceso padre, la hora de inicio, el tiempo transcurrido y los detalles de la invocación. Trata `grep` como una ayuda interactiva, no como un control de auditoría. Un recopilador real debe consultar y guardar directamente los registros relevantes.

## El reenvío de puertos hace más importante a un master inactivo

Un master que parece inactivo todavía puede mantener un estado de reenvío o aceptar después una solicitud de reenvío. Por eso contar únicamente los comandos de shell ofrece una visión incompleta.

El reenvío local expone un listener local que envía tráfico a través de la conexión SSH. El reenvío remoto pide al servidor que escuche y devuelva las conexiones a través del cliente. El reenvío dinámico crea un proxy SOCKS. Cada uno puede durar más que el comando que lo estableció, según cómo se hayan invocado la sesión y el master.

Los manuales de OpenSSH documentan comandos de control como `forward` y `cancel` para las solicitudes de reenvío cuando la multiplexación está activa. Esto resulta útil en operaciones. También significa que un proceso que alcance el socket puede solicitar rutas de red que van más allá de un comando de shell normal, según la política del servidor y el estado del master.

En tareas automatizadas, trata el reenvío como una excepción explícita. Registra la dirección de enlace, el puerto local o remoto, el host y el puerto de destino y el resultado de la limpieza. No permitas que un wrapper SSH genérico herede silenciosamente directivas `LocalForward`, `RemoteForward` o `DynamicForward` de una configuración amplia `Host *` del usuario.

Inspecciona la configuración resuelta antes de confiar en un alias:

```sh
ssh -G build-box | grep -E '^(controlmaster|controlpath|controlpersist|localforward|remoteforward|dynamicforward) '
```

`ssh -G` muestra la configuración efectiva después de que OpenSSH aplique las coincidencias de host y los valores predeterminados. Esta comprobación detecta un fallo sorprendentemente común: una tarea usa un alias sencillo, pero un archivo de configuración incluido activa la multiplexación o el reenvío lejos de la configuración propia de la tarea. El comando puede mostrar más campos en distintas versiones. Guarda la salida completa de `ssh -G`, no solo las líneas que esperabas.

Un sistema remoto puede registrar una única conexión de origen mientras varias conexiones de aplicaciones reenviadas pasan por ella. La telemetría de red, los registros SSH del servidor y los registros de la tarea responden a partes distintas de la historia. Ninguno puede sustituir a los demás.

## Los registros del servidor por sí solos no pueden reconstruir la secuencia de decisiones locales

El servidor SSH remoto ve el transporte y lo que su configuración de registro conserve sobre canales y comandos. No puede decirte de forma fiable por qué un proceso local recibió permiso para usar un socket de control, qué tarea era propietaria del directorio del socket ni si otro proceso local lo reutilizó después de que terminara la tarea original.

Esto no es una crítica a los registros del servidor. Operan en otro punto del sistema. `sshd` puede registrar eventos de autenticación y conexión. Un wrapper de comandos forzados o un subsistema de auditoría puede registrar comandos remotos. Esos registros siguen siendo útiles. De forma predeterminada, no identifican a cada cliente local de multiplexación porque el servidor puede ver todos los canales como parte de un único transporte ya autenticado.

Conserva evidencias a ambos lados del límite. Un registro útil de la tarea incluye:

- la configuración completa del cliente obtenida con `ssh -G`, sin secretos;
- el nombre y la dirección del host remoto, la cuenta, el resultado de la verificación de la huella del host y el ID inicial del proceso master;
- la ruta del socket de control, el directorio padre privado y las horas observadas de creación y salida;
- cada comando remoto, transferencia o operación de reenvío solicitada, con su estado de salida;
- el resultado de `check` antes de la limpieza, el resultado de `exit` y la inspección posterior de procesos y sockets.

Añade un identificador de tarea al nombre del directorio y al registro, no a una ruta de socket global compartida por todas las tareas. El identificador conecta los eventos locales sin fingir que por sí solo es un límite de seguridad.

Aplicar un hash o firmar el registro después de recopilarlo ayuda a detectar modificaciones posteriores, pero no repara los eventos que faltan. Recopila los eventos del ciclo de vida a medida que ocurren. Un registro armado a partir del historial del shell después de un incidente es una evidencia débil, especialmente cuando intervienen masters en segundo plano y reintentos.

Para el trabajo controlado por agentes, distingue la intención del agente de la acción SSH ejecutada. «Desplegar la versión X» es una intención. `ssh build-box 'sudo systemctl restart api'` es una acción. El registro de reutilización del socket explica si esa acción obtuvo un transporte nuevo o viajó por uno existente. Son hechos de auditoría distintos.

## Los sockets limitados a una tarea dan un responsable a la limpieza

El patrón más seguro para trabajo automatizado breve es sencillo: usa un directorio de socket de control único por tarea, permite la reutilización solo dentro de esa tarea y envía una solicitud explícita de `exit` antes de informar de que la tarea terminó.

Un wrapper práctico necesita un ciclo de vida claro. Debe crear el directorio con permisos restrictivos, escribir un registro antes de la primera conexión, ejecutar los comandos con el mismo `ControlPath` explícito, apagar el master, verificar el resultado y solo después eliminar el directorio vacío. Una tarea que no pueda verificar la limpieza debe informar de un fallo de limpieza aunque el comando remoto haya tenido éxito.

Este ejemplo usa `mktemp` para evitar tener que adivinar un nombre de directorio único:

```sh
set -eu
base="$HOME/.ssh/task-control"
mkdir -p "$base"
chmod 700 "$base"
cm_dir=$(mktemp -d "$base/run.XXXXXX")
chmod 700 "$cm_dir"
socket="$cm_dir/%C"

finish() {
    status=$?
    ssh -S "$socket" -O exit build-box >>"$cm_dir/cleanup.log" 2>&1 || \
        printf '%s\n' 'master exit request failed' >>"$cm_dir/cleanup.log"
    ssh -S "$socket" -O check build-box >>"$cm_dir/cleanup.log" 2>&1 || true
    exit "$status"
}
trap finish EXIT HUP INT TERM

ssh -o ControlMaster=auto \
    -o ControlPersist=2m \
    -o ControlPath="$socket" \
    build-box 'deployctl apply release-4821'
```

La opción breve de `ControlPersist` cubre el caso en que un proceso muere antes de ejecutar su trap. No debe interpretarse como permiso para que otra tarea reutilice el master. El directorio aleatorio bloquea esa reutilización porque la segunda tarea no conoce ni hereda la ruta de la primera.

Hay una corrección que debes hacer antes de adoptar exactamente este ejemplo. No registres en `cleanup.log` argumentos sensibles de comandos, valores del entorno ni datos privados copiados. Los registros de auditoría necesitan suficiente detalle para establecer quién hizo qué, pero no deben convertirse en un nuevo almacén de secretos. Redacta los argumentos en el límite del wrapper, donde todavía conoces su significado.

Sallyport dirige las acciones SSH mediante su helper sin estado incluido `sp-ssh`, mientras mantiene las claves SSH en su bóveda cifrada en lugar de exponerlas al agente. Esto elimina un fallo común en la gestión de credenciales, pero los equipos todavía deben definir el límite de la acción y conservar registros que indiquen cuándo terminó la ejecución.

## Las opciones de conveniencia globales destruyen los límites entre tareas

Una sección global `Host *` suele activar la multiplexación para todos los shells humanos, scripts, repositorios y procesos hijos de automatización de una cuenta. Es demasiado amplia cuando la misma cuenta ejecuta tareas con aprobaciones o responsables distintos.

La opción puede llegar mediante archivos `Include`, herramientas de gestión de configuración, los archivos personales del desarrollador o una imagen de compilación. La línea de comandos también puede sobrescribirla. No deduzcas el comportamiento activo a partir de un único archivo de configuración visible. Usa `ssh -G` con el alias de host y el contexto de usuario exactos que utilizará la tarea.

Si un entorno de automatización no puede garantizar una ruta de control privada, desactiva la multiplexación para esa acción:

```sh
ssh -o ControlMaster=no \
    -o ControlPath=none \
    build-box 'maintenancectl status'
```

Esto exige una nueva conexión y autenticación en cada invocación. Es un coste razonable para acciones de gran impacto, accesos excepcionales o trabajos que cruzan límites de confianza y de tareas. La autenticación repetida proporciona un evento de autorización más claro y hace mucho menos ambigua la interpretación del ciclo de vida.

No confundas `ControlMaster=auto` con una garantía de que un proceso solo reutilizará su propia conexión. `auto` significa que el cliente intentará encontrar un master en la ruta configurada y creará uno si no lo encuentra. La ruta configurada decide qué conexión puede encontrar.

Algunos equipos sostienen que un socket compartido está bien porque todos sus trabajos se ejecutan con una sola cuenta de servicio. Ese argumento solo se sostiene si todos los trabajos con esa identidad Unix tienen la misma autoridad para alcanzar todos los destinos mediante esa cuenta y si el equipo acepta que un trabajo pueda heredar el transporte activo de otro. La mayoría de los entornos maduros no quieren realmente eso.

## Un final limpio de la tarea necesita pruebas del cierre del transporte

No declares terminada una tarea SSH cuando regrese el último comando remoto. Declárala terminada cuando la tarea haya cerrado su master y registrado pruebas, o haya informado de que no pudo hacerlo.

El registro final debe indicar a un investigador si un comando posterior tenía alguna vía para reutilizar la conexión. Necesita la identidad de la tarea, la configuración SSH resuelta, la ruta del socket, los detalles del proceso master, los registros de actividad, los resultados explícitos de los comandos de control y la observación posterior a la limpieza. También necesita evidencias remotas para los cambios que continúen de forma independiente de SSH, como el reinicio de un servicio o un proceso separado.

Establece un procedimiento que los operadores puedan ejecutar bajo presión: aísla el socket, inspecciónalo antes de limpiarlo, solicita `exit`, verifica que ningún master responda y conserva el resultado. Ese procedimiento es más útil que un tiempo de espera predeterminado largo porque convierte una suposición sobre el acceso en un hecho comprobable.

Cuando el trabajo sea lo bastante sensible como para que no resulte aceptable dejar un transporte autenticado, no lo optimices con un master compartido. Abre una conexión nueva, realiza la acción, ciérrala y conserva el registro. Los segundos adicionales cuestan menos que explicar por qué una ruta de acceso sobrevivió después de que la tarea debía haber terminado.
