diff --git a/setup-auto-updates.sh b/setup-auto-updates.sh new file mode 100644 index 0000000..c020e0d --- /dev/null +++ b/setup-auto-updates.sh @@ -0,0 +1,175 @@ +#!/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 +# +# 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}" + +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) : ;; + *) [[ "${ID_LIKE:-}" =~ 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 +} + +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() { + # Use a single-quoted heredoc to avoid expanding ${distro_*} macros here. + # Then substitute REBOOT_TIME safely afterward. + cat > /etc/apt/apt.conf.d/50unattended-upgrades <<'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 { + // Cross-distro (Debian or Ubuntu) standard pockets (archive form) + "origin=${distro_id},archive=${distro_codename}"; + "origin=${distro_id},archive=${distro_codename}-updates"; + "origin=${distro_id},archive=${distro_codename}-security"; + + // Also allow codename form to match mirrors that expose only n= + "origin=${distro_id},codename=${distro_codename}"; + "origin=${distro_id},codename=${distro_codename}-updates"; + "origin=${distro_id},codename=${distro_codename}-security"; + + // Ubuntu ESM (harmless on Debian; matches only if present/attached) + "origin=UbuntuESM,archive=${distro_codename}-infra-security"; + "origin=UbuntuESMApps,archive=${distro_codename}-apps-security"; +}; + +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 + + # Inject the desired reboot time without touching macro lines + sed -i "s/REBOOT_TIME_PLACEHOLDER/${REBOOT_TIME}/" /etc/apt/apt.conf.d/50unattended-upgrades +} + +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 the header line so you can see what matched + 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] 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 + 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. Regular + security updates will apply automatically; reboot at ${REBOOT_TIME} if needed." +} + +main "$@"