231 lines
7.2 KiB
Bash
231 lines
7.2 KiB
Bash
#!/usr/bin/env bash
|
||
#
|
||
# setup-auto-updates.sh
|
||
#
|
||
# Universal unattended-upgrades setup for Debian & Ubuntu.
|
||
# - Uses Origins-Pattern (not legacy Allowed-Origins) for forward-compatibility
|
||
# - Works with sources.list and deb822 .sources
|
||
# - Enables security + updates pockets; optional Ubuntu ESM if present
|
||
# - Validates with a dry-run; resilient to apt/dpkg locks
|
||
#
|
||
# Notes:
|
||
# * Backports/proposed are intentionally not enabled.
|
||
# * On non-systemd hosts, APT::Periodic drives execution via cron.
|
||
|
||
set -Eeuo pipefail
|
||
trap 'echo "[ERROR] Line $LINENO failed" >&2' ERR
|
||
|
||
REBOOT_TIME="${REBOOT_TIME:-04:00}"
|
||
|
||
OS=""
|
||
CODENAME=""
|
||
declare -a POLICY_PAIRS=() # "Origin:Archive" from apt-cache
|
||
declare -a ORIGIN_PATTERNS=() # "origin=...,archive=..."
|
||
|
||
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
|
||
case "${ID,,}" in
|
||
debian) OS="debian" ;;
|
||
ubuntu) OS="ubuntu" ;;
|
||
*) [[ "${ID_LIKE:-}" =~ debian ]] && OS="debian" || { echo "[ERROR] Unsupported OS: ${ID}."; exit 1; } ;;
|
||
esac
|
||
|
||
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
|
||
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}"
|
||
}
|
||
|
||
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
|
||
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
|
||
}
|
||
|
||
extract_policy_pairs() {
|
||
# Extract unique Origin:Archive pairs from apt-cache policy lines
|
||
mapfile -t POLICY_PAIRS < <(
|
||
apt-cache policy 2>/dev/null \
|
||
| sed -n 's/.*o=\([^,]*\),a=\([^,]*\).*/\1:\2/p' \
|
||
| awk 'NF' | sort -u
|
||
)
|
||
}
|
||
|
||
build_origin_patterns() {
|
||
ORIGIN_PATTERNS=()
|
||
|
||
have_pair() { printf '%s\n' "${POLICY_PAIRS[@]}" | grep -Fxq -- "$1"; }
|
||
add_pat() { ORIGIN_PATTERNS+=("origin=$1,archive=$2"); }
|
||
|
||
if [[ "$OS" == "debian" ]]; then
|
||
# Consider common Debian suites (codename/stable/testing variants)
|
||
local want=( "$CODENAME" "$CODENAME-updates" "$CODENAME-security"
|
||
stable stable-updates stable-security
|
||
testing testing-security )
|
||
for a in "${want[@]}"; do
|
||
have_pair "Debian:$a" && add_pat "Debian" "$a"
|
||
done
|
||
# Fallback if apt-cache didn’t show anything yet
|
||
if [[ ${#ORIGIN_PATTERNS[@]} -eq 0 ]]; then
|
||
ORIGIN_PATTERNS+=("origin=Debian,archive=$CODENAME"
|
||
"origin=Debian,archive=${CODENAME}-updates"
|
||
"origin=Debian,archive=${CODENAME}-security")
|
||
fi
|
||
else # ubuntu
|
||
# Standard pockets
|
||
for a in "$CODENAME" "${CODENAME}-updates" "${CODENAME}-security"; do
|
||
have_pair "Ubuntu:$a" && add_pat "Ubuntu" "$a"
|
||
done
|
||
# Optional ESM, only if attached and present
|
||
have_pair "UbuntuESM:${CODENAME}-infra-security" && add_pat "UbuntuESM" "${CODENAME}-infra-security"
|
||
have_pair "UbuntuESMApps:${CODENAME}-apps-security" && add_pat "UbuntuESMApps" "${CODENAME}-apps-security"
|
||
|
||
if [[ ${#ORIGIN_PATTERNS[@]} -eq 0 ]]; then
|
||
ORIGIN_PATTERNS+=("origin=Ubuntu,archive=$CODENAME"
|
||
"origin=Ubuntu,archive=${CODENAME}-updates"
|
||
"origin=Ubuntu,archive=${CODENAME}-security")
|
||
fi
|
||
fi
|
||
|
||
echo "[INFO] Origins-Pattern to be written:"
|
||
printf ' %s\n' "${ORIGIN_PATTERNS[@]}"
|
||
}
|
||
|
||
write_50unattended() {
|
||
local patterns=""
|
||
for p in "${ORIGIN_PATTERNS[@]}"; do
|
||
patterns+=" \"$p\";\n"
|
||
done
|
||
|
||
cat > /etc/apt/apt.conf.d/50unattended-upgrades <<EOF
|
||
// Auto-installed by setup-auto-updates.sh
|
||
// Use Origins-Pattern (modern) and explicitly clear legacy Allowed-Origins.
|
||
|
||
// Clear legacy stanzas to avoid legacy parser bugs.
|
||
#clear Unattended-Upgrade::Allowed-Origins;
|
||
#clear Unattended-Upgrade::Origins-Pattern;
|
||
|
||
Unattended-Upgrade::Origins-Pattern {
|
||
${patterns}
|
||
};
|
||
|
||
// Reboot policy
|
||
Unattended-Upgrade::Automatic-Reboot "true";
|
||
Unattended-Upgrade::Automatic-Reboot-Time "${REBOOT_TIME}";
|
||
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
|
||
|
||
// Cleanups
|
||
Unattended-Upgrade::Remove-Unused-Dependencies "true";
|
||
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
|
||
|
||
// Logging
|
||
Unattended-Upgrade::SyslogEnable "true";
|
||
Unattended-Upgrade::Verbose "0";
|
||
EOF
|
||
}
|
||
|
||
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
|
||
grep -E "Allowed origins are" "$log" | head -n1 || true
|
||
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
|
||
extract_policy_pairs
|
||
build_origin_patterns
|
||
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; normal timers/periodic will still run. Inspect the log above if needed."
|
||
fi
|
||
|
||
show_status
|
||
echo
|
||
echo "[OK] Unattended updates configured. Regular + security updates will apply automatically; reboot at ${REBOOT_TIME} if needed."
|
||
}
|
||
|
||
main "$@"
|