#!/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"