Back to sh0
sh0

Terminal web y explorador de archivos en un PaaS autoalojado

Cómo construimos un terminal basado en navegador (xterm.js + WebSocket + Docker exec) y un explorador de archivos estilo Docker Desktop con mkdir, edición y eliminación -- funciones que la mayoría de PaaS autoalojados no ofrecen.

Thales & Claude | March 30, 2026 11 min sh0
EN/ FR/ ES
terminalxtermwebsocketfile-explorerdockersveltepaas

Hay un momento en cada despliegue en que algo sale mal y necesitas mirar dentro del contenedor. Verificar un archivo de log. Comprobar una ruta de configuración. Ver si un volumen realmente se montó. En una configuración tradicional, accedes por SSH al servidor, ejecutas docker exec -it nombre_contenedor /bin/sh y exploras. En un PaaS, esa salida de emergencia debería estar a un clic de distancia.

Construimos dos funcionalidades que la mayoría de plataformas PaaS autoalojadas no ofrecen: un terminal basado en navegador que te conecta a cualquier contenedor en ejecución, y un explorador de archivos estilo Docker Desktop que te permite navegar, crear, editar y eliminar archivos sin tocar nunca la línea de comandos.

El panorama competitivo

Antes de construir, evaluamos todos los PaaS autoalojados principales:

  • Coolify: Sin terminal web. Sin navegador de archivos.
  • Easypanel: Sin terminal web. Sin navegador de archivos.
  • Dokku: Solo acceso SSH. Sin herramientas en navegador.
  • CapRover: Tiene terminal web (básico). Sin navegador de archivos.
  • Railway: Tiene terminal web (solo nube). Sin navegador de archivos.

La ausencia de navegador de archivos es particularmente llamativa. La documentación de cada competidor dice alguna variante de "si necesitas acceder a archivos, despliega FileBrowser como servicio separado." Eso no es una solución -- es un parche. sh0 tiene ambas funcionalidades integradas en el panel.

Terminal web: Arquitectura

El terminal web tiene tres capas:

  1. Frontend: xterm.js v6 renderiza un terminal en el navegador.
  2. Transporte: Una conexión WebSocket transporta pulsaciones de teclado y salida de forma bidireccional.
  3. Backend: Rust ejecuta docker exec -i <contenedor> <shell> como proceso hijo y canaliza entre el WebSocket y el stdin/stdout del proceso.

El handler del backend

El handler del terminal en crates/sh0-api/src/handlers/terminal.rs acepta una solicitud de upgrade WebSocket:

GET /api/v1/apps/:id/terminal?token=<jwt>&shell=/bin/sh

Al conectarse:

  1. Valida el token JWT.
  2. Busca el ID de contenedor de la aplicación.
  3. Ejecuta docker exec -i <container_id> <shell> como proceso hijo usando tokio::process::Command.
  4. Entra en un bucle de bombeo bidireccional: los mensajes WebSocket se convierten en escrituras a stdin, las lecturas de stdout se convierten en mensajes WebSocket.
  5. Al desconectar, mata el proceso hijo y limpia recursos.
rustlet mut child = Command::new("docker")
    .args(["exec", "-i", &container_id, &shell])
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .stderr(Stdio::piped())
    .spawn()?;

let mut child_stdin = child.stdin.take().unwrap();
let child_stdout = child.stdout.take().unwrap();

// Bombear stdout -> WebSocket
let ws_tx_clone = ws_tx.clone();
tokio::spawn(async move {
    let mut reader = BufReader::new(child_stdout);
    let mut buf = [0u8; 4096];
    loop {
        match reader.read(&mut buf).await {
            Ok(0) => break,
            Ok(n) => {
                let _ = ws_tx_clone.send(Message::Binary(buf[..n].to_vec())).await;
            }
            Err(_) => break,
        }
    }
});

// Bombear WebSocket -> stdin
while let Some(Ok(msg)) = ws_rx.recv().await {
    if let Message::Binary(data) = msg {
        let _ = child_stdin.write_all(&data).await;
    }
}

// Limpieza
let _ = child.kill().await;

El shell es configurable: /bin/sh (por defecto, funciona en todos lados), /bin/bash (si está disponible) o /bin/ash (Alpine). El frontend ofrece un desplegable para cambiar de shell, y la elección persiste durante la sesión.

El componente frontend

AppTerminal.svelte envuelve xterm.js v6 con el addon fit para redimensionamiento automático:

svelte<script lang="ts">
  import { Terminal } from '@xterm/xterm';
  import { FitAddon } from '@xterm/addon-fit';
  import '@xterm/xterm/css/xterm.css';

  let { appId } = $props<{ appId: string }>();
  let terminal: Terminal;
  let ws: WebSocket;
  let connected = $state(false);
  let shell = $state('/bin/sh');
  let terminalEl: HTMLDivElement;

  function connect() {
    terminal = new Terminal({
      theme: {
        background: '#1a1b26',   // Tokyo Night
        foreground: '#a9b1d6',
        cursor: '#c0caf5',
        black: '#32344a',
        red: '#f7768e',
        green: '#9ece6a',
        yellow: '#e0af68',
        blue: '#7aa2f7',
        magenta: '#ad8ee6',
        cyan: '#449dab',
        white: '#787c99',
      },
      fontFamily: 'JetBrains Mono, monospace',
      fontSize: 14,
      scrollback: 5000,
      cursorBlink: true,
    });

    const fitAddon = new FitAddon();
    terminal.loadAddon(fitAddon);
    terminal.open(terminalEl);
    fitAddon.fit();

    const token = getAuthToken();
    ws = new WebSocket(
      `${wsBase}/api/v1/apps/${appId}/terminal?token=${token}&shell=${shell}`
    );
    ws.binaryType = 'arraybuffer';

    ws.onopen = () => { connected = true; terminal.focus(); };
    ws.onmessage = (e) => terminal.write(new Uint8Array(e.data));
    ws.onclose = () => { connected = false; };

    terminal.onData((data) => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(new TextEncoder().encode(data));
      }
    });

    // Manejo de redimensionamiento
    new ResizeObserver(() => fitAddon.fit()).observe(terminalEl);
  }
</script>

<div class="flex items-center gap-3 mb-3">
  <select bind:value={shell} class="text-sm ...">
    <option value="/bin/sh">sh</option>
    <option value="/bin/bash">bash</option>
    <option value="/bin/ash">ash</option>
  </select>
  <button onclick={connected ? disconnect : connect} class="...">
    {connected ? t('terminal.disconnect') : t('terminal.connect')}
  </button>
  <span class="flex items-center gap-1 text-xs">
    <span class="w-2 h-2 rounded-full"
          class:bg-green-500={connected}
          class:bg-red-500={!connected} />
    {connected ? t('terminal.connected') : t('terminal.disconnected')}
  </span>
</div>

<div bind:this={terminalEl} class="h-96 rounded-lg overflow-hidden" />

El tema de color Tokyo Night fue una elección deliberada. Es un tema oscuro popular en la comunidad de desarrolladores y encaja con la paleta del modo oscuro de sh0. El búfer de desplazamiento de 5.000 líneas permite recorrer la salida sin perder historial, manteniendo la memoria acotada.

El ResizeObserver en el elemento del terminal asegura que cuando la ventana del navegador se redimensiona o el usuario ajusta un panel dividido, el terminal redistribuya sus columnas y filas. Sin esto, las líneas se cortarían incorrectamente o las columnas se renderizarían fuera del área visible.

Explorador de archivos: Arquitectura

El explorador de archivos es más complejo que el terminal porque implica múltiples operaciones: navegación, creación, edición y eliminación. Cada operación se ejecuta mediante comandos docker exec en el contenedor objetivo.

Backend: Operaciones de archivos

El backend en crates/sh0-api/src/handlers/storage.rs expone seis endpoints para operaciones de archivos:

GET  /apps/:id/browse?path=/&container=<id>    # Listar directorio
GET  /apps/:id/files/read?path=/etc/nginx.conf # Leer contenido de archivo
POST /apps/:id/files/write                     # Escribir contenido de archivo
POST /apps/:id/files/mkdir                     # Crear directorio
POST /apps/:id/files/new                       # Crear archivo vacío
DELETE /apps/:id/files?path=/tmp/debug.log     # Eliminar archivo o carpeta

Cada operación es un comando docker exec. Navegar ejecuta ls -la. Leer ejecuta cat. Escribir canaliza contenido a tee. Crear directorios ejecuta mkdir -p. La eliminación ejecuta rm -rf con salvaguardas.

Compatibilidad con BusyBox

El primer bug que encontramos fue que ls --time-style=long-iso no funciona en contenedores basados en Alpine. Alpine usa BusyBox, que tiene una implementación diferente de ls. Nuestro parser esperaba la salida de GNU ls y fallaba con el formato de BusyBox.

La solución fue un respaldo de tres niveles:

rustasync fn list_directory(container_id: &str, path: &str) -> Result<Vec<FileEntry>> {
    // Intentar GNU ls primero
    let output = docker_exec(container_id, &["ls", "-la", "--time-style=long-iso", path]).await;
    if output.is_ok() {
        return parse_gnu_ls(output.unwrap());
    }

    // Respaldo a BusyBox --full-time
    let output = docker_exec(container_id, &["ls", "-la", "--full-time", path]).await;
    if output.is_ok() {
        return parse_busybox_ls(output.unwrap());
    }

    // Último recurso: ls -la plano
    let output = docker_exec(container_id, &["ls", "-la", path]).await?;
    parse_plain_ls(output)
}

El parser también maneja casos especiales: destinos de enlaces simbólicos (eliminados del nombre de visualización), campos de zona horaria en la salida de BusyBox y la línea total al inicio de la salida de ls.

Prevención de recorrido de rutas

Cada operación de archivos valida la ruta solicitada:

rustfn validate_path(path: &str) -> Result<()> {
    if path.contains("..") {
        return Err(ApiError::bad_request("Path traversal not allowed"));
    }
    Ok(())
}

Adicionalmente, el endpoint de eliminación bloquea la eliminación de directorios protegidos del sistema:

rustconst PROTECTED_PATHS: &[&str] = &[
    "/bin", "/etc", "/usr", "/lib", "/sbin",
    "/proc", "/sys", "/dev",
];

fn validate_delete_path(path: &str) -> Result<()> {
    validate_path(path)?;
    let normalized = path.trim_end_matches('/');
    if PROTECTED_PATHS.contains(&normalized) {
        return Err(ApiError::bad_request("Cannot delete protected system directory"));
    }
    Ok(())
}

Esta es defensa en profundidad. La interfaz del panel también evita navegar fuera del sistema de archivos del contenedor, pero el backend lo aplica de forma independiente. Una llamada API maliciosa con path=/../../../etc/passwd es rechazada antes de que docker exec se ejecute.

Resolución de ID de contenedor

Un bug sutil surgió cuando los usuarios intentaban navegar archivos en un contenedor de base de datos. El desplegable de selección de contenedores mostraba las bases de datos por su UUID de servicio (una cadena de 36 caracteres como a1b2c3d4-e5f6-...), pero la API de navegación esperaba un ID de contenedor Docker (una cadena hexadecimal de 12 o 64 caracteres).

La solución fue resolve_container_id():

rustasync fn resolve_container_id(app_id: &str, container_ref: &str, db: &DbPool) -> Result<String> {
    // Heurística: los UUID son 36 caracteres con guiones, los ID de Docker son 12/64 caracteres hex
    if container_ref.len() == 36 && container_ref.contains('-') {
        // Buscar el ID de contenedor Docker del servicio en la base de datos
        let service = db.get_service(container_ref).await?;
        // Prevención de IDOR: verificar que el servicio pertenece a esta aplicación
        if service.app_id != app_id {
            return Err(ApiError::forbidden("Service does not belong to this app"));
        }
        Ok(service.container_id)
    } else {
        // Asumir que ya es un ID de contenedor Docker
        Ok(container_ref.to_string())
    }
}

La verificación de IDOR (Referencia Directa a Objetos Insegura) es importante. Sin ella, un usuario podría pasar un UUID de servicio de otra aplicación y navegar los archivos de ese contenedor. La verificación asegura que el servicio referenciado realmente pertenece a la aplicación especificada en la ruta URL.

Frontend: El diseño de dos paneles

AppFiles.svelte implementa un diseño estilo Docker Desktop:

Panel izquierdo (280px): Un árbol de directorios recursivo (FileTree.svelte) con un desplegable de selección de contenedor en la parte superior. Las carpetas se expanden al hacer clic, cargando sus hijos de forma perezosa desde la API. Un ícono de carpeta/archivo distingue las entradas de un vistazo.

Panel derecho (ancho restante): Ya sea una tabla de listado de directorio (cuando se selecciona una carpeta), un visor/editor de archivo (cuando se selecciona un archivo) o un estado vacío.

La barra de herramientas abarca la parte superior del panel derecho: navegación por migas de pan, botones de Nuevo archivo, Nueva carpeta, Eliminar y Actualizar. El flujo de creación es en línea -- haz clic en Nuevo archivo, escribe un nombre directamente en el listado del directorio, presiona Enter. Sin modal, sin diálogo.

svelte<!-- FileTree.svelte (simplificado) -->
<script lang="ts">
  let { appId, containerId } = $props();
  let root = $state<TreeNode>({ path: '/', children: [], loaded: false });

  async function loadChildren(node: TreeNode) {
    const entries = await filesApi.browse(appId, node.path, containerId);
    node.children = entries
      .filter(e => e.isDirectory)
      .map(e => ({ path: `${node.path}${e.name}/`, children: [], loaded: false }));
    node.loaded = true;
  }

  // La expansión usa $effect con untrack para prevenir bucles infinitos
  $effect(() => {
    const key = `${appId}-${containerId}`;
    untrack(() => {
      root = { path: '/', children: [], loaded: false };
      loadChildren(root);
    });
  });
</script>

El bucle de efecto infinito

El bug más insidioso del explorador de archivos fue un problema de reactividad de Svelte 5. FileTree.svelte tenía un $effect que tanto leía como escribía root (una variable $state). Cuando loadChildren() mutaba root estableciendo node.children y node.loaded, Svelte 5 detectaba un cambio de estado y re-disparaba el efecto, que cargaba los hijos de nuevo, que mutaba el estado de nuevo -- un bucle infinito de llamadas API.

La corrección requirió dos cambios:

  1. untrack() alrededor de las mutaciones. Todas las mutaciones de estado dentro del efecto están envueltas en untrack(), indicándole a Svelte "estas escrituras no deben disparar una re-ejecución de este efecto."
  2. Clave de sesión $derived. En lugar de que el efecto dependa de root, depende de una clave derivada computada desde appId y containerId. Esto significa que el efecto solo se re-ejecuta cuando el contenedor cambia, no cuando el árbol se expande.
  3. Guardia de error. Establecer node.loaded = true en caso de error evita que el efecto reintente una carga fallida indefinidamente.

Este es un patrón que vale la pena recordar para cualquiera que construya estructuras de árbol con Svelte 5: los efectos que mutan sus propias dependencias necesitan límites explícitos de untrack().

La experiencia de edición

Cuando se selecciona un archivo, el panel derecho muestra su contenido. Para archivos de texto, un botón "Editar" cambia a un textarea con botones de Guardar y Cancelar. Para archivos binarios, el componente detecta caracteres no imprimibles y muestra un mensaje "Archivo binario -- no se puede editar" en su lugar.

svelte{#if editMode}
  <textarea bind:value={editContent}
            class="w-full h-96 font-mono text-sm bg-[var(--bg-secondary)]
                   text-[var(--text-primary)] p-4 rounded" />
  <div class="flex gap-2 mt-3">
    <Button onclick={saveFile}>{t('files.save')}</Button>
    <Button variant="ghost" onclick={() => editMode = false}>
      {t('files.cancel')}
    </Button>
  </div>
{:else}
  <pre class="p-4 text-sm font-mono overflow-x-auto">{fileContent}</pre>
  <Button onclick={() => { editContent = fileContent; editMode = true; }}>
    {t('files.edit')}
  </Button>
{/if}

La barra de metadatos del archivo debajo de la barra de herramientas muestra permisos (ej. -rw-r--r--), tamaño (legible para humanos) y fecha de última modificación. Esta es información que los desarrolladores miran instintivamente -- "¿es escribible este archivo? ¿Qué antigüedad tiene?"

Por qué esto importa

El terminal web y el explorador de archivos no son funcionalidades llamativas. Son funcionalidades pragmáticas. Eliminan la razón más común por la que un desarrollador accede por SSH a un servidor después de desplegar una aplicación: "Necesito mirar dentro del contenedor."

Para el público objetivo de sh0 -- desarrolladores que gestionan sus propios servidores pero no quieren pasar el día en la CLI de Docker -- estas funcionalidades cierran la brecha entre "desplegué mi aplicación" y "puedo gestionar completamente mi aplicación." El terminal es la salida de emergencia para todo lo que el panel no cubre. El explorador de archivos es la herramienta visual para el 80% de las operaciones de archivos que no necesitan un shell.

Juntos, hacen que sh0 se sienta menos como una herramienta de despliegue y más como un entorno de desarrollo.


Siguiente en la serie: Logs en tiempo real: streaming WebSocket desde contenedores Docker -- cómo construimos el streaming de logs con WebSockets autenticados por JWT, reconexión automática y un visor estilo terminal.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles