2026-08-26 15:17:47 +02:00
2026-08-26 15:17:47 +02:00
2026-08-26 15:17:47 +02:00
2026-08-26 14:29:16 +02:00
2026-08-26 10:06:43 +02:00

RPM Mirror Sync

sync.sh spiegelt einzeln benannte Pakete aus Drittanbieter-RPM-Repos in ein lokales Repo, ohne dabei Pakete mitzuspiegeln, die AlmaLinux selbst schon liefert (BaseOS, AppStream, Extras, CRB/"Extras for Enterprise"). Nur die in der YAML-Liste explizit genannten Pakete bekommen AppStream-Metadaten und werden damit in GNOME Software / Discover als installierbar angezeigt.

Voraussetzungen (auf dem Server, der das Skript ausführt)

dnf install -y dnf-plugins-core createrepo_c python3-pyyaml git

Das Skript muss als root laufen (schreibt kurzzeitig eine .repo-Datei nach /etc/yum.repos.d/).

Konfiguration

Alles über Umgebungsvariablen, z.B. in der systemd-Unit oder im Cron-Job:

Variable Bedeutung Default
SOURCES_GIT_REPO Git-Repo mit der YAML-Liste (leer = lokale Datei verwenden)
SOURCES_GIT_BRANCH Branch im Git-Repo main
SOURCES_YAML_FILE Pfad zur YAML-Datei (im Git-Repo oder lokal) sources.yaml
WORK_DIR Arbeitsverzeichnis (Downloads, Logs, Zwischenlisten) /var/cache/rpm-mirror-sync
REPO_BASE_DIR Zielverzeichnis der gespiegelten Repos /srv/repo
CLIENT_REPO_DIR Wo fertige .repo-Dateien für Clients abgelegt werden ${REPO_BASE_DIR}/client-repos
CLIENT_BASE_URL URL, unter der REPO_BASE_DIR für Clients erreichbar ist http://mirror.example.local/repo
ALMA_REPO_IDS repoids der lokalen AlmaLinux-Repos (dnf repolist) baseos appstream extras crb

Wichtig: ALMA_REPO_IDS muss zu den tatsächlichen repoids auf dem Server passen (dnf repolist prüfen) — sonst funktioniert der Ausschluss-Mechanismus nicht korrekt.

Ausführen

export SOURCES_GIT_REPO=https://git.example.com/infra/rpm-sources.git
export CLIENT_BASE_URL=http://repo.internal.example.com/repo
./sync.sh

Danach liegt unter REPO_BASE_DIR/pkgs/ das eine kombinierte Repo mit allen Paketen aller Quellen (per Webserver auszuliefern) und unter CLIENT_REPO_DIR/mirror.repo die eine fertige .repo-Datei, die auf die Clients verteilt wird (z.B. via Ansible/Config-Management nach /etc/yum.repos.d/).

Am besten per systemd-Timer (z.B. täglich) statt Cron, damit Logs sauber in journalctl landen.

Wie der Ausschluss von AlmaLinux-Abhängigkeiten funktioniert

  1. Für jedes Source-Repo wird zunächst abgefragt, welche Pakete dieses Repo überhaupt bereitstellt (dnf repoquery nur gegen das Source-Repo).
  2. Anschließend wird die komplette Abhängigkeitskette der gewünschten Pakete aufgelöst — dabei sind sowohl das Source-Repo als auch die lokalen AlmaLinux-Repos aktiv, damit die Auflösung überhaupt gelingt (z.B. wenn ein Paket von glibc abhängt).
  3. Von den heruntergeladenen RPMs wird alles verworfen, was nicht in der Liste aus Schritt 1 steht — also alles, was tatsächlich aus einem AlmaLinux-Repo kam. Übrig bleiben nur RPMs, die es ausschließlich im Drittanbieter-Repo gibt.

Wichtig: "übrig bleiben" ist meist mehr als nur die Namen unter packages:. Wenn ein angefordertes Paket von weiteren Drittanbieter-Paketen abhängt, die AlmaLinux nicht bereitstellt (z.B. bei .NET: dotnet-host, dotnet-hostfxr-*, dotnet-apphost-pack-*, aspnetcore-runtime-*, ...), werden die zwangsläufig mitgespiegelt — sonst schlägt dnf install dotnet-sdk-8.0 auf dem Client mit einer fehlenden Abhängigkeit fehl, die nirgendwo sonst verfügbar wäre. Nur echte AlmaLinux-Pakete werden ausgeschlossen, nicht Drittanbieter-Abhängigkeiten. Im Log ist das ab jetzt pro Paket sichtbar:

[...]   keeping (requested): dotnet-sdk-8.0-8.0.130-1.el10_2.x86_64
[...]   keeping (dependency): dotnet-host-10.0.11-1.el10_2.x86_64
[...] Source 'dotnet': kept 26 package(s) - 6 explicitly requested + 20 pulled-in third-party dependencies (...)

Sonderfall: baseurl zeigt auf ein AlmaLinux-eigenes Repo

dnf "sucht" nie selbstständig bei Drittanbietern — es lädt ausschließlich von der URL, die ihr unter baseurl eintragt. Zeigt diese URL zufällig auf ein AlmaLinux-eigenes Repo (z.B. .../AppStream/...), ist also gar keine externe Quelle im Spiel. Genau das ist bei .NET auf AlmaLinux 10 der Fall: dotnet-sdk, dotnet-runtime, dotnet-host & Co. sind Teil des offiziellen AppStream-Repos (anders als bei älteren Alma-/RHEL-Versionen mit separatem Microsoft-Repo).

Konsequenz: Die "requested"-Pakete sind hier technisch schon über das normale, auf jedem Client ohnehin aktivierte AppStream-Repo installierbar — der Ausschluss-Mechanismus (Schritt 1-3 oben) erkennt das nicht, weil er nur gegen die anderen Alma-Repos (BaseOS, Extras, CRB) vergleicht, nicht gegen die Quelle selbst. Ein normaler Mirror-Lauf würde hier also RPM-Inhalte duplizieren, die AlmaLinux bereits liefert.

Für genau diesen Fall gibt es metadata_only: true (pro Quelle in der YAML, siehe sources.example.yaml): Es werden keine RPMs heruntergeladen, verifiziert oder umsigniert — nur kuratierte AppStream-Metadaten für die gelisteten Paketnamen werden generiert und veröffentlicht. Das tatsächliche Installieren übernimmt dnf weiterhin ganz normal über das Repo, das das Paket wirklich bereitstellt (bei AlmaLinux-eigenen Paketen also das Standard-AppStream-Repo, das auf jeder Standardinstallation ohnehin aktiv ist). Der Effekt: Discover zeigt nur eure kuratierte Auswahl statt des kompletten (oft sehr großen) AppStream-Kataloges, ohne dass ihr Inhalte dupliziert, die es schon gibt.

Voraussetzung: Die Clients müssen das Repo, das die Pakete tatsächlich bereitstellt, bereits aktiviert haben — bei Standard-AlmaLinux-Installationen (BaseOS/AppStream/Extras/CRB aktiv) ist das automatisch der Fall.

Für ein echtes Drittanbieter-Repo (Pakete, die AlmaLinux nirgends hat) lasst ihr metadata_only einfach weg (Default: false) — dann läuft der volle Mirror-Prozess wie oben beschrieben, inklusive Verifikation und Umsignieren.

Umsignieren mit einem eigenen GPG-Key

Damit eure Clients nicht für jede gespiegelte Quelle einen eigenen GPG-Key importieren müssen, signiert sync.sh jedes gespiegelte Paket auf einen einzigen, mirror-eigenen Schlüssel um:

  1. Beim ersten Lauf wird — falls noch keiner unter GPG_HOME existiert — einmalig ein GPG-Schlüsselpaar erzeugt (unattended, ohne Passphrase; das ist für automatisierte Repo-Signierung üblich). Der öffentliche Teil wird nach REPO_BASE_DIR/RPM-GPG-KEY-mirror exportiert.
  2. Für jede Quelle wird — sofern gpgkey: in der YAML gesetzt ist — das heruntergeladene RPM zunächst gegen den originalen Key des Upstream geprüft (rpmkeys --checksig). Schlägt die Prüfung fehl, bricht das Skript für diese Quelle ab (kein stillschweigendes Mirroring manipulierter Pakete). Ist kein gpgkey: angegeben, wird eine Warnung geloggt und die Prüfung übersprungen.
  3. Danach wird die ursprüngliche Signatur entfernt und durch eine Signatur mit dem mirror-eigenen Key ersetzt (rpmsign --resign).
  4. Alle generierten Client-.repo-Dateien verweisen auf denselben Key (${CLIENT_BASE_URL}/RPM-GPG-KEY-mirror) — unabhängig davon, wie viele Quellen gespiegelt werden.

gpgcheck und repo_gpgcheck: Die generierte .repo-Datei setzt beide auf 1:

  • gpgcheck=1 prüft beim Installieren die Signatur jedes einzelnen RPM gegen den Mirror-Key.
  • repo_gpgcheck=1 prüft zusätzlich die Signatur der Repo-Metadaten selbst (repodata/repomd.xml.asc, ein detached signature). Ohne das könnte jemand mit Schreibzugriff auf den Webserver/die Leitung theoretisch die Paketliste manipulieren (z.B. ein älteres, verwundbares Paket als aktuell ausgeben oder Einträge entfernen), ohne dass eine einzelne RPM-Signatur berührt würde — repo_gpgcheck schließt genau diese Lücke.

sync.sh signiert repomd.xml nach jedem Lauf neu (nachdem createrepo_c und modifyrepo_c fertig sind, da beide die Datei noch verändern) mit demselben Mirror-Key, den auch die Pakete tragen — Clients brauchen also weiterhin nur den einen RPM-GPG-KEY-mirror.

Wichtig zur Aufbewahrung des privaten Schlüssels: GPG_HOME (Default: ${WORK_DIR}/gnupg) enthält den privaten Signierschlüssel und darf niemals über nginx/den Webserver erreichbar sein — im mitgelieferten Docker-Setup liegt er bewusst auf dem sync-cache-Volume, das nicht in den nginx-Container gemountet wird, nur repo-data (die öffentlichen Dateien) ist das. Wer Lesezugriff auf GPG_HOME hat, kann Pakete als euer Mirror signieren — entsprechend schützen (Backups, Zugriffsrechte).

GPG_HOME muss auf persistentem Speicher liegen: wird es gelöscht, erzeugt das Skript beim nächsten Lauf einen neuen Schlüssel, und alle Clients müssen den neuen öffentlichen Key erneut importieren.

Revocation-Zertifikat

Da der Mirror-Key mit expire=never erzeugt wird, gibt es kein automatisches Ablaufdatum, über das ein kompromittierter Key von selbst "entschärft" würde. Deshalb erzeugt sync.sh beim allerersten Lauf zusammen mit dem Schlüssel auch ein Revocation-Zertifikat unter GPG_HOME/revocation-cert.asc — das ist die einzige Möglichkeit, den Key später für ungültig zu erklären, falls der private Schlüssel jemals kompromittiert wird.

Direkt nach dem ersten Start aus dem Container herausholen und offline sichern:

docker compose cp syncer:/var/cache/rpm-mirror-sync/gnupg/revocation-cert.asc \
    ./mirror-key-revocation-cert.asc

(Pfad anpassen, falls ihr WORK_DIR/GPG_HOME in .env geändert habt.) Das Zertifikat danach an einem Ort außerhalb des Docker-Hosts aufbewahren (Passwort-Safe, Offline-Backup o.ä.) — nicht im selben Ordner wie der private Key selbst, sonst bringt es im Kompromittierungsfall nichts. Das Skript loggt den Hinweis beim ersten Lauf zusätzlich in sync.log.

Kommt der Ernstfall, importiert ihr das Zertifikat auf einer Maschine mit Zugriff auf GPG_HOME (gpg --homedir ... --import mirror-key-revocation-cert.asc), exportiert den nun als widerrufen markierten Public Key erneut nach REPO_BASE_DIR/RPM-GPG-KEY-mirror und verteilt ihn an alle Clients — dnf/rpm lehnen mit einem widerrufenen Schlüssel signierte Pakete danach ab.

Wie die Sichtbarkeit in Discover/GNOME Software gesteuert wird

createrepo_c erzeugt Repo-Metadaten für alle im Zielverzeichnis liegenden RPMs (nötig, damit dnf install Abhängigkeiten auflösen kann). GNOME Software/Discover zeigt aber nur Pakete an, für die es AppStream-Komponenten-Metadaten gibt. Diese werden vom Skript nur für die explizit unter packages: gelisteten Namen erzeugt (nicht für mitgezogene Abhängigkeiten) und per modifyrepo_c --mdtype=appstream in die Repo-Metadaten injiziert.

Einschränkung: Es werden nur "generic" AppStream-Komponenten erzeugt (kein Icon, keine Screenshots, keine .desktop-Datei — die liefert das Originalpaket nicht mit). Das reicht, damit ein Paket in Discover installierbar ist und in der Suche auftaucht, aber es erscheint nicht auf der kuratierten "Entdecken"-Startseite von GNOME Software — das ist ein Verhalten von GNOME Software/appstreamcli, das sich über Repo-Metadaten allein nicht ändern lässt.

Repo auf Clients einbinden

Alle Quellen landen in einem einzigen kombinierten Repo unter REPO_BASE_DIR/pkgs/ — egal wie viele Einträge eure sources.yaml hat. Entsprechend gibt es auch nur eine Client-.repo-Datei: CLIENT_REPO_DIR/mirror.repo (Default: /srv/repo/client-repos/mirror.repo, über nginx erreichbar unter /repo/client-repos/mirror.repo).

Einbinden auf einem Client — ein Kommando, ein Repo:

curl -fsSL http://<host>:8080/repo/client-repos/mirror.repo \
    -o /etc/yum.repos.d/rpm-mirror.repo
dnf makecache

Das war's — egal ob ihr später weitere Quellen zur sources.yaml hinzufügt, die Client-Konfiguration bleibt unverändert (baseurl und gpgkey ändern sich nicht, nur der Inhalt des Repos wächst). Für die Verteilung an viele Clients eignet sich dasselbe curl-Kommando in Ansible/Config-Management oder einem Kickstart-Postscript.

https://<host>/repo selbst (ohne /pkgs/) ist weiterhin kein dnf-Repo, sondern nur ein Verzeichnis-Listing (nginx autoindex) — baseurl muss auf /repo/pkgs/ zeigen (das steht schon so in der generierten mirror.repo, ihr müsst daran nichts anpassen).

Namenskonflikte: Da jetzt alle Quellen in ein gemeinsames Verzeichnis spiegeln, dürfen zwei Quellen keine RPMs mit demselben Paketnamen aber unterschiedlichem Inhalt liefern (identische NEVRA aus zwei Quellen ist unproblematisch, das ist einfach dasselbe Paket). Achtet bei mehreren Drittanbieter-Repos in einer sources.yaml auf eindeutige Paketnamen.

Dateien

  • sync.sh — Hauptskript
  • sync_helpers.py — YAML-Parsing & AppStream-XML-Generierung (muss im selben Verzeichnis wie sync.sh liegen)
  • sources.example.yaml — Beispiel-Quellenliste
  • docker-compose.yml, .env.example, docker/ — Container-Setup (siehe unten)

Docker-Setup

Zwei Container:

  • syncer — AlmaLinux 10 Image, führt sync.sh beim Start und danach alle SYNC_INTERVAL_SECONDS erneut aus (einfache Sleep-Loop, kein Cron/systemd im Container). Schreibt nach /srv/repo (benanntes Volume repo-data).
  • nginx — liefert repo-data read-only unter http://<host>:${NGINX_PORT}/repo/ mit Directory-Listing aus, inklusive client-repos/ für die fertigen .repo-Dateien.

Start

cp .env.example .env
# .env anpassen: mindestens CLIENT_BASE_URL und ALMA_REPO_IDS prüfen
docker compose up -d --build
docker compose logs -f syncer

.env ist die einzige Konfigurationsquelle: docker-compose.yml selbst enthält keine hartkodierten Umgebungsvariablen oder Defaults mehr — beide Container laden alles per env_file: .env. Wer etwas ändern will (Pfade, Sync-Intervall, Client-URL, Signier-Identität, Port, ...), bearbeitet ausschließlich .env und macht docker compose up -d, ohne docker-compose.yml anzufassen.

Danach ist das (eine, kombinierte) Repo erreichbar unter http://localhost:8080/repo/pkgs/ und die eine Client-.repo-Datei unter http://localhost:8080/repo/client-repos/mirror.repo.

Eigene Quellenliste einbinden

Lokale Datei (Standard): LOCAL_SOURCES_YAML in .env auf den Pfad eurer echten sources.yaml setzen (relativ zum docker-compose.yml), z.B. LOCAL_SOURCES_YAML=./sources.yaml. Wird read-only nach /config/sources.yaml im Container gemountet.

Git-Repo statt lokaler Datei: SOURCES_GIT_REPO, SOURCES_GIT_BRANCH und SOURCES_YAML_FILE (Pfad innerhalb des Git-Repos, relativ) in .env setzen. Der Bind-Mount aus der lokalen Variante wird dann schlicht ignoriert.

Einmaliger Sync statt Dauerlauf

RUN_ONCE=true in .env setzen — der syncer-Container synct dann einmal und beendet sich, statt in der Schleife zu laufen. Praktisch, wenn ihr den Sync lieber über einen externen Scheduler (Kubernetes CronJob, systemd-Timer mit docker compose run, etc.) anstoßen wollt.

ALMA_REPO_IDS prüfen

Die repoids im Container können von einer bare-metal-Installation abweichen. Nach dem ersten Start prüfen:

docker compose exec syncer dnf repolist

und ALMA_REPO_IDS in .env entsprechend anpassen, dann docker compose up -d erneut ausführen.

Persistenz

  • repo-data (Volume) — die gespiegelten Repos selbst; das ist auch, was nginx ausliefert.
  • sync-cache (Volume) — Arbeitsverzeichnis von sync.sh (WORK_DIR, u.a. Logs unter sync.log).

Beide überleben docker compose down; erst docker compose down -v löscht sie.

S
Description
No description provided
Readme
289 KiB
Languages
Shell 93.5%
Dockerfile 6.5%