#!/usr/bin/env bash # # setup-auto-updates.sh # # Universal unattended-upgrades setup for Debian & Ubuntu (VMs, LXCs, mixed fleets). # - Derives Allowed-Origins from `apt-cache policy` to handle "stable" vs codename cleanly # - Supports Debian (stable/testing) incl. security pockets; forward-compatible with Trixie # - Supports Ubuntu (noble, etc.) incl. -updates, -security, and optional ESM pockets # - Works with classic sources.list and deb822 `.sources` files # - Validates config with a dry run and enables systemd timers when available # # Notes: # * Backports are intentionally NOT auto-updated. # * On minimal containers without systemd, the APT::Periodic cron path is used. set -Eeuo pipefail trap 'echo "[ERROR] Line $LINENO failed" >&2' ERR REBOOT_TIME="${REBOOT_TIME:-04:00}" OS="" CODENAME="" DETECTED_ORIGINS=() # e.g., ("Debian:bookworm" "Debian:bookworm-security" ...) POLICY_PAIRS=() # raw pairs as "Origin:Archive" extracted from apt-cache require_root() { if [[ $EUID -ne 0 ]]; then echo "[ERROR] Run as root (sudo)." >&2 exit 1 fi } detect_os() { if [[ ! -f /etc/os-release ]]; then echo "[ERROR] /etc/os-release not found." >&2 exit 1 fi # shellcheck disable=SC1091 . /etc/os-release case "${ID,,}" in debian) OS="debian" ;; ubuntu) OS="ubuntu" ;; *) # Fall back via ID_LIKE if helpful if [[ "${ID_LIKE:-}" =~ debian ]]; then OS="debian" else echo "[ERROR] Unsupported OS: ${ID}." >&2 exit 1 fi ;; esac # CODENAME detection with fallbacks CODENAME="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}" if [[ -z "$CODENAME" ]] && command -v lsb_release >/dev/null 2>&1; then CODENAME="$(lsb_release -cs 2>/dev/null || true)" fi if [[ -z "$CODENAME" ]]; then # Last-resort: attempt to infer from apt-cache (n= field) CODENAME="$(apt-cache policy 2>/dev/null | sed -n 's/.*n=\([^,]*\).*/\1/p' | grep -E '^[a-z]+$' | head -n1 || true)" fi echo "[INFO] Detected OS: $OS codename: ${CODENAME:-unknown}" } apt_refresh_and_install() { echo "[INFO] Updating APT cache…" apt-get update -qq || true echo "[INFO] Installing unattended-upgrades…" DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades >/dev/null } extract_policy_pairs() { # Extract distinct Origin:Archive pairs that APT knows about. # Examples: "Debian:bookworm", "Debian:stable-security", "Ubuntu:noble-updates", "UbuntuESM:noble-infra-security" mapfile -t POLICY_PAIRS < <( apt-cache policy 2>/dev/null \ | sed -n 's/.*o=\([^,]*\),a=\([^,]*\).*/\1:\2/p' \ | awk 'NF' \ | sort -u ) } build_allowed_origins() { DETECTED_ORIGINS=() # Helper: add if present in POLICY_PAIRS have_pair() { local needle="$1" printf '%s\n' "${POLICY_PAIRS[@]}" | grep -Fxq -- "$needle" } add_if_present() { local pair="$1" if have_pair "$pair"; then DETECTED_ORIGINS+=("$pair") fi } # Debian: prefer codename pockets; also include stable/testing aliases if present. if [[ "$OS" == "debian" ]]; then # Codename-based pockets (typical) add_if_present "Debian:${CODENAME}" add_if_present "Debian:${CODENAME}-updates" add_if_present "Debian:${CODENAME}-security" # Suite aliases (if admin uses 'stable' or 'testing' in sources) add_if_present "Debian:stable" add_if_present "Debian:stable-updates" add_if_present "Debian:stable-security" add_if_present "Debian:testing" add_if_present "Debian:testing-security" # If nothing matched (e.g., first run, cache stale), fall back to sensible defaults if [[ ${#DETECTED_ORIGINS[@]} -eq 0 ]]; then DETECTED_ORIGINS=( "Debian:${CODENAME}" "Debian:${CODENAME}-updates" "Debian:${CODENAME}-security" ) fi fi # Ubuntu: pockets + optional ESM (if enabled on the host) if [[ "$OS" == "ubuntu" ]]; then add_if_present "Ubuntu:${CODENAME}" add_if_present "Ubuntu:${CODENAME}-updates" add_if_present "Ubuntu:${CODENAME}-security" # ESM origins (if the machine is attached to Ubuntu Pro/Advantage) add_if_present "UbuntuESM:${CODENAME}-infra-security" add_if_present "UbuntuESMApps:${CODENAME}-apps-security" if [[ ${#DETECTED_ORIGINS[@]} -eq 0 ]]; then # As with Debian, fall back to standard pockets DETECTED_ORIGINS=( "Ubuntu:${CODENAME}" "Ubuntu:${CODENAME}-updates" "Ubuntu:${CODENAME}-security" ) fi fi echo "[INFO] Allowed-Origins to be written:" printf ' %s\n' "${DETECTED_ORIGINS[@]}" } write_50unattended() { local origins_block="" for o in "${DETECTED_ORIGINS[@]}"; do origins_block+=" \"${o}\";\n" done cat > /etc/apt/apt.conf.d/50unattended-upgrades < /etc/apt/apt.conf.d/20auto-upgrades <<'EOF' APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Download-Upgradeable-Packages "1"; APT::Periodic::Unattended-Upgrade "1"; APT::Periodic::AutocleanInterval "7"; EOF } enable_timers_if_systemd() { if command -v systemctl >/dev/null 2>&1; then # Not all containers have systemd; ignore failures. systemctl enable --now apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true fi } validate_with_dryrun() { echo "[INFO] Validating unattended-upgrades with a dry run…" local log="/tmp/unattended-upgrades-dryrun.$$" if ! timeout 90 unattended-upgrades --dry-run --debug >"$log" 2>&1; then echo "[WARN] Dry run timed out or failed; see $log" return 1 fi # Show recognized allowed origins summary grep -E "Allowed origins are" "$log" | head -n1 || true # If nothing to upgrade, that's fine; ensure there is no obvious parse error. if grep -qiE "Unable to parse|ValueError|AttributeError|ImportError" "$log"; then echo "[ERROR] Parsing error detected; see $log" return 1 fi # Confirm at least one of our pairs appears in the "Allowed origins are" line local ok=0 local line line="$(grep -m1 "Allowed origins are" "$log" || true)" if [[ -n "$line" ]]; then for pair in "${DETECTED_ORIGINS[@]}"; do # Translate "Origin:Archive" -> "o=Origin,a=Archive" for comparison if [[ "$pair" =~ ^([^:]+):(.+)$ ]]; then local o="o=${BASH_REMATCH[1]},a=${BASH_REMATCH[2]}" if [[ "$line" == *"$o"* ]]; then ok=1; break fi fi done fi if (( ok == 0 )); then echo "[WARN] None of the configured origins were echoed in the debug header; this can be benign if the host has no updates yet or APT lists are stale. See $log for context." fi } show_status() { echo echo "[INFO] Config files:" ls -la /etc/apt/apt.conf.d/20auto-upgrades /etc/apt/apt.conf.d/50unattended-upgrades 2>/dev/null || true echo if command -v systemctl >/dev/null 2>&1; then echo "[INFO] Timers:" systemctl list-timers --all | grep -E 'apt-(daily|daily-upgrade)\.timer' || true else echo "[INFO] systemd not present; relying on APT::Periodic via cron." fi } main() { echo "[INFO] Unattended-upgrades configurator (Debian/Ubuntu)" require_root detect_os apt_refresh_and_install extract_policy_pairs build_allowed_origins write_50unattended write_20auto enable_timers_if_systemd validate_with_dryrun show_status echo echo "[OK] Unattended updates configured. Regular + security updates will apply automatically; reboot at ${REBOOT_TIME} if needed." } main "$@"