328 lines
16 KiB
Markdown
328 lines
16 KiB
Markdown
# 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://<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
|
||
|
||
```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.
|