Hull Runtime

Hull es un runtime de contenedores Linux daemonless. Un solo binario Zig static-musl de ~3 MB orquesta namespaces, cgroups v2, seccomp-bpf, Landlock y pivot_root — sin daemon, sin containerd, sin shim. Cada hull run forkea el workload, escribe estado a disco y sale. Comandos como ps, stop e inspect leen ese directorio de estado.

Referencia CLI

hull run [--rootless] <manifest>Iniciar contenedor desde manifest JSON. --rootless fuerza NEWUSER incluso como root
hull psListar contenedores: nombre, PID, uptime, argv[0]
hull stop <name>Parar gracioso (SIGTERM)
hull kill <name>Parar inmediato (SIGKILL)
hull logs <name>Imprimir stdout/stderr capturado
hull inspect <name>Mostrar cgroup, namespaces, mount points
hull versionImprimir version
hull helpMostrar uso

Codigos de salida: 0 exito, 1 error de uso, 2 error de runtime, 3 error de manifest, 127 execve fallo.

Especificacion del Manifest

Un manifest hull es un archivo JSON que describe el contenedor. Tres campos son requeridos; todo lo demas tiene defaults razonables.

Campos Requeridos

namestringNombre del contenedor (1-64 chars, alfanumerico + guion + underscore)
rootfsstringRuta a directorio rootfs o archivo .tar.gz. Los archivos se extraen y cachean en /var/lib/hull/rootfs/<name>/
argvstring[]Comando a ejecutar dentro del contenedor

Campos Opcionales

envstring[][]Variables de entorno (e.g. ["PORT=4500"])
profilestring"default"Perfil seccomp: default, beam, dotnet o node
networkstring"none"Modo de red: none (loopback), host (compartida) o bridge (veth)
bridge.namestring"hull0"Nombre del bridge (solo si network=bridge)
bridge.subnetstring"10.88.0.0/24"CIDR del subnet (solo /24 soportado)
bridge.ipstring"" (auto)IP del contenedor. Vacio = auto-asignar via lease dir
bridge.mtunumber0MTU del par veth. 0 = default del kernel (1500)
hostnamestringnameHostname del contenedor (namespace UTS)
cwdstring"/"Directorio de trabajo dentro del contenedor antes del execve
limits.memory_mbnumber0Limite de memoria en MB. 0 = sin limite
limits.cpunumber0Fraccion de CPU (1.0 = un core). 0 = sin limite
limits.pidsnumber0Max procesos. 0 = sin limite
mounts[].hoststringRuta fuente en el host para bind mount
mounts[].containerstringRuta destino en el contenedor
mounts[].readonlybooleanfalseMontar como solo lectura

Ejemplo: Titan ESB (Elixir/Phoenix)

/etc/hull/titan.json
{
  "name": "titan",
  "rootfs": "/var/lib/hull/rootfs/titan",
  "argv": ["/opt/titan/bin/titan", "start"],
  "env": [
    "PHX_SERVER=true",
    "PORT=4500",
    "PHX_HOST=www.titan-bus.com",
    "LANG=en_US.UTF-8",
    "HOME=/tmp",
    "RELEASE_TMP=/tmp"
  ],
  "profile": "beam",
  "network": "host",
  "hostname": "titan",
  "cwd": "/opt/titan",
  "mounts": [
    { "host": "/dev/null", "container": "/dev/null" },
    { "host": "/dev/urandom", "container": "/dev/urandom" },
    { "host": "/etc/resolv.conf", "container": "/etc/resolv.conf", "readonly": true },
    { "host": "/etc/ssl/certs", "container": "/etc/ssl/certs", "readonly": true }
  ],
  "limits": { "memory_mb": 1024, "cpu": 2.0, "pids": 4096 }
}

Ejemplo: Contenedor con Bridge

bridge-test.json
{
  "name": "webapp",
  "rootfs": "/var/lib/hull/rootfs/webapp",
  "argv": ["/usr/local/bin/node", "server.js"],
  "env": ["PORT=3000", "NODE_ENV=production"],
  "profile": "node",
  "network": "bridge",
  "bridge": { "subnet": "10.88.0.0/24" },
  "hostname": "webapp",
  "cwd": "/app",
  "limits": { "memory_mb": 256, "cpu": 1.0, "pids": 128 }
}

Perfiles Seccomp

Hull trae cuatro allowlists de syscalls curadas. El perfil se selecciona via el campo profile del manifest y se instala justo antes del execve — incluso el primer syscall del workload esta filtrado. Cualquier syscall fuera de la lista gatilla KILL_PROCESS (no EPERM como el default de Docker).

PerfilSyscallsUsoExtras notables
default122Binarios estaticos Rust musl, Zig, Go; scripts shellexecve/clone/clone3 para pipelines shell, copy_file_range para coreutils
beam177Elixir, Erlang, Phoenix — la BEAM VM+55 extras: timerfd, signalfd, inotify, memfd_create, legacy mkdir/rmdir/unlink/rename/chmod/chown
node32Node.js, Deno, Bun — event loop libuvepoll_create1, epoll_wait, eventfd, signalfd, timerfd
dotnet36.NET 8/9 (CoreCLR, NativeAOT)select, pselect6, signalfd4, memfd_create (staging JIT), tgkill (pthreads)

Bloqueados en todos los perfiles: ptrace, process_vm_readv/writev, bpf, add_key/keyctl, userfaultfd, kexec_load, init_module.

Capas de Seguridad

Hull aplica siete capas de aislamiento, de la mas externa a la mas interna. Cada capa es independiente — el fallo de una no deshabilita las otras.

1User namespaceEl proceso cree que es root; el host ve uid no privilegiado. Habilitado con --rootless
2PID namespaceArbol PID aislado. PID 1 del contenedor = tu workload. No puede ver ni signalear procesos del host
3Network namespaceStack de red aislado. Tres modos: none (solo loopback), host (compartido), bridge (par veth + NAT)
4Mount namespacepivot_root a rootfs dedicado. Filesystem del host completamente invisible (no chroot — enforcement del kernel)
5cgroups v2Limites duros de CPU, memoria y PIDs. Enforced por kernel. El contenedor no puede fork-bomb ni agotar RAM del host
6Landlock LSMAllowlist de filesystem. Default: rootfs read+exec, /tmp read+write. Ni uid 0 dentro del contenedor puede bypassear. Skip gracioso en kernels < 5.13
7seccomp-bpfAllowlist de syscalls por perfil de workload. KILL_PROCESS en violacion. Instalado justo antes del execve

Networking Bridge

Pon "network": "bridge" y hull crea un stack de networking veth-bridge completo por contenedor:

  1. Crea el bridge hull0 (idempotente) con IP gateway 10.88.0.1/24
  2. Habilita ip_forward e instala regla masquerade nftables para el subnet
  3. Inserta iptables -I FORWARD 1 -i hull0 -j ACCEPT para bypasear el policy DROP de Docker
  4. Asigna la siguiente IP libre via lockfiles atomicos O_EXCL en ~/.hull/leases/
  5. Crea par veth; conecta host end al bridge, mueve container end al NEWNET del child
  6. Configura container side via nsenter -t <pid> -n ip addr add ...
  7. Al salir: lease liberado, veth auto-limpiado por kernel al terminar netns
Salida verificada desde contenedor bridge
$ sudo nsenter -t <pid> -n ip -br addr
lo               UNKNOWN        127.0.0.1/8
eth0@if36548     UP             10.88.0.2/24

$ sudo nsenter -t <pid> -n ip route
default via 10.88.0.1 dev eth0
10.88.0.0/24 dev eth0 proto kernel scope link src 10.88.0.2

$ sudo nsenter -t <pid> -n ping -c 3 8.8.8.8
3 packets transmitted, 3 received, 0% packet loss
rtt min/avg/max = 0.256/0.389/0.523 ms

Modo Rootless

Pasa --rootless (o ejecuta como usuario no-root) y hull usa un dance de tres procesos con pipes para montar el namespace NEWUSER:

Arbol de procesos rootless
orig_parent (uid del host)
    │ fork
    ▼
userns_setup
    │ unshare(NEWUSER) → señal parent "listo"
    │ bloquea en pipe → parent escribe uid_map/gid_map
    │ unshare(NEWPID|NEWNET|NEWNS|NEWUTS|NEWIPC)
    │ fork
    ▼
workload (PID 1 en NEWPID, uid 0 en contenedor)
    dup2 log_fd → pivot_root → landlock → seccomp → execve

Estado y Logs

Hull no tiene daemon — el estado vive en disco. Precedencia de ubicacion:

Estado$HULL_STATE_DIR → $HOME/.hull/state → /var/run/hull/state → /tmp/.hull/state
Logs$HULL_LOGS_DIR → $HOME/.hull/logs → /var/run/hull/logs → /tmp/.hull/logs
Leases$HULL_LEASE_DIR → $HOME/.hull/leases → /var/run/hull/leases
Rootfs cache/var/lib/hull/rootfs/<name>/ (extraido de archivos tar.gz)

Integracion con Mentat

Cuando se usa como driver Mentat, hull es invocado por mentat-agent via la implementacion del trait HullDriver. El YAML config.driver: hull dispara al agent a ejecutar hull run <manifest>, registrar el endpoint en el registry de servicios, y auto-generar el bloque reverse proxy de Caddy.

YAML de servicio Mentat para hull
services:
  - name: titan
    replicas: 1
    config:
      driver: hull
      manifest: /etc/hull/titan.json
      binary: /usr/local/bin/hull-sudo
      port: 4500
    ingress:
      host: www.titan-bus.com
      aliases:
        - titan.getmentat.run
      path: /
      tls: true
    security:
      profile: hull

Limitaciones Conocidas

  • Solo x86_64 y aarch64 (tablas seccomp son especificas por arquitectura)
  • Kernel ≥ 5.13 requerido para Landlock (fallback gracioso en kernels mas viejos)
  • Solo cgroups v2 jerarquia unificada (sin fallback v1)
  • Bridge mode solo soporta subnets /24 (254 IPs usables)
  • Bridge mode omite NEWPID — el workload comparte PID namespace del host
  • Sin registry de contenedores — rootfs debe ser path local o archivo
  • Sin filesystem por capas — el rootfs es una copia completa, no capas overlayfs
  • Reglas Landlock custom via manifest aun no implementadas (solo politica default)
  • Modo rootless: cgroups son best-effort (la mayoria de hosts no delegan a usuarios no privilegiados)

Build y Deploy

Cross-compile desde macOS a Linux
cd /path/to/hull
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseFast
# Binario: zig-out/bin/hull (~3.1 MB)

scp zig-out/bin/hull user@host:/usr/local/bin/hull
ssh user@host "hull version"
# hull 0.2.0 (zig 0.15.2)