256 lines
8.3 KiB
Bash
256 lines
8.3 KiB
Bash
#!/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 "$@"
|