#!/usr/bin/env bash # # setup-auto-updates.sh # # Universal unattended-upgrades setup for Debian & Ubuntu. # - Uses Origins-Pattern (modern) with ${distro_id}/${distro_codename} macros # - Safe for Debian Bookworm/Trixie and Ubuntu Noble+ (classic & deb822 sources) # - Clears legacy Allowed-Origins to avoid fragile legacy parser # - Enables APT Periodic + systemd timers (when present) # - Dry-run validation that won’t abort the run if APT is busy # - Interactive choice: FULL updates or SECURITY-only updates # # Re-run safe: overwrites 50unattended-upgrades and 20auto-upgrades. set -Eeuo pipefail trap 'echo "[ERROR] Line $LINENO failed" >&2' ERR REBOOT_TIME="${REBOOT_TIME:-04:00}" UPDATE_SCOPE="${UPDATE_SCOPE:-}" # optional env override: "all" (or "full") / "security" OS="" require_root() { if [[ $EUID -ne 0 ]]; then echo "[ERROR] Run as root (sudo)." >&2 exit 1 fi } detect_os() { [[ -f /etc/os-release ]] || { echo "[ERROR] /etc/os-release not found." >&2; exit 1; } # shellcheck disable=SC1091 . /etc/os-release local id="${ID,,}" case "$id" in debian|ubuntu) OS="$id" ;; *) [[ "${ID_LIKE:-}" =~ debian ]] && OS="debian" || { echo "[ERROR] Unsupported OS: $ID."; exit 1; } ;; esac echo "[INFO] Detected OS: ${PRETTY_NAME:-$ID}" } wait_for_apt() { echo "[INFO] Checking for apt/dpkg locks…" local locks=(/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock) for i in {1..90}; do if ! fuser "${locks[@]}" >/dev/null 2>&1; then echo "[INFO] No locks detected." return 0 fi (( i % 10 == 0 )) && echo "[INFO] apt/dpkg busy…waiting $(($i*2))s" sleep 2 done echo "[WARN] apt/dpkg still busy after ~180s; continuing anyway." return 1 } prompt_update_scope() { # Honor env override first (supports: all, full, security) if [[ -n "${UPDATE_SCOPE:-}" ]]; then case "${UPDATE_SCOPE,,}" in all|full) UPDATE_SCOPE="all"; echo "[INFO] Update scope (from env): ALL"; return 0 ;; security) UPDATE_SCOPE="security"; echo "[INFO] Update scope (from env): SECURITY-only"; return 0 ;; *) echo "[ERROR] UPDATE_SCOPE must be 'all' (or 'full') or 'security'."; exit 1 ;; esac fi # Non-interactive (no TTY): default to Full if [[ ! -t 0 ]]; then UPDATE_SCOPE="all" echo "[INFO] Non-interactive session: defaulting to ALL updates." return 0 fi echo echo "Configure automatic updates. Choose one:" echo " 1) Full updates — install regular and security updates from your distro" echo " (recommended for most systems; includes -updates and -security pockets)." echo " 2) Security-only — install only security updates from your distro" echo " (fewer changes; functional fixes may be delayed)." echo while true; do read -r -p "Select [1/2] (default: 1): " choice case "$choice" in ""|1) UPDATE_SCOPE="all"; echo "[INFO] Chosen update scope: ALL"; break ;; 2) UPDATE_SCOPE="security"; echo "[INFO] Chosen update scope: SECURITY-only"; break ;; *) echo "Please enter 1 or 2."; ;; esac done } apt_refresh_and_install() { wait_for_apt || true echo "[INFO] Updating APT cache…" apt-get update -qq || true wait_for_apt || true echo "[INFO] Installing unattended-upgrades…" DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades >/dev/null || true # Ensure periodic is enabled via debconf echo 'unattended-upgrades unattended-upgrades/enable_auto_updates boolean true' | debconf-set-selections || true DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive unattended-upgrades >/dev/null || true } write_50unattended() { # Build the Origins-Pattern block dynamically without expanding unattended macros. local tmp_file="/etc/apt/apt.conf.d/50unattended-upgrades" { # Header and open block (single-quoted heredoc preserves ${distro_*}) cat <<'EOF' // Auto-installed by setup-auto-updates.sh // Use Origins-Pattern only; clear legacy to avoid fragile parsing. #clear Unattended-Upgrade::Allowed-Origins; #clear Unattended-Upgrade::Origins-Pattern; Unattended-Upgrade::Origins-Pattern { EOF # ALL vs SECURITY blocks if [[ "$UPDATE_SCOPE" == "all" ]]; then printf ' "origin=${distro_id},archive=${distro_codename}";\n' printf ' "origin=${distro_id},archive=${distro_codename}-updates";\n' printf ' "origin=${distro_id},archive=${distro_codename}-security";\n' printf ' "origin=${distro_id},codename=${distro_codename}";\n' printf ' "origin=${distro_id},codename=${distro_codename}-updates";\n' printf ' "origin=${distro_id},codename=${distro_codename}-security";\n' else # Security-only printf ' "origin=${distro_id},archive=${distro_codename}-security";\n' printf ' "origin=${distro_id},codename=${distro_codename}-security";\n' fi # Ubuntu ESM pockets (only meaningful on Ubuntu; harmless otherwise—removed later on non-Ubuntu) printf ' "origin=UbuntuESM,archive=${distro_codename}-infra-security";\n' printf ' "origin=UbuntuESMApps,archive=${distro_codename}-apps-security";\n' # Close block + rest of configuration cat <<'EOF' }; Unattended-Upgrade::Automatic-Reboot "true"; Unattended-Upgrade::Automatic-Reboot-Time "REBOOT_TIME_PLACEHOLDER"; Unattended-Upgrade::Automatic-Reboot-WithUsers "false"; Unattended-Upgrade::Remove-Unused-Dependencies "true"; Unattended-Upgrade::Remove-New-Unused-Dependencies "true"; Unattended-Upgrade::SyslogEnable "true"; Unattended-Upgrade::Verbose "0"; EOF } > "$tmp_file" # Inject the desired reboot time without touching ${distro_*} macros sed -i "s/REBOOT_TIME_PLACEHOLDER/${REBOOT_TIME}/" "$tmp_file" # For neatness: drop ESM patterns on non-Ubuntu systems if [[ "$OS" != "ubuntu" ]]; then sed -i '/UbuntuESM/d' "$tmp_file" fi } write_20auto() { cat > /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 systemctl enable --now apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true fi } validate_with_dryrun() { wait_for_apt || true echo "[INFO] Validating unattended-upgrades with a dry run…" local log="/tmp/unattended-upgrades-dryrun.$$" if ! timeout 180 unattended-upgrades --dry-run --debug >"$log" 2>&1; then echo "[WARN] Dry run timed out or failed; see $log" return 1 fi # Show summary line grep -E "Allowed origins are" "$log" | head -n1 || true # Catch real parser errors if grep -qiE "Unable to parse|ValueError|AttributeError|ImportError" "$log"; then echo "[ERROR] Parsing error detected; see $log" return 1 fi } show_status() { echo echo "[INFO] Update scope: $UPDATE_SCOPE" 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 } prompt_self_delete() { # Only prompt on an interactive TTY if [[ -t 0 ]]; then echo read -r -p "Script successful. Do you wish to delete this script? [y/N] " reply case "$reply" in [yY]|[yY][eE][sS]) echo "[INFO] Removing script: $0" rm -- "$0" 2>/dev/null || echo "[WARN] Could not delete $0 (permission or filesystem issue)." ;; *) echo "[INFO] Keeping script: $0" ;; esac else echo "[INFO] Non-interactive session; skipping delete prompt." fi } main() { echo "[INFO] Unattended-upgrades configurator (Debian/Ubuntu)" require_root detect_os prompt_update_scope apt_refresh_and_install write_50unattended write_20auto enable_timers_if_systemd set +e validate_with_dryrun vr=$? set -e if (( vr != 0 )); then echo "[WARN] Validation failed or timed out. Config is written; timers/periodic will still run." fi show_status echo echo "[OK] Unattended updates configured (%s updates); reboot at %s if needed." "$UPDATE_SCOPE" "$REBOOT_TIME" # Offer to remove the script itself prompt_self_delete } main "$@"