Initial commit

This commit is contained in:
2026-09-07 11:55:21 +02:00
commit d83087cbbd
20 changed files with 1963 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ostree \
flatpak \
git \
openssh-client \
curl \
ca-certificates \
gnupg \
&& rm -rf /var/lib/apt/lists/*
# yq (mikefarah, Go-Binary) zum Parsen von flatpaks.yaml
RUN curl -sSL -o /usr/local/bin/yq \
https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \
&& chmod +x /usr/local/bin/yq
# Gemeinsamer Signing-Key: entrypoint.sh kopiert ihn beim Start aus dem
# read-only gemounteten /run/secrets/gpg hierher.
ENV GNUPGHOME=/etc/mirror-gpg
WORKDIR /app
COPY gpg-import.sh /usr/local/lib/gpg-import.sh
COPY entrypoint.sh /app/entrypoint.sh
COPY sync.sh /app/sync.sh
RUN chmod +x /app/entrypoint.sh /app/sync.sh
ENTRYPOINT ["/app/entrypoint.sh"]
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Uebernimmt den gemeinsamen, EXTERN erzeugten Signing-Key aus dem read-only
# gemounteten Verzeichnis /run/secrets/gpg (auf dem Host: ./gpg neben der
# docker-compose.yml) in ein containerlokales GNUPGHOME.
#
# Bewusst eine Kopie statt direkt darauf zu arbeiten: gpg schreibt in seinem
# Homedir (trustdb, random_seed, Lockfiles), und derselbe Ordner ist
# gleichzeitig im dnf-mirror-Container gemountet. Die Kopie haelt das
# Original unveraendert und vermeidet, dass sich beide Container beim
# Signieren gegenseitig ins Gehege kommen.
set -euo pipefail
GPG_SOURCE=/run/secrets/gpg
export GNUPGHOME="${GNUPGHOME:-/etc/mirror-gpg}"
: "${GPG_KEY_ID:?GPG_KEY_ID muss gesetzt sein (gemeinsamer Signing-Key, siehe .env)}"
# shellcheck source=/dev/null
. /usr/local/lib/gpg-import.sh
import_shared_gpg
require_usable_signing_key
exec /app/sync.sh
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# Gemeinsame Hilfsfunktionen beider Spiegel-Container: den EXTERN erzeugten
# Signing-Key aus dem read-only gemounteten $GPG_SOURCE uebernehmen und
# pruefen, ob damit unbeaufsichtigt signiert werden kann.
#
# ACHTUNG: identische Kopie in dnf-mirror/gpg-import.sh - Aenderungen bitte in
# beiden pflegen. (Getrennte Build-Kontexte je Image; ein gemeinsamer Kontext
# wuerde das Schluesselverzeichnis ./gpg in den Build-Kontext ziehen.)
# Die Dateien, die ein GnuPG-Homedir ausmachen. Bewusst eine feste Liste statt
# "cp -a .": im Quellverzeichnis liegen oft noch die Agent-Sockets
# (S.gpg-agent & Co.) und Lock-Dateien eines gpg-agent, der auf dem HOST
# lief/laeuft. Die haben im Container nichts verloren.
GPG_HOME_ENTRIES=(pubring.kbx pubring.gpg secring.gpg trustdb.gpg \
private-keys-v1.d openpgp-revocs.d public-keys.d)
import_shared_gpg() {
if [ ! -d "$GPG_SOURCE" ]; then
echo "[gpg] FEHLER: $GPG_SOURCE ist nicht gemountet - fehlt der Ordner ./gpg?" >&2
return 1
fi
rm -rf "$GNUPGHOME"
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"
local entry copied=0
for entry in "${GPG_HOME_ENTRIES[@]}"; do
[ -e "$GPG_SOURCE/$entry" ] || continue
# Kein "|| true": schlaegt das Kopieren des Schluesselmaterials fehl,
# soll das laut und sofort auffallen statt spaeter als "Key nicht
# gefunden".
cp -a "$GPG_SOURCE/$entry" "$GNUPGHOME/"
copied=1
done
if [ "$copied" -eq 0 ]; then
echo "[gpg] FEHLER: in $GPG_SOURCE liegt kein GnuPG-Schluesselbund." >&2
echo "[gpg] Erwartet wird der INHALT eines GnuPG-Homedirs (pubring.kbx," >&2
echo "[gpg] private-keys-v1.d, ...), nicht z.B. eine exportierte .asc-Datei." >&2
echo "[gpg] Tatsaechlicher Inhalt:" >&2
ls -la "$GPG_SOURCE" >&2
return 1
fi
chmod -R go-rwx "$GNUPGHOME"
# Signiert wird unbeaufsichtigt, also nie ein Pinentry-Fenster erwarten.
echo "allow-loopback-pinentry" > "$GNUPGHOME/gpg-agent.conf"
echo "pinentry-mode loopback" > "$GNUPGHOME/gpg.conf"
}
# Listet die im Homedir tatsaechlich vorhandenen geheimen Schluessel als
# "<fingerprint> <uid>" - genau das, was als GPG_KEY_ID in die .env gehoert.
list_available_secret_keys() {
gpg --batch --list-secret-keys --with-colons 2>/dev/null | awk -F: '
$1 == "sec" { want = 1; fpr = ""; next }
$1 == "fpr" && want { fpr = $10; want = 0; next }
$1 == "uid" && fpr != "" { print " " fpr " " $10; fpr = ""; next }
'
}
# Prueft nicht nur, OB der Key da ist, sondern ob damit auch wirklich
# unbeaufsichtigt signiert werden kann - ein passphrasegeschuetzter Key faellt
# sonst erst mitten im ersten Sync-Lauf auf.
require_usable_signing_key() {
if ! gpg --batch --list-secret-keys "$GPG_KEY_ID" >/dev/null 2>&1; then
echo "[gpg] FEHLER: kein geheimer Schluessel '$GPG_KEY_ID' in ./gpg gefunden." >&2
local available
available="$(list_available_secret_keys)"
if [ -n "$available" ]; then
echo "[gpg] Vorhanden sind:" >&2
echo "$available" >&2
echo "[gpg] Einen dieser Fingerprints als GPG_KEY_ID in die .env eintragen." >&2
else
echo "[gpg] Der Schluesselbund enthaelt ueberhaupt keinen GEHEIMEN Schluessel" >&2
echo "[gpg] (nur oeffentliche?). private-keys-v1.d/ pruefen - dort muss" >&2
echo "[gpg] mindestens eine .key-Datei liegen." >&2
fi
return 1
fi
local err
err="$(printf 'signing-selftest' \
| gpg --batch --yes --local-user "$GPG_KEY_ID" --detach-sign --output /dev/null - 2>&1)" && {
echo "[gpg] Gemeinsamen Signing-Key '$GPG_KEY_ID' uebernommen, Testsignatur ok."
return 0
}
echo "[gpg] FEHLER: Schluessel '$GPG_KEY_ID' ist da, aber Signieren schlaegt fehl:" >&2
echo "$err" >&2
echo "[gpg] Haeufigste Ursache: der Key hat eine Passphrase. Unbeaufsichtigtes" >&2
echo "[gpg] Signieren braucht einen Key OHNE Passphrase (siehe README)." >&2
return 1
}
+348
View File
@@ -0,0 +1,348 @@
#!/bin/bash
set -euo pipefail
# OSTree-Repo = Wurzel des Ausgabe-Volumes. nginx mountet genau dieses Volume
# nach /usr/share/nginx/html/flatpak und liefert es unter /flatpak/ aus.
REPO_PATH="/srv/flatpak"
CACHE_DIR="/var/cache/flatpak-mirror"
GIT_CACHE="$CACHE_DIR/sources-git"
CONFIG_SRC="/config/flatpaks.yaml"
ARCH="x86_64"
: "${MIRROR_DOMAIN:?MIRROR_DOMAIN muss gesetzt sein}"
: "${GPG_KEY_ID:?GPG_KEY_ID muss gesetzt sein (gemeinsamer Signing-Key, siehe .env)}"
: "${MIRROR_REPO_ID:=company-mirror}"
: "${SOURCES_GIT_REF:=main}"
: "${SYNC_INTERVAL_SECONDS:=900}"
export GNUPGHOME="${GNUPGHOME:-/etc/mirror-gpg}"
mkdir -p "$CACHE_DIR"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
# --- 1. flatpaks.yaml besorgen ---------------------------------------------
# Setzt die Git-Authentifizierung fuer das gemeinsame Freigabe-Repo. Bei
# HTTPS+Token ueber GIT_CONFIG_*-Umgebungsvariablen (git >= 2.31): das Token
# steht so weder in der URL (die in Logs auftauchen koennte) noch als
# Kommandozeilenargument (das per "ps aux" auf dem Host sichtbar waere).
configure_git_auth() {
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
if [ -n "${SOURCES_GIT_USERNAME:-}" ] && [ -n "${SOURCES_GIT_TOKEN:-}" ]; then
log "Git: verwende Token-Authentifizierung (Benutzer: ${SOURCES_GIT_USERNAME})"
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0="http.extraHeader"
export GIT_CONFIG_VALUE_0="Authorization: Basic $(printf '%s' "${SOURCES_GIT_USERNAME}:${SOURCES_GIT_TOKEN}" | base64 | tr -d '\n')"
fi
if [ -n "${SOURCES_GIT_SSH_KEY:-}" ] && [ -f "${SOURCES_GIT_SSH_KEY}" ]; then
log "Git: verwende SSH-Deploy-Key ${SOURCES_GIT_SSH_KEY}"
export GIT_SSH_COMMAND="ssh -i ${SOURCES_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${CACHE_DIR}/known_hosts"
fi
}
get_config() {
if [ -n "${SOURCES_GIT_URL:-}" ]; then
configure_git_auth
if [ -d "$GIT_CACHE/.git" ]; then
log "Git: hole Updates aus ${SOURCES_GIT_URL} (@ ${SOURCES_GIT_REF})"
if ! git -C "$GIT_CACHE" fetch --quiet origin "$SOURCES_GIT_REF" \
|| ! git -C "$GIT_CACHE" reset --hard --quiet "origin/${SOURCES_GIT_REF}"; then
log "FEHLER: git fetch fehlgeschlagen."
return 1
fi
else
log "Git: klone ${SOURCES_GIT_URL} (@ ${SOURCES_GIT_REF})"
rm -rf "$GIT_CACHE"
if ! git clone --quiet --branch "$SOURCES_GIT_REF" "$SOURCES_GIT_URL" "$GIT_CACHE"; then
log "FEHLER: git clone fehlgeschlagen."
return 1
fi
fi
CONFIG_FILE="$GIT_CACHE/flatpaks.yaml"
else
log "Kein SOURCES_GIT_URL gesetzt, nutze lokal gemountete ${CONFIG_SRC}"
CONFIG_FILE="$CONFIG_SRC"
fi
if [ ! -f "$CONFIG_FILE" ]; then
log "FEHLER: flatpaks.yaml nicht gefunden unter ${CONFIG_FILE}"
return 1
fi
}
# --- 2. Repo initialisieren, falls noch nicht vorhanden --------------------
init_repo() {
mkdir -p "$REPO_PATH"
if [ ! -f "$REPO_PATH/config" ]; then
log "Initialisiere neues OSTree-Repo unter ${REPO_PATH}"
ostree --repo="$REPO_PATH" init --mode=archive-z2
fi
if ! ostree --repo="$REPO_PATH" remote list | grep -q '^flathub$'; then
log "Richte Flathub als Remote ein"
if ! curl -sSL -o /tmp/flathub.gpg "$FLATHUB_GPG_URL"; then
log "FEHLER: Flathub-GPG-Key konnte nicht von ${FLATHUB_GPG_URL} geladen werden."
return 1
fi
if ! ostree --repo="$REPO_PATH" remote add flathub "$FLATHUB_REMOTE_URL" \
--gpg-import=/tmp/flathub.gpg; then
log "FEHLER: Flathub-Remote konnte nicht eingerichtet werden."
return 1
fi
fi
}
# --- 3. Explizit gewuenschte App-Refs aus flatpaks.yaml --------------------
explicit_app_refs() {
yq -r '.apps[] | "app/" + .id + "/'"$ARCH"'/" + .branch' "$CONFIG_FILE"
}
# --- Hilfsfunktion: Runtime-Abhaengigkeit eines bereits gepullten Refs -----
# --- aus der eingebetteten "metadata"-Datei auslesen (Zeile runtime=...) ---
get_runtime_dependency() {
local ref="$1"
local meta dep
meta="$(ostree --repo="$REPO_PATH" cat "$ref" /metadata 2>/dev/null || true)"
[ -z "$meta" ] && return 0
dep="$(echo "$meta" | grep -E '^runtime=' | head -n1 | cut -d= -f2-)"
[ -z "$dep" ] && return 0
echo "runtime/${dep}"
}
# --- Hilfsfunktion: Commit-Checksum eines Refs finden, egal ob unter -------
# --- refs/heads/ oder (Collection-ID-gebunden) refs/mirrors/<id>/ ----------
resolve_pulled_checksum() {
local ref="$1"
if [ -f "$REPO_PATH/refs/heads/$ref" ]; then
cat "$REPO_PATH/refs/heads/$ref"
return 0
fi
find "$REPO_PATH/refs/mirrors" -type f -path "*/$ref" 2>/dev/null | head -n1 | xargs -r cat
}
# --- 4. Aktuell lokal verwaltete Refs ermitteln (Apps + Runtimes) ----------
current_refs() {
ostree --repo="$REPO_PATH" refs | grep -E "^(app|runtime)/" || true
}
# --- 5. Pull + rekursive Abhaengigkeitsaufloesung --------------------------
# ACHTUNG: run_once() wird in der Hauptschleife als if-Bedingung aufgerufen.
# Damit ist "set -e" fuer den gesamten Aufrufbaum darunter ausgehebelt (Bash
# unterdrueckt den Abbruch in jedem Kontext, in dem der Rueckgabewert
# ausgewertet wird - und zwar rekursiv). Fehler muessen hier deshalb
# ausdruecklich geprueft und weitergereicht werden, sonst laeuft der Sync
# stillschweigend weiter.
# Pullt einen einzelnen Ref und legt den passenden heads/-Ref an
# (siehe Kommentar bei resolve_pulled_checksum weiter oben).
pull_one_ref() {
local ref="$1"
log "Pull: ${ref}"
if ! ostree --repo="$REPO_PATH" pull --mirror flathub "$ref"; then
log "FEHLER: Pull von ${ref} fehlgeschlagen."
return 1
fi
local checksum
checksum="$(resolve_pulled_checksum "$ref")"
if [ -z "$checksum" ]; then
log "FEHLER: Konnte Commit-Checksum fuer ${ref} nach dem Pull nicht ermitteln."
return 1
fi
if ! ostree --repo="$REPO_PATH" refs --create="$ref" "$checksum" --force; then
log "FEHLER: Konnte heads/-Ref fuer ${ref} nicht anlegen."
return 1
fi
# ref hat die Form "app/ID/ARCH/BRANCH" oder "runtime/ID/ARCH/BRANCH".
# Signiert wird mit dem gemeinsamen Key aus ./gpg - demselben, mit dem der
# DNF-Spiegel seine RPMs signiert.
local ref_id ref_arch ref_branch
ref_id="$(echo "$ref" | cut -d/ -f2)"
ref_arch="$(echo "$ref" | cut -d/ -f3)"
ref_branch="$(echo "$ref" | cut -d/ -f4)"
# "flatpak build-sign" sucht die ID per Default unter app/ - eine Runtime
# muss ausdruecklich als solche gekennzeichnet werden, sonst scheitert es
# mit "Refspec 'app/<ID>/<ARCH>/<BRANCH>' not found" und die Runtime bleibt
# unsigniert (Clients mit GPG-Pruefung koennen sie dann nicht installieren).
local -a sign_args=(
--arch="$ref_arch"
--gpg-sign="$GPG_KEY_ID"
--gpg-homedir="$GNUPGHOME"
)
[ "${ref%%/*}" = "runtime" ] && sign_args+=(--runtime)
log "Signiere ${ref} mit dem Spiegel-Key"
if ! flatpak build-sign "$REPO_PATH" "$ref_id" "$ref_branch" "${sign_args[@]}"; then
log "FEHLER: Signieren von ${ref} fehlgeschlagen."
return 1
fi
}
# Ergebnis von resolve_and_pull_all(): alle Refs, die der Spiegel fuehren soll
# (freigegebene Apps + alle aufgeloesten Runtimes).
#
# Bewusst eine globale Variable statt einer Ausgabe auf stdout: in der
# Funktion laufen ostree und flatpak, deren eigene Ausgabe sonst ueber die
# Kommandosubstitution mit ins "Ergebnis" geraet - im Log tauchten dann
# saemtliche Fortschrittszeilen als vermeintliche Refs auf.
WANTED_REFS=()
# Nimmt die explizit freigegebenen App-Refs, pullt sie, liest jeweils die
# Runtime-Abhaengigkeit aus und haengt sie hinten an die Warteschlange an -
# so werden auch mehrstufige Abhaengigkeiten (App -> Runtime -> Basis-Runtime)
# vollstaendig aufgeloest.
#
# Ein einzelner fehlgeschlagener Ref bricht den Lauf nicht ab: die uebrigen
# Apps werden trotzdem aktualisiert, der Ref behaelt seinen letzten guten
# Stand (er bleibt in WANTED_REFS und wird deshalb auch nicht geloescht) und
# der Rueckgabewert markiert den Lauf als fehlgeschlagen.
resolve_and_pull_all() {
local -A seen=()
local queue=()
local ref dep status=0
WANTED_REFS=()
while IFS= read -r ref; do
[ -z "$ref" ] && continue
queue+=("$ref")
done < <(explicit_app_refs)
while [ "${#queue[@]}" -gt 0 ]; do
ref="${queue[0]}"
queue=("${queue[@]:1}")
[ -n "${seen[$ref]:-}" ] && continue
seen["$ref"]=1
if ! pull_one_ref "$ref"; then
log "${ref} wird uebersprungen, letzter guter Stand bleibt erhalten."
status=1
fi
# Die Runtime-Abhaengigkeit auch dann aufloesen, wenn der Ref gerade
# fehlgeschlagen ist: sonst fehlte sie in WANTED_REFS und der Abgleich
# in sync_refs() wuerde eine laengst gespiegelte Runtime loeschen, die
# von der App weiterhin gebraucht wird.
dep="$(get_runtime_dependency "$ref")"
if [ -n "$dep" ] && [ -z "${seen[$dep]:-}" ]; then
log " -> benoetigt zusaetzlich: ${dep}"
queue+=("$dep")
fi
done
[ "${#seen[@]}" -gt 0 ] && WANTED_REFS=("${!seen[@]}")
return "$status"
}
# --- 6. Abgleich: pullen was fehlt (inkl. Abhaengigkeiten), loeschen was ---
# --- nicht mehr gewuenscht ist ----------------------------------------------
sync_refs() {
local wanted current ref status=0
log "Loese App-Refs und Abhaengigkeiten auf..."
resolve_and_pull_all || status=1
# Schutz gegen eine leere/kaputte flatpaks.yaml (oder ein fehlgeschlagenes
# yq): ohne diese Pruefung waere die Liste der gewuenschten Refs leer und
# der Abgleich unten wuerde JEDEN Ref aus dem Spiegel loeschen.
if [ "${#WANTED_REFS[@]}" -eq 0 ]; then
log "FEHLER: flatpaks.yaml ergibt keinen einzigen App-Ref - es wird nichts"
log "geloescht, der bisherige Stand des Repos bleibt unveraendert."
return 1
fi
wanted="$(printf '%s\n' "${WANTED_REFS[@]}")"
current="$(current_refs)"
log "Verwaltete Refs (Apps + Runtimes):"
printf ' + %s\n' "${WANTED_REFS[@]}"
# Refs entfernen, die weder explizit freigegeben noch als Abhaengigkeit
# noch benoetigt werden
while IFS= read -r ref; do
[ -z "$ref" ] && continue
if ! grep -qxF "$ref" <<< "$wanted"; then
log "Entferne nicht mehr benoetigten Ref: ${ref}"
if ! ostree --repo="$REPO_PATH" refs --delete "$ref"; then
log "FEHLER: Ref ${ref} konnte nicht geloescht werden."
status=1
fi
fi
done <<< "$current"
return "$status"
}
# --- 7. Summary aktualisieren -----------------------------------------------
update_summary() {
log "Aktualisiere Repo-Summary und Static Deltas"
if ! flatpak build-update-repo \
--generate-static-deltas \
--gpg-sign="$GPG_KEY_ID" \
--gpg-homedir="$GNUPGHOME" \
"$REPO_PATH"; then
log "FEHLER: Signierte Summary konnte nicht erstellt werden"
return 1
fi
}
# --- 8. Client-Dateien schreiben --------------------------------------------
# Public Key + fertige .flatpakrepo-Datei, damit Clients den Spiegel mit
# einem Kommando und MIT Signaturpruefung einbinden koennen. Beides liegt im
# Repo-Verzeichnis und ist damit unter https://<domain>/flatpak/... abrufbar
# (Flathub macht es mit flathub.gpg genauso).
publish_client_files() {
local keyfile="$REPO_PATH/${MIRROR_REPO_ID}.gpg"
if ! gpg --batch --yes --export --output "$keyfile" "$GPG_KEY_ID"; then
log "FEHLER: Public Key konnte nicht exportiert werden."
return 1
fi
cat > "$REPO_PATH/${MIRROR_REPO_ID}.flatpakrepo" <<EOF
[Flatpak Repo]
Title=${MIRROR_REPO_ID}
Url=https://${MIRROR_DOMAIN}/flatpak/
Homepage=https://${MIRROR_DOMAIN}/
Comment=Kuratierter Flatpak-Spiegel
GPGKey=$(base64 -w0 < "$keyfile")
EOF
log "Client-Dateien geschrieben: ${MIRROR_REPO_ID}.gpg, ${MIRROR_REPO_ID}.flatpakrepo"
}
run_once() {
local status=0
get_config || return 1
init_repo || return 1
# Ein einzelner nicht spiegelbarer Ref soll die uebrigen Apps nicht
# aufhalten: Summary und Client-Dateien werden trotzdem geschrieben, der
# Lauf gilt am Ende aber als fehlgeschlagen und wird wiederholt.
sync_refs || status=1
update_summary || return 1
publish_client_files || return 1
if [ "$status" -eq 0 ]; then
log "Sync abgeschlossen."
else
log "Sync abgeschlossen, aber mit Fehlern - siehe oben."
fi
return "$status"
}
# --- Hauptschleife ----------------------------------------------------------
log "Starte Sync-Loop (Intervall: ${SYNC_INTERVAL_SECONDS}s)"
while true; do
if run_once; then
log "Naechster Lauf in ${SYNC_INTERVAL_SECONDS}s"
else
log "Sync-Lauf fehlgeschlagen, versuche es beim naechsten Intervall erneut"
fi
sleep "$SYNC_INTERVAL_SECONDS"
done