Back to sh0
sh0

Los bugs que casi nos destruyen

Fallos en git pull, CSRF bloqueando subidas, procesos obsoletos de Caddy, incompatibilidades FTP con IPv6 y alias de red Docker -- los bugs que casi descarrilaron sh0 y como los solucionamos.

Thales & Claude | March 30, 2026 12 min sh0
EN/ FR/ ES
debuggingbugswar-storiesrustdockercaddyproduction

Construir un PaaS en 14 dias significa que lanzas rapido y rompes cosas a un ritmo que haria llorar a un ingeniero de QA. Rompimos muchas cosas. Algunos bugs eran triviales -- un import faltante, un offset de paginacion desviado por uno. Pero un punado de bugs casi descarrilaron todo el proyecto. Eran el tipo de fallos que no producen ningun mensaje de error, o peor aun, producen un mensaje de error que miente. Consumieron horas cuando no teniamos ninguna de sobra.

Esta es una coleccion de historias de guerra del rastreador de bugs de sh0: los sintomas que vimos, las madrigueras en las que nos metimos y las correcciones que nos salvaron. Si estas construyendo software de infraestructura en Rust, o lidiando con Docker y Caddy en produccion, algunas de estas te resultaran dolorosamente familiares.

BUG-009: Git Pull, objetos obsoletos y el respaldo de clon limpio

Sintoma: Redesplegar una aplicacion que ya habia sido desplegada anteriormente fallaba silenciosamente durante la fase de git pull. El estado del despliegue transitaba a "cloning" y luego se quedaba colgado, eventualmente agotando el tiempo. Los primeros despliegues siempre funcionaban bien.

Investigacion: El pipeline de despliegue cacheaba repositorios git en un directorio local indexado por nombre de aplicacion. En el primer despliegue, ejecutaba git clone --depth 1. En despliegues subsecuentes, ejecutaba git fetch seguido de git reset --hard origin/{branch}. El fallo ocurria en el paso de fetch -- libgit2 devolvia un error opaco de "object not found", que nuestro manejo de errores registraba diligentemente y luego... no hacia nada util con ello.

La causa raiz era el historial de clon superficial. Cuando un repositorio se clona con --depth 1, el repositorio local contiene exactamente un commit. Si el remoto hace force-push, rebase, o el historial superficial diverge de cualquier manera, git fetch no puede reconciliar el grafo de objetos. libgit2 no maneja esto con gracia -- lanza un error sobre objetos faltantes en lugar de decirte "tu clon superficial esta obsoleto".

Solucion: Anadimos un mecanismo de respaldo en sh0-git/src/repo.rs. Si un pull o fetch falla con un error de objeto, el pipeline elimina todo el directorio del repositorio cacheado y realiza un clon limpio:

rustpub async fn clone_or_pull(url: &str, path: &Path, branch: &str, creds: Option<&GitCredentials>) -> Result<String> {
    if path.join(".git").exists() {
        match pull(path, branch, creds).await {
            Ok(hash) => return Ok(hash),
            Err(e) => {
                tracing::warn!("Pull failed ({e}), removing stale repo for fresh clone");
                tokio::fs::remove_dir_all(path).await?;
            }
        }
    }
    clone(url, path, branch, creds).await
}

El reintento anade unos segundos al despliegue, pero elimina toda una clase de fallos del tipo "funciona la primera vez, falla en el redespliegue". Los clones superficiales obsoletos ya no son un problema.

BUG-010: Caddy con permiso denegado en macOS

Sintoma: Ejecutar sh0 serve en macOS fallaba inmediatamente con "permission denied" cuando Caddy intentaba escribir sus archivos de datos. El mismo binario funcionaba perfectamente en Linux.

Investigacion: Caddy almacena sus certificados TLS, grapas OCSP y estado de configuracion en un directorio de datos. Nuestro valor por defecto era /var/lib/caddy, que sigue el estandar de jerarquia del sistema de archivos en Linux. En macOS, /var/lib/ requiere permisos de root, y ejecutar un PaaS de desarrollo como root no es algo que quisieramos fomentar.

Causa raiz: Habiamos codificado una ruta especifica de Linux como valor por defecto para ProxyConfig::caddy_data.

Solucion: Cambiamos el valor por defecto a ./sh0-data/caddy -- una ruta relativa dentro del directorio de trabajo de sh0. Esto funciona en todas partes sin permisos elevados:

rustimpl Default for ProxyConfig {
    fn default() -> Self {
        Self {
            caddy_bin: "caddy".into(),
            caddy_data: PathBuf::from("./sh0-data/caddy"),
            caddy_admin_url: "http://localhost:2019".into(),
            acme_email: None,
        }
    }
}

Mas adelante implementamos un sistema completo de rutas de datos consciente de la plataforma (FHS en Linux root, XDG en usuario Linux, macOS Application Support, o anulacion con --data-dir), pero esta solucion rapida desbloqueo el desarrollo en macOS durante tres dias criticos.

BUG-012: Middleware CSRF contra POSTs sin cuerpo

Sintoma: Despues de habilitar la proteccion CSRF, varias acciones del panel dejaron de funcionar -- rollback, restart y stop devolvian 415 Unsupported Media Type. La consola del navegador mostraba que no se enviaba cuerpo en la solicitud, lo cual era correcto: estos son endpoints POST que no reciben entrada.

Investigacion: Nuestro middleware CSRF aplicaba Content-Type: application/json en todas las solicitudes POST, PATCH y PUT. Esta es una defensa estandar contra envios de formularios cross-site: los navegadores no pueden enviar application/json desde un formulario HTML plano, asi que requerirlo bloquea ataques CSRF sin tokens.

El problema: cuando una solicitud POST no tiene cuerpo, el navegador no establece una cabecera Content-Type. No hay nada cuyo tipo describir. El middleware CSRF vio un Content-Type faltante, decidio que no era application/json y rechazo la solicitud.

Solucion: Omitir la verificacion de Content-Type cuando el cuerpo de la solicitud esta vacio:

rustasync fn csrf_middleware(req: Request, next: Next) -> Response {
    if matches!(*req.method(), Method::POST | Method::PATCH | Method::PUT) {
        let content_length = req.headers()
            .get(header::CONTENT_LENGTH)
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.parse::<u64>().ok())
            .unwrap_or(0);

        if content_length > 0 {
            // Enforce Content-Type: application/json for requests with a body
            let content_type = req.headers().get(header::CONTENT_TYPE);
            if !content_type.map_or(false, |ct| ct.as_bytes().starts_with(b"application/json")) {
                return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response();
            }
        }
    }
    next.run(req).await
}

Una solicitud sin cuerpo no puede llevar un payload CSRF, asi que omitir la verificacion es seguro. Logica simple, pero nos tomo una sesion de depuracion vergonzosamente larga llegar a esa conclusion porque el error 415 nos hizo mirar la capa equivocada del stack.

BUG-014: La unicidad del nombre de app era global, no por proyecto

Sintoma: Crear una aplicacion llamada "api" en el Proyecto B fallaba con un error de restriccion de unicidad, porque el Proyecto A ya tenia una aplicacion llamada "api". Los usuarios esperaban que los nombres de aplicacion fueran unicos dentro de su proyecto, no en toda la plataforma.

Investigacion: La tabla apps tenia una restriccion UNIQUE simple en la columna name. Esto era correcto durante las fases tempranas cuando sh0 no tenia concepto de proyecto, pero la Fase 19 introdujo soporte multi-proyecto sin actualizar la restriccion.

Solucion: La migracion 016 recreo la tabla de aplicaciones con un indice unico compuesto:

sqlCREATE UNIQUE INDEX idx_apps_name_project ON apps(name, project_id);

Pero la solucion no termino ahi. Los nombres de contenedores tambien necesitaban ser delimitados por proyecto. Dos aplicaciones llamadas "api" en diferentes proyectos producirian el mismo nombre de contenedor Docker sh0-api, causando colisiones. Introdujimos un helper container_prefix():

rustfn container_prefix(app_name: &str, project_id: Option<&str>) -> String {
    match project_id {
        Some(pid) => format!("sh0-{}-{}", &pid[..8], app_name),
        None => format!("sh0-{}", app_name),
    }
}

Los primeros 8 caracteres del UUID del proyecto sirven como prefijo de espacio de nombres. Este cambio se propago a traves de las seis variantes de pipeline (git, imagen Docker, Dockerfile, subida, plantilla, compose), el sistema de escalado y los manejadores de stop/start/restart. Cada lugar que construia un nombre de contenedor necesitaba actualizacion -- un recordatorio de que el nombrado es uno de los dos problemas dificiles en ciencias de la computacion.

BUG-017: Subida de ZIP bloqueada por CSRF y limites de cuerpo

Sintoma: Subir un archivo ZIP a traves del panel producia un fallo silencioso. La subida parecia comenzar, luego la API devolvia un error. Ningun mensaje de error util en el navegador. Los registros del servidor mostraban un codigo de estado 415.

Investigacion: Este bug tenia dos causas raiz apiladas una sobre otra, por lo que fue tan dificil de diagnosticar.

Causa raiz 1: El middleware CSRF requeria Content-Type: application/json para todas las solicitudes POST. Las subidas de ZIP usan Content-Type: multipart/form-data. Cada subida era rechazada antes de llegar siquiera al manejador.

Causa raiz 2: Incluso despues de eximir la ruta de subida del CSRF, los archivos mayores de 10 MB eran rechazados. El limite global de tamano de cuerpo era 10 MB, aplicado como una capa de middleware de Axum. El manejador de subida declaraba un limite de 100 MB en su propio extractor, pero el limite global se aplicaba primero.

Solucion: Dos cambios:

rust// 1. Exempt the upload route from CSRF (auth via Bearer token is sufficient)
let csrf_exempt = vec!["/api/v1/webhooks", "/api/v1/apps/upload"];

// 2. Per-route body limit override
Router::new()
    .route("/api/v1/apps/upload", post(upload_app))
    .layer(DefaultBodyLimit::max(500 * 1024 * 1024)) // 500 MB

Esto llevo a una auditoria mas amplia de todos los limites de tasa y topes de tamano. sh0 es una plataforma auto-alojada -- los usuarios son duenos de su servidor. Una limitacion agresiva hacia que la plataforma se sintiera rota durante el uso normal. Relajamos las lecturas de API de 300/min a 1000/min, las escrituras de 120/min a 500/min, y el limite global de cuerpo de 10 MB a 50 MB. Un PaaS auto-alojado no deberia luchar contra su propio administrador.

La pesadilla de FTP IPv6/EPSV

Sintoma: Las conexiones FTP y FTPS a Hetzner Storage Box fallaban desde el sistema de respaldos de sh0. El mismo servidor funcionaba perfectamente cuando se conectaba a traves de Transmit, un cliente FTP de macOS.

Investigacion: Este era un problema de tres capas que llevo una sesion entera desenredar.

Capa 1: El DNS del Hetzner Storage Box resolvia a una direccion IPv6 (2a01:...). Nuestra biblioteca FTP (suppaftp, a traves de OpenDAL) usaba el modo PASV, que es un comando solo para IPv4. Cuando se conectaba sobre IPv6, PASV devuelve una direccion IPv4 en la que el servidor no puede escuchar. El servidor respondio con: 421 Could not listen for passive connection: invalid passive IP "[2a01". Esa direccion truncada en el mensaje de error fue la pista -- estaba intentando parsear una direccion IPv6 como una respuesta de modo pasivo IPv4.

Capa 2: La solucion para FTP sobre IPv6 es EPSV (Modo Pasivo Extendido), que es agnostico al protocolo. Pero el backend FTP de OpenDAL no exponia set_mode(Mode::ExtendedPassive). No habia opcion de configuracion, ni escape posible.

Capa 3: Consideramos resolver el hostname a IPv4 manualmente y conectar directamente. Pero para FTPS (FTP sobre TLS), el hostname SNI de TLS debe coincidir con el certificado. Si nos conectabamos a una direccion IPv4 pero enviabamos el hostname para SNI, podria funcionar -- o no, dependiendo de la configuracion del certificado del servidor.

Solucion: Evitamos OpenDAL completamente para FTP/FTPS y escribimos un cliente suppaftp directo:

rustpub struct FtpClient {
    host: String,
    port: u16,
    username: String,
    password: String,
    use_tls: bool,
}

impl FtpClient {
    pub async fn connect(&self) -> Result<AsyncNativeTlsFtpStream> {
        let mut ftp = AsyncNativeTlsFtpStream::connect(
            format!("{}:{}", self.host, self.port)
        ).await?;

        if self.use_tls {
            ftp = ftp.into_secure(
                AsyncNativeTlsConnector::from(tls_connector),
                &self.host  // Correct SNI hostname
            ).await?;
        }

        ftp.login(&self.username, &self.password).await?;
        ftp.set_mode(Mode::ExtendedPassive);  // EPSV -- works with IPv4 and IPv6
        Ok(ftp)
    }
}

El StorageBackend fue refactorizado con un enum Engine -- OpenDAL para S3/R2/SFTP/proveedores cloud, FtpClient para FTP/FTPS. Tambien corregimos un bug de la interfaz donde el campo de puerto por defecto mostraba 22 (SFTP) incluso cuando el usuario seleccionaba FTP como tipo de proveedor.

Alias de red Docker: el fallo silencioso

Sintoma: Los despliegues de plantillas con multiples servicios (como WordPress + MySQL) iniciaban ambos contenedores, pero el contenedor de la aplicacion no podia alcanzar el contenedor de la base de datos por hostname. WordPress mostraba "Error establishing a database connection."

Investigacion: Los contenedores Docker en la misma red pueden alcanzarse mutuamente por nombre de contenedor, pero solo si los alias de red estan correctamente configurados. Cuando creabamos contenedores con docker create, los adjuntabamos a la red sh0-net. Pero no estableclamos el alias de red para que coincidiera con el nombre del servicio de la plantilla.

La plantilla definia depends_on: [mysql] y el entorno de WordPress referenciaba mysql:3306 como el host de la base de datos. El contenedor se llamaba sh0-wordpress-mysql, pero el alias de red tambien era sh0-wordpress-mysql -- no mysql. La resolucion de hostname fallaba porque nada en la red estaba escuchando simplemente como "mysql".

Solucion: Al conectar un contenedor a la red, establecer el alias con el nombre del servicio de la plantilla:

rustdocker.network_connect(
    "sh0-net",
    &container_id,
    Some(vec![service.name.clone()])  // Alias matches template service name
).await?;

De esta forma, un contenedor creado a partir de un servicio de plantilla llamado "mysql" es alcanzable como "mysql" en la red Docker, sin importar cual sea el nombre real del contenedor. Una correccion de dos lineas para un bug que hacia que cada despliegue de plantilla multi-servicio fallara.

La leccion: los bugs de infraestructura son diferentes

Los bugs de aplicacion hacen que tu programa falle. Los bugs de infraestructura rompen los programas que se supone que deberias estar ejecutando para otras personas. El modo de fallo es casi siempre el silencio -- el contenedor arranca pero no puede alcanzar la base de datos, el despliegue tiene exito pero la ruta no funciona, la subida se completa pero el archivo es rechazado por una capa de middleware que el usuario no puede ver.

Cada uno de estos bugs reforzo la misma leccion: registra todo, no confies en nada, y siempre ten un respaldo. El respaldo de clon limpio para BUG-009. La ruta de datos consciente de la plataforma para BUG-010. La exencion CSRF para subidas. El cliente FTP directo evitando la capa de abstraccion. El alias de red explicito en lugar de depender del comportamiento por defecto de Docker.

Cuando construyes un PaaS, estas construyendo una plataforma que ejecuta el software de otras personas. Tus bugs se convierten en sus bugs, excepto que ellos no pueden ver el codigo fuente. Por eso rastreamos cada fallo, lo documentamos y lo corregimos el mismo dia.


Esta es la Parte 31 de la serie "Como construimos sh0.dev". A continuacion: como usamos utoipa para auto-generar una especificacion OpenAPI 3.1 a partir de anotaciones de manejadores Rust, y luego usamos esa especificacion para alimentar documentacion de API, un playground interactivo y definiciones de herramientas MCP -- tres salidas desde una unica fuente de verdad.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles