Loading...

Упаковка

Как разместить собственный подписанный APT- и YUM-репозиторий на CDN

Раздавать Linux-инструмент архивом можно, но людям на самом деле нужна команда `apt-get install ваш-инструмент` — она переносит обновления в тот цикл, который у них уже работает. Это полное и проверенное руководство: как собрать подписанные APT- и YUM-репозитории, раздавать их через CDN и не попасться на ловушки подписи и кэширования. Каждую из описанных ошибок мы поймали сами, публикуя собственный CLI.

12 Средний уровень Updated

Как разместить собственный подписанный APT- и YUM-репозиторий на CDN

Зачем возиться, если архив работает?

Установка через `curl | tar` прекрасно работает в первый раз. Проблема — во второй. Ничто не сообщает пользователям о новой версии, ничто не обновляет её вместе с остальными пакетами и ничто не удаляет её начисто. Каждый пользователь в итоге придумывает собственный ритуал обновления.

Репозиторий пакетов перекладывает всё это на механику, которая у операционной системы уже есть. `apt-get upgrade` подхватывает вашу новую версию вместе со всем остальным. Удаление — это `apt-get remove`. Происхождение подтверждает GPG-подпись, которую менеджер пакетов проверяет автоматически, и никому не нужно об этом думать.

Цена вопроса — один вечер настройки и небольшая работа на каждый релиз, которую можно заскриптовать. Вот всё целиком.

Что такое репозиторий на самом деле

Оба формата — это просто статические файлы по HTTPS. Ни демона, ни базы данных — именно поэтому CDN раздаёт их так хорошо.

В APT-репозитории есть `pool/` с файлами `.deb` и дерево метаданных `dists/<suite>/`. `Packages` перечисляет каждый пакет с размером, хешами и путём; `Release` перечисляет хеши файлов `Packages`; а подпись покрывает `Release`. Это вся цепочка доверия, и дальше это будет важно.

YUM-репозиторий проще: файлы `.rpm` лежат в каталоге рядом с `repodata/`, где находится `repomd.xml` и сжатые индексы, на которые он ссылается.

Структура репозитория

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

Создайте ключ подписи

Ключ — единственная часть, которую нельзя переделать между делом: если вы его потеряете, каждому пользователю придётся импортировать новый. Сгенерируйте его один раз, сделайте резервную копию и секретного ключа, и сертификата отзыва, и держите приватную половину подальше от машин, которым она не нужна.

Релизные скрипты подписывают без участия человека, поэтому у ключа подписи репозитория обычно нет пароля: он защищён правами доступа к файлу и тем, что лежит в ограниченном месте — так же, как ключ подписи в CI. Задайте реальный срок действия (пять лет — обычная практика) и запишите дату продления там, где вы её действительно увидите.

Сгенерировать и экспортировать
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

Соберите и подпишите APT-репозиторий

`apt-ftparchive` (из пакета `apt-utils`) генерирует оба файла метаданных. Важны две детали.

Запускайте его из корня репозитория, потому что пути `Filename:`, которые он записывает в `Packages`, отсчитываются от места запуска. Ошибётесь — apt спокойно прочитает метаданные, а затем получит 404 на каждой загрузке.

Затем опубликуйте подпись дважды: `InRelease` — современная встроенная форма, а `Release.gpg` — отсоединённая подпись для старых клиентов. Сделать оба ничего не стоит и снимает целый класс вопросов в поддержку.

Собрать метаданные 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 нужны две подписи, а не одна

Это тот шаг, на котором спотыкаются почти все, и мы тоже споткнулись. APT и YUM проверяют пакеты принципиально по-разному.

APT — это цепочка: подпись покрывает `Release`, где лежат хеши `Packages`, где лежат хеши каждого `.deb`. Подписали метаданные — пакеты покрыты автоматически; отдельные файлы `.deb` не подписываются вовсе.

У RPM две независимые проверки. `repo_gpgcheck=1` проверяет `repomd.xml` через отсоединённый `repomd.xml.asc`. А `gpgcheck=1` проверяет подпись, встроенную внутрь каждого пакета, — её больше ничто не даёт. Подпишете только метаданные, и dnf остановится с `Package … is not signed / Error: GPG check FAILED`.

Поэтому выполняйте `rpmsign` для каждого `.rpm` — и делайте это до `createrepo_c`, потому что подписывание переписывает файлы, и любая ранее записанная контрольная сумма мгновенно становится неверной.

Сначала подписать пакеты, потом собрать метаданные
# 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

Раздача через CDN — и ловушка

Репозиторий пакетов — почти идеальная нагрузка для CDN: статические файлы, которые скачивают гораздо чаще, чем меняют, причём пользователи со всего мира. Направьте edge на ваш источник, и проблема трафика исчезает.

Ловушка в том, что метаданные репозитория внутренне согласованы. Каждый файл ссылается на хеши других файлов. Кэш, который отдал вам свежий `Release` и устаревший `Packages`, дал вам не слегка устаревший репозиторий, а сломанный, и в тексте ошибки про кэш не будет ни слова.

Вот три сбоя, которые мы поймали за одну публикацию:

• Устаревший `InRelease` при свежем `Packages` — apt сообщает о несовпадении хеша.

• Устаревший `.rpm` — dnf сообщает `Package … is not signed`, потому что подписывание переписало файл, а в кэше лежит старая, неподписанная копия.

• Свежий `repomd.xml` при устаревшем `repomd.xml.asc` — dnf сообщает `Bad PGP signature`. Изменились оба, а очищен был только один.

Отсюда правило: частичная очистка хуже, чем никакой. Очищайте каждый файл, который переписала публикация — точки входа, хешированные индексные файлы в `repodata/` и сами пакеты. Либо задайте метаданным очень короткий TTL, а пакетам позвольте кэшироваться надолго, поскольку их имена меняются с каждой версией.

Очистите всё, к чему прикоснулись
# 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

Публикация: как файлы попадают на CDN

Репозиторий — это лишь статические файлы, поэтому «опубликовать» значит положить это дерево каталогов туда, откуда CDN сможет его раздавать. Есть две формы, и какая нужна вам, зависит от того, есть ли у вас уже веб-сервер.

**Если источник уже есть** — любая машина, отдающая HTTPS, — поставьте CDN перед ней и публикуйте, копируя файлы туда. Достаточно `rsync` по SSH; edge заберёт с источника при первом запросе и дальше будет отдавать из кэша. Именно так мы делаем для cdn.com.tr, потому что машина загрузок — та же, что отдаёт сайт.

Предупреждение из опыта: синхронизируйте подкаталоги, а не родительский каталог. `rsync --delete`, нацеленный на каталог, где лежат и другие ваши загрузки, с удовольствием их удалит.

**Если вы вообще не хотите держать источник**, отправляйте файлы прямо в хранилище CDN. С cdn.com.tr вы загружаете дерево, и оно раздаётся с вашего CDN-домена — никакого веб-сервера, который надо ставить, патчить и поддерживать. `cdnctl cp -r` загружает целый каталог, а это ровно та форма, которую имеет репозиторий.

В обоих случаях базовый URL, который ваши пользователи впишут в `sources.list` или в файл `.repo`, — это просто ваш CDN-домен плюс путь публикации. И в обоих случаях завершайте шагом очистки из предыдущего раздела — именно про него забывают.

Два способа опубликовать одно и то же дерево
# 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

Проверьте в чистом контейнере

Не проверяйте на своей машине. Она уже доверяет вашему ключу, может держать кэшированный индекс и с радостью скроет ровно те проблемы, на которые сейчас наткнутся ваши пользователи. Одноразовый контейнер — единственная честная проверка: так мы и нашли и отсутствующую подпись пакета, и сбои из-за устаревшего кэша.

Держите проверку подписи включённой во время тестов. Отключить `gpgcheck`, чтобы «посмотреть, работает ли остальное», — самый известный способ выпустить неподписанный репозиторий.

Совет для стороны Fedora: передавайте `--disablerepo="*" --enablerepo="ваш-репо"`. Иначе dnf будет занят скачиванием собственных метаданных Fedora, и медленный или неудачный запуск не скажет вам о вашем репозитории ничего.

Два контейнера, настоящая установка
# 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'

И ещё: отключите самообновление в пакетах

Если ваш инструмент умеет обновлять себя сам, эта функция теперь конфликтует с менеджером пакетов. Самообновление перезаписывает файл, которым владеет `dpkg` или `rpm`, а их база продолжает указывать на версию, которой уже нет на диске, — и следующее обновление делает что-то неожиданное.

Исправление небольшое: пометьте сборку тем, как она распространялась, и пусть самообновление отходит в сторону, когда установку сделал менеджер пакетов. Прямые загрузки продолжают обновляться как раньше.

Метка на этапе сборки (пример на 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"

Частые вопросы

Обязательно ли вообще подписывать репозиторий?

Формально нет — apt и dnf можно заставить пропустить проверку. Практически да. Пользователям пришлось бы отключить проверку безопасности, чтобы установить ваше ПО: просить об этом плохо, а приучать к этому ещё хуже. Подпись стоит одного ключа и двух команд.

Почему dnf говорит, что пакет не подписан, если я подписал метаданные?

Потому что RPM проверяет две разные вещи. `repo_gpgcheck` покрывает `repomd.xml`; `gpgcheck` покрывает подпись, встроенную в каждый `.rpm`. Нужен ещё `rpmsign --addsign` для пакетов — и запускать его надо до `createrepo_c`, поскольку подписывание меняет файлы.

Почему apt сообщает о несовпадении хеша сразу после публикации?

Почти всегда из-за слоя кэширования, отдающего смесь старых и новых метаданных. Метаданные репозитория внутренне согласованы, поэтому частичное обновление — это сломанный репозиторий. Очистите каждый файл, переписанный публикацией, или задайте метаданным короткий TTL.

Может ли один репозиторий обслуживать amd64 и arm64?

Да. Для APT сгенерируйте файл `Packages` для каждой архитектуры в одной и той же suite и перечислите обе в `Architectures`. Для YUM положите оба пакета в один каталог — `createrepo_c` сам запишет архитектуру каждого.

Должны ли файлы репозитория лежать в git?

Обычно нет. Пулы пакетов растут с каждым релизом, а git хранит все версии вечно. Собирайте дерево из релизных артефактов и синхронизируйте его на источник, оставляя в git только скрипты и публичный ключ. Сделайте сборку воспроизводимой, чтобы репозиторий можно было пересоздать с нуля в любой момент.