Durante las primeras 28 fases del desarrollo de sh0, cada aplicación se ejecutaba en un solo servidor. Un daemon Docker. Un conjunto de recursos. Un punto único de fallo. Eso estaba bien para desarrolladores individuales y equipos pequeños. No lo estaba para cualquiera que ejecutara cargas de trabajo de producción en múltiples regiones, o cualquiera que quisiera desplegar en servidores que ya poseía.
El soporte multi-servidor -- lo que llamamos BYOS (Bring Your Own Server, Trae Tu Propio Servidor) -- fue la funcionalidad que transformó sh0 de un PaaS de una sola máquina en una plataforma de despliegue multi-nodo. Los usuarios podían registrar sus propios servidores, y sh0 desplegaría aplicaciones en cualquiera de ellos. El desafío técnico era significativo: necesitábamos controlar daemons Docker en máquinas remotas sin instalar ningún software agente, transferir imágenes de contenedores entre servidores, verificar claves de host en la primera conexión y hacer que cada handler de API existente fuera consciente del nodo.
La arquitectura
La restricción de diseño era clara: cero instalación de agentes. Los usuarios deberían poder apuntar sh0 a cualquier servidor con Docker instalado y acceso SSH. No deberían necesitar instalar un daemon, abrir un puerto o configurar una VPN. La solución fueron los túneles SSH.
sh0 accedería por SSH al servidor remoto y reenviaría el socket Docker (/var/run/docker.sock) a través del túnel. Desde ese punto, cada llamada a la API de Docker -- crear contenedores, descargar imágenes, leer logs, recopilar estadísticas -- viajaría a través del túnel SSH al daemon Docker remoto. El servidor remoto no necesitaba exponer el puerto TCP de Docker a internet.
Binario sh0 --túnel SSH--> Servidor remoto
| |
v v
API Docker local API Docker remota
(Socket Unix) (/var/run/docker.sock vía túnel)Túneles SSH con russh
Usamos russh, una implementación SSH pura en Rust, para establecer túneles. A diferencia de ejecutar el binario ssh como comando, russh nos daba control programático sobre parámetros de conexión, verificación de claves y ciclo de vida del túnel:
rustpub struct SshTunnel {
session: Handle<SshHandler>,
local_port: u16,
}
impl SshTunnel {
pub async fn connect(
host: &str,
port: u16,
username: &str,
private_key: &str,
expected_fingerprint: Option<&str>,
) -> Result<(Self, String)> {
let key_pair = decode_secret_key(private_key, None)?;
let handler = SshHandler {
expected_fingerprint: expected_fingerprint.map(String::from),
observed_fingerprint: Arc::new(Mutex::new(None)),
hostname: host.to_string(),
};
let config = Arc::new(russh::client::Config::default());
let mut session = connect(config, (host, port), handler).await?;
session.authenticate_publickey(username, Arc::new(key_pair)).await?;
// Reenviar puerto TCP local al socket Docker remoto
let local_port = find_available_port()?;
session.channel_open_direct_tcpip(
"/var/run/docker.sock", 0,
"127.0.0.1", local_port as u32,
).await?;
let fingerprint = /* extraer huella digital observada */;
Ok((SshTunnel { session, local_port }, fingerprint))
}
}Una vez establecido el túnel, el cliente Docker se conectaba a 127.0.0.1:{local_port} en lugar del socket Unix. Cada petición a la API de Docker -- POST /containers/create, GET /containers/{id}/stats, DELETE /containers/{id} -- viajaba a través del túnel SSH de forma transparente.
Trust On First Use (TOFU)
La verificación de clave de host SSH es una de esas funcionalidades de seguridad que la mayoría de la gente acepta con "sí" sin leer. Pero para una plataforma de despliegue que gestiona servidores remotos, aceptar ciegamente claves de host sería una vulnerabilidad seria. Un ataque de hombre en el medio podría redirigir las llamadas a la API de Docker a un servidor malicioso, interceptando imágenes de contenedores, variables de entorno y secretos.
Implementamos Trust On First Use (TOFU), el mismo modelo que usa SSH:
rustpub struct SshHandler {
expected_fingerprint: Option<String>,
observed_fingerprint: Arc<Mutex<Option<String>>>,
hostname: String,
}
impl russh::client::Handler for SshHandler {
async fn check_server_key(
&mut self,
server_public_key: &PublicKey,
) -> Result<bool, Self::Error> {
let fingerprint = server_public_key.fingerprint();
*self.observed_fingerprint.lock().unwrap() = Some(fingerprint.clone());
match &self.expected_fingerprint {
None => {
// Primera conexión: aceptar y almacenar
tracing::info!(
host = %self.hostname,
fingerprint = %fingerprint,
"TOFU: accepting host key on first connection"
);
Ok(true)
}
Some(expected) if expected == &fingerprint => {
// Host conocido, clave coincidente
Ok(true)
}
Some(expected) => {
// PELIGRO: discrepancia de clave
tracing::error!(
host = %self.hostname,
expected = %expected,
observed = %fingerprint,
"Host key mismatch -- possible MITM attack"
);
Ok(false) // Abortar conexión
}
}
}
}En la primera conexión, el handler aceptaba cualquier clave de host y la huella digital se almacenaba en la base de datos junto al registro del nodo. En conexiones posteriores, el handler comparaba la clave del servidor contra la huella digital almacenada. Una discrepancia abortaba la conexión inmediatamente y registraba una advertencia de seguridad.
La migración de base de datos añadió una columna host_key_fingerprint a la tabla de nodos:
sqlALTER TABLE nodes ADD COLUMN host_key_fingerprint TEXT;La huella digital se almacenaba en cada conexión exitosa, ya fuera el registro inicial, una reconexión de verificación de salud o un despliegue. Esto significaba que la huella digital almacenada siempre era la más recientemente verificada.
El registro de nodos
Gestionar múltiples clientes Docker requería un registro que mapeara IDs de nodo a sus clientes correspondientes. El NodeRegistry usaba DashMap para acceso concurrente desde múltiples handlers de API:
rustpub struct NodeRegistry {
local: Arc<DockerClient>,
remotes: DashMap<String, Arc<DockerClient>>,
}
impl NodeRegistry {
pub fn get(&self, node_id: Option<&str>) -> Arc<DockerClient> {
match node_id {
None => self.local.clone(),
Some(id) => self.remotes
.get(id)
.map(|r| r.value().clone())
.unwrap_or_else(|| self.local.clone()),
}
}
}El DockerClient fue refactorizado de una implementación solo de socket Unix a un despacho enum que soportaba tanto sockets Unix como conexiones TCP:
rustenum DockerInner {
Unix(UnixStream),
Tcp(TcpStream),
}Este fue un refactoreo limpio: los 40+ métodos existentes de la API de Docker (crear contenedor, iniciar, detener, exec, stats, logs, etc.) continuaban llamando a self.send() sin ningún cambio. Solo la capa de transporte era polimórfica.
Transferencia de imágenes entre nodos
Cuando una aplicación se compilaba en el servidor local y se desplegaba en un nodo remoto, la imagen de contenedor existía solo localmente. El comando pull de Docker no podía ayudar -- la imagen era personalizada, no disponible en ningún registro. Necesitábamos transferirla.
La solución fue la transferencia de imágenes basada en disco usando las APIs save y load de Docker:
rustpub async fn transfer_image(
source: &DockerClient,
target: &DockerClient,
image: &str,
) -> Result<()> {
// Guardar imagen del daemon Docker fuente en un archivo tar
let tar_data = source.save_image(image).await?;
// Cargar el archivo tar en el daemon Docker destino
target.load_image(&tar_data).await?;
Ok(())
}Tres pipelines de compilación -- compilaciones git push, compilaciones Dockerfile y compilaciones por subida de archivos -- fueron actualizados para compilar con ctx.local_docker y luego llamar maybe_transfer_image() antes de iniciar el contenedor en el nodo remoto.
Haciendo cada handler consciente del nodo
La parte más laboriosa del soporte multi-servidor no fue construir el túnel o el mecanismo de transferencia. Fue actualizar cada handler de API existente para usar el cliente Docker correcto para el nodo asignado de la aplicación. Una función auxiliar encapsulaba la búsqueda:
rustpub async fn docker_for_app(
db: &DbPool,
nodes: &NodeRegistry,
app_id: &str,
) -> Result<Arc<DockerClient>> {
let app = App::find_by_id(db, app_id).await?;
Ok(nodes.get(app.node_id.as_deref()))
}Esta función fue llamada en 14 handlers: detener/iniciar/reiniciar/eliminar aplicación, WebSocket de terminal, streaming de logs, operaciones de archivos/volúmenes, inspeccionar/reiniciar/detener/iniciar servicio, estadísticas de contenedor, inspección de IP de contenedor de dominio, operaciones de volúmenes y construcción del contexto de despliegue.
Cada handler que antes usaba state.docker.clone() fue cambiado para usar docker_for_app(&state.db, &state.nodes, &app_id).await?. El cambio fue mecánico pero crítico -- un solo handler usando el cliente Docker local para una aplicación remota fallaría silenciosamente, produciendo errores "contenedor no encontrado" que serían desconcertantes de depurar.
La API de nodos
Los nodos se gestionaban a través de una API CRUD restringida a usuarios del plan Business:
GET /api/v1/nodes -- Listar todos los nodos registrados
POST /api/v1/nodes -- Registrar un nuevo nodo
GET /api/v1/nodes/:id -- Obtener detalles del nodo
PATCH /api/v1/nodes/:id -- Actualizar configuración del nodo
DELETE /api/v1/nodes/:id -- Eliminar un nodo
POST /api/v1/nodes/:id/test -- Probar conexión SSHEl endpoint de creación aceptaba el hostname, puerto SSH, nombre de usuario y clave privada. Establecía un túnel SSH, verificaba que el daemon Docker fuera alcanzable, almacenaba la huella digital de la clave de host y registraba el nodo tanto en la base de datos como en el registro en memoria.
Un monitor de salud en segundo plano se ejecutaba cada 30 segundos, verificando el estado del túnel de cada nodo registrado y reconectando si era necesario. El estado del nodo se rastreaba como online, pending, error u offline, con la marca de tiempo del último latido almacenada en la base de datos.
El panel: Gestión de nodos
La página de Configuración ganó una sección "Nodos" con una tabla mostrando el nombre de cada nodo, hostname, insignia de estado (verde para online, amarillo para pendiente, rojo para error, gris para offline), versión de Docker y marca de tiempo de última vista.
Añadir un nodo abría un modal con campos para nombre, hostname, puerto, nombre de usuario y clave SSH. El botón "Probar conexión" establecía un túnel, verificaba el acceso Docker y mostraba el resultado sin guardar el nodo -- permitiendo a los usuarios verificar sus credenciales SSH antes de confirmar.
La huella digital de la clave de host se mostraba como solo lectura en el modal de edición, sirviendo como confirmación visual de que la identidad del nodo no había cambiado.
Lo más importante, el componente NodeSelector fue integrado en los siete formularios de despliegue (Git, Dockerfile, Docker Image, Framework, Upload, Service, Compose). Cuando existían nodos remotos, aparecía un desplegable permitiendo a los usuarios elegir dónde desplegar. Cuando no había nodos remotos registrados, el selector se ocultaba completamente -- la experiencia de servidor único permanecía sin cambios.
Los números
Multi-servidor BYOS tocó 29 archivos en la primera sesión y 27 más en la sesión de completación. Añadió una migración de base de datos, un modelo Node con CRUD completo, un módulo de túnel SSH, un registro de nodos, un módulo de transferencia de imágenes, una API de nodos con restricción por plan, un helper docker_for_app usado en 14 handlers, un monitor de salud en segundo plano, verificación de clave de host TOFU, integración del pipeline de despliegue con transferencia de imágenes, una página de gestión de nodos en el panel y un selector de nodos en todos los formularios de despliegue -- con i18n en cinco idiomas.
Los 53 tests de API pasaron. Cero funcionalidad existente se rompió. La experiencia de servidor único no fue afectada en absoluto a menos que el usuario agregara explícitamente un nodo remoto.
Ese era el objetivo: complejidad aditiva. Multi-servidor era una capacidad que existía cuando la necesitabas y era invisible cuando no.
Siguiente en la serie: Cron Jobs y entornos de vista previa: dos funcionalidades, cero tiempo de inactividad -- cómo construimos la programación de cron con imposición de timeout y entornos de vista previa basados en PR, desarrollados en paralelo usando aislamiento con git worktree.