# 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 ```bash 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:** ```bash 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:** ```bash curl -fsSL http://: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:///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://:${NGINX_PORT}/repo/` mit Directory-Listing aus, inklusive `client-repos/` für die fertigen `.repo`-Dateien. ### Start ```bash 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: ```bash 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.