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
- Für jedes Source-Repo wird zunächst abgefragt, welche Pakete dieses
Repo überhaupt bereitstellt (
dnf repoquerynur gegen das Source-Repo). - 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
glibcabhängt). - 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:
- Beim ersten Lauf wird — falls noch keiner unter
GPG_HOMEexistiert — einmalig ein GPG-Schlüsselpaar erzeugt (unattended, ohne Passphrase; das ist für automatisierte Repo-Signierung üblich). Der öffentliche Teil wird nachREPO_BASE_DIR/RPM-GPG-KEY-mirrorexportiert. - 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 keingpgkey:angegeben, wird eine Warnung geloggt und die Prüfung übersprungen. - Danach wird die ursprüngliche Signatur entfernt und durch eine Signatur
mit dem mirror-eigenen Key ersetzt (
rpmsign --resign). - 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=1prüft beim Installieren die Signatur jedes einzelnen RPM gegen den Mirror-Key.repo_gpgcheck=1prü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_gpgcheckschließ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— Hauptskriptsync_helpers.py— YAML-Parsing & AppStream-XML-Generierung (muss im selben Verzeichnis wiesync.shliegen)sources.example.yaml— Beispiel-Quellenlistedocker-compose.yml,.env.example,docker/— Container-Setup (siehe unten)
Docker-Setup
Zwei Container:
- syncer — AlmaLinux 10 Image, führt
sync.shbeim Start und danach alleSYNC_INTERVAL_SECONDSerneut aus (einfache Sleep-Loop, kein Cron/systemd im Container). Schreibt nach/srv/repo(benanntes Volumerepo-data). - nginx — liefert
repo-dataread-only unterhttp://<host>:${NGINX_PORT}/repo/mit Directory-Listing aus, inklusiveclient-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 vonsync.sh(WORK_DIR, u.a. Logs untersync.log).
Beide überleben docker compose down; erst docker compose down -v löscht
sie.