Loading...

Empaquetado

Cómo alojar tu propio repositorio APT y YUM firmado en un CDN

Distribuir una herramienta Linux como tarball funciona, pero lo que la gente quiere de verdad es `apt-get install tuherramienta`: pone las actualizaciones en el ciclo que ya ejecutan. Esto es un recorrido completo y probado para construir repositorios APT y YUM firmados, servirlos desde un CDN, y los fallos de firma y de caché que te van a morder. Cada uno de ellos lo sufrimos publicando nuestro propio CLI.

12 Intermedio Updated

Cómo alojar tu propio repositorio APT y YUM firmado en un CDN

¿Por qué molestarse si un tarball funciona?

Una instalación con `curl | tar` funciona bien la primera vez. El problema es la segunda. Nada avisa a tus usuarios de que hay una versión nueva, nada la actualiza junto con sus otros paquetes y nada la desinstala limpiamente. Cada usuario acaba inventándose su propio ritual de actualización.

Un repositorio de paquetes traslada todo eso a la maquinaria que el sistema operativo ya tiene. `apt-get upgrade` recoge tu nueva versión junto con lo demás. Desinstalar es `apt-get remove`. La procedencia es una firma GPG que el gestor de paquetes comprueba automáticamente, sin que nadie tenga que pensar en ello.

El coste es una tarde de configuración y una pequeña cantidad de trabajo por versión que puedes automatizar. Aquí está todo.

Qué es realmente un repositorio

Ambos formatos son solo ficheros estáticos sobre HTTPS. No hay demonio ni base de datos, y por eso mismo un CDN los sirve tan bien.

Un repositorio APT tiene un `pool/` con los ficheros `.deb` y un árbol `dists/<suite>/` de metadatos. `Packages` lista cada paquete con su tamaño, hashes y ruta; `Release` lista los hashes de los ficheros `Packages`; y la firma cubre `Release`. Esa es toda la cadena de confianza, y luego importa.

Un repositorio YUM es más plano: los ficheros `.rpm` están en un directorio junto a `repodata/`, que contiene `repomd.xml` y los índices comprimidos a los que apunta.

Estructura del repositorio

deb/
  dists/stable/
    Release  InRelease  Release.gpg
    main/binary-amd64/Packages{,.gz}
    main/binary-arm64/Packages{,.gz}
  pool/main/y/yourtool/yourtool_1.0.0_amd64.deb

rpm/
  yourtool-1.0.0-1.x86_64.rpm
  repodata/
    repomd.xml  repomd.xml.asc
    *-primary.xml.gz  *-filelists.xml.gz  *-other.xml.gz

Crea una clave de firma

La clave es la única parte que no puedes rehacer a la ligera: si la pierdes, todos los usuarios tienen que importar una nueva. Genérala una vez, haz copia de seguridad tanto de la clave secreta como del certificado de revocación, y mantén la mitad privada fuera de cualquier máquina que no la necesite.

Los scripts de publicación firman sin intervención, así que una clave de firma de repositorio normalmente no lleva contraseña: se protege con permisos de fichero y viviendo en un sitio restringido, igual que una clave de firma de CI. Dale una caducidad real (cinco años es habitual) y apunta la fecha de renovación donde vayas a verla de verdad.

Generar y exportar
cat > key.batch <<'EOF'
%no-protection
Key-Type: RSA
Key-Length: 4096
Key-Usage: sign
Name-Real: example.com Package Signing
Name-Email: packages@example.com
Expire-Date: 5y
%commit
EOF
gpg --batch --gen-key key.batch && rm key.batch

# the public half — this is what users import
gpg --armor --export packages@example.com > example-com.gpg

# BACK THESE UP, offline:
#   gpg --export-secret-keys --armor packages@example.com
#   ~/.gnupg/openpgp-revocs.d/<fingerprint>.rev

Construye y firma el repositorio APT

`apt-ftparchive` (del paquete `apt-utils`) genera ambos ficheros de metadatos. Hay dos detalles que importan.

Ejecútalo desde la raíz del repositorio, porque las rutas `Filename:` que escribe en `Packages` son relativas al sitio desde el que lo invocas. Si te equivocas, apt leerá tus metadatos tan tranquilo y luego dará 404 en cada descarga.

Después publica la firma dos veces: `InRelease` es la forma moderna con firma en línea y `Release.gpg` es una firma separada para clientes antiguos. Producir ambas no cuesta nada y evita toda una categoría de preguntas de soporte.

Construir los metadatos APT
cd deb
for arch in amd64 arm64; do
  mkdir -p "dists/stable/main/binary-$arch"
  apt-ftparchive --arch "$arch" packages pool \
    > "dists/stable/main/binary-$arch/Packages"
  gzip -9cn "dists/stable/main/binary-$arch/Packages" \
    > "dists/stable/main/binary-$arch/Packages.gz"
done

apt-ftparchive \
  -o APT::FTPArchive::Release::Origin=example.com \
  -o APT::FTPArchive::Release::Suite=stable \
  -o APT::FTPArchive::Release::Components=main \
  -o APT::FTPArchive::Release::Architectures="amd64 arm64" \
  release dists/stable > dists/stable/Release

gpg --batch --yes -abs -o dists/stable/Release.gpg dists/stable/Release
gpg --batch --yes --clearsign -o dists/stable/InRelease dists/stable/Release

RPM necesita dos firmas, no una

Este es el paso con el que tropieza casi todo el mundo, y nosotros también. APT y YUM verifican los paquetes de maneras fundamentalmente distintas.

APT es una cadena: la firma cubre `Release`, que contiene los hashes de `Packages`, que contiene los hashes de cada `.deb`. Firmas los metadatos y los paquetes quedan cubiertos gratis: los ficheros `.deb` individuales no se firman en absoluto.

RPM tiene dos comprobaciones independientes. `repo_gpgcheck=1` verifica `repomd.xml` mediante un `repomd.xml.asc` separado. Pero `gpgcheck=1` verifica una firma incrustada dentro de cada paquete, que no proporciona nada más. Si solo firmas los metadatos, dnf se detiene con `Package … is not signed / Error: GPG check FAILED`.

Así que ejecuta `rpmsign` sobre cada `.rpm`, y hazlo antes de `createrepo_c`, porque firmar reescribe los ficheros y cualquier checksum registrado antes queda mal al instante.

Firma los paquetes y luego construye los metadatos
# rpmsign ships in the 'rpm' package on Debian/Ubuntu.
# rpm hardcodes %__gpg to /usr/bin/gpg2, which Debian/Ubuntu do not ship:
rpmsign --define "_gpg_name packages@example.com" \
        --define "__gpg $(command -v gpg)" \
        --addsign rpm/*.rpm

# fail loudly rather than publish something unsigned
for r in rpm/*.rpm; do
  rpm -qpi "$r" | grep -qi '^Signature *: *(none)' \
    && { echo "UNSIGNED: $r"; exit 1; }
done

# only now build the indexes, then sign repomd.xml
createrepo_c --quiet rpm/
gpg --batch --yes --detach-sign --armor \
    -o rpm/repodata/repomd.xml.asc rpm/repodata/repomd.xml

Servirlo desde un CDN — y la trampa

Un repositorio de paquetes es casi la carga ideal para un CDN: ficheros estáticos, descargados mucho más a menudo de lo que cambian, por usuarios de todo el mundo. Apunta el edge a tu origen y el problema de ancho de banda desaparece.

La trampa es que los metadatos de un repositorio son internamente consistentes. Cada fichero referencia hashes de otros ficheros. Una caché que te entrega un `Release` nuevo y un `Packages` viejo no te ha dado un repositorio un poco desactualizado: te ha dado uno roto, y el error no mencionará la caché por ningún lado.

Estos son los tres fallos que sufrimos, en una sola publicación:

• `InRelease` obsoleto con `Packages` nuevo: apt informa de un hash que no cuadra.

• `.rpm` obsoleto: dnf informa de `Package … is not signed`, porque firmar reescribió el fichero y la copia en caché es la antigua sin firmar.

• `repomd.xml` nuevo con `repomd.xml.asc` obsoleto: dnf informa de `Bad PGP signature`. Cambiaron los dos; solo se purgó uno.

La regla que sale de aquí: una purga parcial es peor que ninguna purga. Purga todos los ficheros que la publicación reescribió: los puntos de entrada, los ficheros de índice con hash bajo `repodata/` y los propios paquetes. O dale a los metadatos un TTL muy corto y deja que los paquetes se cacheen mucho tiempo, ya que sus nombres cambian con cada versión.

Purga todo lo que hayas tocado
# entry points
for p in /deb/dists/stable/InRelease \
         /deb/dists/stable/Release \
         /deb/dists/stable/Release.gpg \
         /deb/dists/stable/main/binary-amd64/Packages \
         /deb/dists/stable/main/binary-amd64/Packages.gz \
         /rpm/repodata/repomd.xml \
         /rpm/repodata/repomd.xml.asc; do
  cdnctl purge --account "$ACC" --path "$p" --type exact
done

# the hashed indexes and the packages changed too
for f in rpm/repodata/* rpm/*.rpm; do
  cdnctl purge --account "$ACC" \
    --path "/rpm/$(basename "$f")" --type exact
done

Publicar: cómo llegan los ficheros al CDN

Un repositorio son solo ficheros estáticos, así que «publicar» significa poner ese árbol de directorios donde el CDN pueda servirlo. Hay dos formas, y cuál quieres depende de si ya tienes un servidor web.

**Si ya tienes un origen** —cualquier máquina que sirva HTTPS— pon el CDN delante y publica copiando ficheros allí. `rsync` sobre SSH basta; el edge trae del origen en la primera petición y cachea a partir de ahí. Es lo que hacemos con cdn.com.tr, porque la máquina de descargas es la misma que sirve el sitio.

Un aviso desde la experiencia: sincroniza los subdirectorios, no el padre. Un `rsync --delete` apuntado al directorio que también guarda tus otras descargas te las borrará sin pestañear.

**Si no quieres mantener un origen**, sube los ficheros directamente al almacenamiento del CDN. Con cdn.com.tr subes el árbol y se sirve desde tu dominio del CDN: ningún servidor web que instalar, parchear ni mantener vivo. `cdnctl cp -r` sube un directorio entero, que es exactamente la forma de un repositorio.

En ambos casos, la URL base que tus usuarios ponen en `sources.list` o en el fichero `.repo` es simplemente tu dominio del CDN más la ruta donde publicaste. Y en ambos casos, termina con el paso de purga de la sección anterior: esa es la parte que se olvida.

Dos formas de publicar el mismo árbol
# A) you have an origin: sync the subdirectories, never the parent
rsync -a --delete dist/repo/deb/ user@origin:/var/www/downloads/deb/
rsync -a --delete dist/repo/rpm/ user@origin:/var/www/downloads/rpm/
rsync -a          dist/repo/example-com.gpg user@origin:/var/www/downloads/

# B) no origin: push straight into CDN storage
cdnctl cp -r dist/repo/deb <account_uuid>:/downloads/deb
cdnctl cp -r dist/repo/rpm <account_uuid>:/downloads/rpm
cdnctl files put --file dist/repo/example-com.gpg \
                 --target-path /downloads/example-com.gpg

# the base URL is simply your CDN hostname + that path:
#   deb  -> https://cdn.example.com/downloads/deb
#   rpm  -> https://cdn.example.com/downloads/rpm

Verifica en un contenedor limpio

No verifiques en tu propia máquina. Ya confía en tu clave, puede tener un índice en caché y te ocultará alegremente justo los problemas que tus usuarios están a punto de encontrar. Un contenedor desechable es la única prueba honesta: así encontramos tanto la firma de paquete que faltaba como los fallos de caché obsoleta.

Mantén la comprobación de firma activada mientras pruebas. Desactivar `gpgcheck` para ver si el resto funciona es la forma habitual de acabar publicando un repositorio sin firmar.

Un consejo para el lado de Fedora: pasa `--disablerepo="*" --enablerepo="turepo"`. Si no, dnf se pasa el rato descargando los metadatos de Fedora, y una ejecución lenta o fallida no te dice nada sobre tu repositorio.

Dos contenedores, instalaciones reales
# Debian / Ubuntu
docker run --rm debian:12 sh -c '
  apt-get update -qq && apt-get install -y -qq curl gnupg ca-certificates
  curl -fsSL https://example.com/example-com.gpg |
    gpg --dearmor -o /usr/share/keyrings/example-com.gpg
  echo "deb [signed-by=/usr/share/keyrings/example-com.gpg] \
        https://example.com/deb stable main" \
    > /etc/apt/sources.list.d/example.list
  apt-get update && apt-get install -y yourtool && yourtool --version'

# Fedora / RHEL — only your repo, so the test is about your repo
docker run --rm fedora:40 sh -c '
  printf "[example]\nbaseurl=https://example.com/rpm\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=https://example.com/example-com.gpg\n" \
    > /etc/yum.repos.d/example.repo
  dnf --disablerepo="*" --enablerepo="example" -y install yourtool
  rpm -qi yourtool | grep Signature'

Una cosa más: desactiva la autoactualización en los paquetes

Si tu herramienta puede actualizarse sola, esa función ahora choca con el gestor de paquetes. Una autoactualización sobrescribe un fichero que pertenece a `dpkg` o `rpm`, y su base de datos sigue apuntando a una versión que ya no está en disco, así que la siguiente actualización hace algo sorprendente.

El arreglo es pequeño: marca la compilación con la forma en que se distribuyó y haz que el autoactualizador se aparte cuando la instaló un gestor de paquetes. Las descargas directas siguen actualizándose solas como antes.

Una marca en tiempo de compilación (ejemplo en Go)
// installChannel is overridden at build time for packaged builds.
var installChannel = "direct"

func update() error {
    if installChannel != "direct" {
        return fmt.Errorf(
            "installed via %s — upgrade it with that instead",
            installChannel)
    }
    // ... normal self-update
}

// packaged builds:
//   go build -ldflags "-X main.installChannel=deb"

Preguntas frecuentes

¿Tengo que firmar el repositorio?

Técnicamente no: se puede decir a apt y a dnf que se salten la verificación. En la práctica sí. Los usuarios tendrían que desactivar una comprobación de seguridad para instalar tu software, lo cual es feo pedirlo y peor todavía enseñarlo. Firmar cuesta una clave y dos comandos.

¿Por qué dnf dice que mi paquete no está firmado si firmé los metadatos?

Porque RPM comprueba dos cosas distintas. `repo_gpgcheck` cubre `repomd.xml`; `gpgcheck` cubre una firma incrustada en cada `.rpm`. También necesitas `rpmsign --addsign` sobre los paquetes, y tiene que ejecutarse antes de `createrepo_c`, porque firmar modifica los ficheros.

¿Por qué apt da un error de hash justo después de publicar?

Casi siempre por una capa de caché que sirve una mezcla de metadatos viejos y nuevos. Los metadatos de un repositorio son internamente consistentes, así que una actualización parcial es un repositorio roto. Purga todos los ficheros que reescribió la publicación, o dale a los metadatos un TTL corto.

¿Puede un repositorio servir amd64 y arm64?

Sí. Para APT, genera un fichero `Packages` por arquitectura bajo la misma suite y lista ambas en `Architectures`. Para YUM, pon los dos paquetes en el mismo directorio: `createrepo_c` registra la arquitectura de cada uno.

¿Los ficheros del repositorio deben ir en git?

Normalmente no. Los pools de paquetes crecen con cada versión y git guarda todas para siempre. Construye el árbol a partir de tus artefactos de publicación y sincronízalo con el origen, dejando en git solo los scripts y la clave pública. Haz que la construcción sea reproducible para poder recrear el repositorio desde cero en cualquier momento.