Public Access
Provides a self-contained, containerized aptly (Debian repo manager) stack with independently releasable image and Helm chart versions. - images/: aptly-server (aptly built from source, cross-compiled) and aptly-deb-builder (nfpm + dpkg-buildpackage) container images - rootfs/: shared aptly-init/aptly-reconcile/aptly-push/aptly-pack scripts consumed identically by Compose and the Helm chart, driven by one declarative state.yaml contract - compose/: test (ephemeral, open) and production docker-compose stacks with an nginx read/auth sidecar - charts/aptly/: aptly-native Helm chart covering every security posture from fully open to authenticated read+write, Ingress and Gateway API support (usable in parallel for migration scenarios), metrics, and declarative repo/mirror/publish reconciliation via a Helm hook - .gitea/workflows/: CI (lint, template, kubeconform, E2E smoke test) plus separately tagged image (image/v*) and chart (chart/v*) releases, weekly rebuilds, and a preflight workflow validating the runner's Docker/Helm-OCI capabilities - pubkeys/: RSA chart-signing key for Helm --sign / Artifact Hub's signKey annotation (Helm can't verify Ed25519 keys) - docs/, README.md, charts/aptly/README.md: usage, security, and versioning documentation
181 lines
9.2 KiB
Bash
Executable File
181 lines
9.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# aptly-init — runs once as an initContainer (Helm) or a one-shot service (Compose).
|
|
# Produces everything the aptly and nginx containers consume, so neither of them
|
|
# has to guess about start-order or generate secrets themselves:
|
|
#
|
|
# /run/aptly/aptly.yaml rendered aptly config (env placeholders resolved)
|
|
# /run/aptly/htpasswd nginx basic-auth file (hashed here, never in a template)
|
|
# /run/aptly/gnupg/ GNUPGHOME, imported signing key + trusted keys
|
|
# /run/aptly/signing.json the one source of truth for "how do I sign a publish"
|
|
# /run/aptly/pub/signing-key.asc armored public key, served by nginx
|
|
#
|
|
# Inputs (all optional unless noted):
|
|
# APTLY_CONFIG_SRC path to the un-interpolated aptly config (required)
|
|
# APTLY_CONFIG_DST path to write the rendered config (required)
|
|
# APTLY_USERS_FILE plaintext "user:pass" lines, one per line, to hash into htpasswd
|
|
# APTLY_HTPASSWD_SRC pre-hashed htpasswd file to use as-is (wins over APTLY_USERS_FILE)
|
|
# APTLY_INTERNAL_USER username always appended to htpasswd (used by the reconcile job)
|
|
# APTLY_INTERNAL_PASSWORD password for APTLY_INTERNAL_USER
|
|
# APTLY_GPG_PRIVATE_KEY_FILE armored private key
|
|
# APTLY_GPG_SECRET_KEYRING_FILE binary secring.gpg (alternative to the above)
|
|
# APTLY_GPG_PASSPHRASE_FILE file containing the passphrase, if any
|
|
# APTLY_GPG_PUBLIC_KEY_FILE armored public key (derived from the private key if absent)
|
|
# APTLY_GPG_KEYS_DIR directory of extra *.asc files to import as trusted (mirror) keys
|
|
# APTLY_GPG_ENABLED "true"/"false" — false disables signing entirely (see below)
|
|
# APTLY_RUN_DIR defaults to /run/aptly
|
|
set -euo pipefail
|
|
|
|
# Deliberately does NOT source lib/common.sh: that helper requires APTLY_URL
|
|
# (it's built for the REST-API scripts), and aptly-init makes no API calls —
|
|
# coupling it to that requirement would make aptly-init fail outside a
|
|
# context that sets APTLY_URL, which is wrong for an initContainer.
|
|
log() { printf '[aptly-init] %s\n' "$*" >&2; }
|
|
die() { log "ERROR: $*"; exit 1; }
|
|
warn() { log "WARN: $*"; }
|
|
|
|
RUN_DIR="${APTLY_RUN_DIR:-/run/aptly}"
|
|
GNUPGHOME="${RUN_DIR}/gnupg"
|
|
mkdir -p "${RUN_DIR}" "${RUN_DIR}/pub"
|
|
install -d -m 0700 "${GNUPGHOME}"
|
|
export GNUPGHOME
|
|
|
|
# Ensure the publish directory exists before nginx ever tries to mount it.
|
|
# On Kubernetes, nginx's subPath mount of data/public happens at container
|
|
# creation, after this initContainer completes but with no other guarantee
|
|
# the directory has been created yet on a fresh, empty volume — aptly itself
|
|
# only creates it lazily on first publish.
|
|
if [[ -n "${APTLY_ROOT_DIR:-}" ]]; then
|
|
mkdir -p "${APTLY_ROOT_DIR}/public"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Render the aptly config: resolve ${VAR} placeholders against the process
|
|
# environment. Bare `envsubst` (no argument) blanks every variable it does
|
|
# not know about, which would silently wreck a GPG passphrase containing a
|
|
# literal "$". We restrict substitution to a computed SHELL-FORMAT list of
|
|
# currently-exported vars instead.
|
|
# ---------------------------------------------------------------------------
|
|
: "${APTLY_CONFIG_SRC:?APTLY_CONFIG_SRC must point at the source aptly config}"
|
|
: "${APTLY_CONFIG_DST:?APTLY_CONFIG_DST must point at the rendered output path}"
|
|
|
|
mkdir -p "$(dirname -- "$APTLY_CONFIG_DST")"
|
|
shell_format="$(env | cut -d= -f1 | grep -E '^[A-Za-z_][A-Za-z0-9_]*$' | sed 's/^/${/;s/$/}/' | tr '\n' ' ')"
|
|
envsubst "${shell_format}" < "$APTLY_CONFIG_SRC" > "$APTLY_CONFIG_DST"
|
|
chmod 0640 "$APTLY_CONFIG_DST"
|
|
|
|
# Strip comment lines first: a source config documenting its own ${VAR}
|
|
# syntax in a comment would otherwise trip this check on itself.
|
|
# shellcheck disable=SC2016 # single quotes are deliberate: this is a regex, not shell expansion
|
|
if unresolved="$(grep -v '^[[:space:]]*#' "$APTLY_CONFIG_DST" \
|
|
| grep -o '\${[A-Za-z_][A-Za-z0-9_]*}' | sort -u)"; then
|
|
while IFS= read -r v; do
|
|
[[ -n "$v" ]] && log "WARN: unresolved placeholder ${v} left in rendered config"
|
|
done <<< "$unresolved"
|
|
fi
|
|
log "rendered config -> ${APTLY_CONFIG_DST}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. htpasswd. Hashing happens here, not in a Helm template: a template using
|
|
# sprig's htpasswd would pick a new random bcrypt salt on every render,
|
|
# changing the Secret on every `helm upgrade` and restart-looping the pod
|
|
# via the checksum/secret annotation. The Secret therefore carries
|
|
# plaintext "user:pass" lines (or a ready-made htpasswd, for the
|
|
# ExternalSecrets path) and this script does the one-time hashing.
|
|
# ---------------------------------------------------------------------------
|
|
HTPASSWD_OUT="${RUN_DIR}/htpasswd"
|
|
: > "$HTPASSWD_OUT"
|
|
|
|
if [[ -n "${APTLY_HTPASSWD_SRC:-}" && -s "${APTLY_HTPASSWD_SRC}" ]]; then
|
|
cat "${APTLY_HTPASSWD_SRC}" >> "$HTPASSWD_OUT"
|
|
log "using pre-hashed htpasswd from ${APTLY_HTPASSWD_SRC}"
|
|
elif [[ -n "${APTLY_USERS_FILE:-}" && -s "${APTLY_USERS_FILE}" ]]; then
|
|
while IFS=: read -r user pass; do
|
|
[[ -z "$user" || "$user" == \#* || -z "${pass:-}" ]] && continue
|
|
printf '%s:%s\n' "$user" "$(openssl passwd -apr1 -- "$pass")" >> "$HTPASSWD_OUT"
|
|
done < "${APTLY_USERS_FILE}"
|
|
log "hashed $(wc -l < "$HTPASSWD_OUT") user(s) from ${APTLY_USERS_FILE}"
|
|
fi
|
|
|
|
if [[ -n "${APTLY_INTERNAL_USER:-}" && -n "${APTLY_INTERNAL_PASSWORD:-}" ]]; then
|
|
printf '%s:%s\n' "$APTLY_INTERNAL_USER" "$(openssl passwd -apr1 -- "$APTLY_INTERNAL_PASSWORD")" >> "$HTPASSWD_OUT"
|
|
log "appended internal user '${APTLY_INTERNAL_USER}' (used by the reconcile job / cron)"
|
|
fi
|
|
# World-readable, not 0640: the nginx container reads this file as its own
|
|
# UID (101 upstream, or whatever securityContext.runAsUser is set to in the
|
|
# Helm chart), which has no relation to the aptly UID that wrote it. In
|
|
# Kubernetes, pod-level fsGroup would put both UIDs in a shared supplementary
|
|
# group instead — Compose has no equivalent, so this file has to be
|
|
# world-readable. It contains only apr1-hashed passwords, not plaintext.
|
|
chmod 0644 "$HTPASSWD_OUT"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. GPG. GNUPGHOME lives on an in-memory emptyDir: the private key never
|
|
# touches a PersistentVolume, and the keyring is re-derived from Secrets
|
|
# on every start, so key rotation is just "restart the pod".
|
|
# ---------------------------------------------------------------------------
|
|
gpg_enabled="${APTLY_GPG_ENABLED:-true}"
|
|
signing_json="${RUN_DIR}/signing.json"
|
|
|
|
if [[ "$gpg_enabled" != "true" ]]; then
|
|
printf '{"skip": true}\n' > "$signing_json"
|
|
log "signing disabled (APTLY_GPG_ENABLED=false) -> ${signing_json}"
|
|
else
|
|
imported_any=false
|
|
|
|
if [[ -n "${APTLY_GPG_PRIVATE_KEY_FILE:-}" && -s "${APTLY_GPG_PRIVATE_KEY_FILE}" ]]; then
|
|
gpg --batch --import "${APTLY_GPG_PRIVATE_KEY_FILE}" 2>&1 | while read -r l; do log "gpg: $l"; done || true
|
|
imported_any=true
|
|
fi
|
|
if [[ -n "${APTLY_GPG_SECRET_KEYRING_FILE:-}" && -s "${APTLY_GPG_SECRET_KEYRING_FILE}" ]]; then
|
|
gpg --batch --import "${APTLY_GPG_SECRET_KEYRING_FILE}" 2>&1 | while read -r l; do log "gpg: $l"; done || true
|
|
imported_any=true
|
|
fi
|
|
|
|
if [[ "$imported_any" != "true" ]]; then
|
|
warn_msg="APTLY_GPG_ENABLED=true but no private key was provided"
|
|
log "WARN: ${warn_msg} — publishing will fail signature checks unless aptly.gpg.enabled is also false"
|
|
printf '{"skip": true}\n' > "$signing_json"
|
|
else
|
|
# Auto-detect the key id so nothing downstream has to guess it.
|
|
key_id="$(gpg --batch --list-secret-keys --with-colons 2>/dev/null | awk -F: '$1=="sec"{print $5; exit}')"
|
|
[[ -z "$key_id" ]] && die "GPG key(s) imported but no secret key id could be detected"
|
|
|
|
passphrase_file=""
|
|
if [[ -n "${APTLY_GPG_PASSPHRASE_FILE:-}" && -s "${APTLY_GPG_PASSPHRASE_FILE}" ]]; then
|
|
passphrase_file="${APTLY_GPG_PASSPHRASE_FILE}"
|
|
fi
|
|
|
|
jq -n --arg keyId "$key_id" --arg keyring "" --arg secretKeyring "" \
|
|
--arg passphraseFile "$passphrase_file" \
|
|
'{skip: false, batch: true, gpgKey: $keyId} + (if $passphraseFile != "" then {passphraseFile: $passphraseFile} else {} end)' \
|
|
> "$signing_json"
|
|
log "signing key detected: ${key_id} -> ${signing_json}"
|
|
|
|
# Export the public key so nginx can serve it, and clients can `signed-by=` it.
|
|
pub_out="${RUN_DIR}/pub/signing-key.asc"
|
|
if [[ -n "${APTLY_GPG_PUBLIC_KEY_FILE:-}" && -s "${APTLY_GPG_PUBLIC_KEY_FILE}" ]]; then
|
|
cp "${APTLY_GPG_PUBLIC_KEY_FILE}" "$pub_out"
|
|
else
|
|
gpg --batch --armor --export "$key_id" > "$pub_out"
|
|
fi
|
|
log "public key exported -> ${pub_out}"
|
|
fi
|
|
|
|
# Trusted keys for mirror verification (aptly.gpgKeys[] in values / mirrors.gpgKeys in state.yaml)
|
|
if [[ -n "${APTLY_GPG_KEYS_DIR:-}" && -d "${APTLY_GPG_KEYS_DIR}" ]]; then
|
|
shopt -s nullglob
|
|
for f in "${APTLY_GPG_KEYS_DIR}"/*.asc "${APTLY_GPG_KEYS_DIR}"/*.gpg; do
|
|
[[ -e "$f" ]] || continue
|
|
if gpg --batch --import "$f" 2>&1 | while read -r l; do log "gpg: $l"; done; then
|
|
log "imported trusted key from ${f}"
|
|
else
|
|
warn "failed to import trusted key ${f} (continuing — mirrors referencing it will fail signature checks)"
|
|
fi
|
|
done
|
|
shopt -u nullglob
|
|
fi
|
|
fi
|
|
|
|
chmod -R go-rwx "${GNUPGHOME}" 2>/dev/null || true
|
|
log "done"
|