Skip to content

Instantly share code, notes, and snippets.

@loxK
Last active February 18, 2026 05:53
Show Gist options
  • Select an option

  • Save loxK/c4864e7bfa37e5e40dd034c53747a7f4 to your computer and use it in GitHub Desktop.

Select an option

Save loxK/c4864e7bfa37e5e40dd034c53747a7f4 to your computer and use it in GitHub Desktop.
Interactively enable unattended-upgrades for third-party APT sources.
#!/usr/bin/env bash
# Install unattended-upgrades-extra-origins to /usr/local/bin
# curl -fsSL https://gist.githubusercontent.com/loxK/c4864e7bfa37e5e40dd034c53747a7f4/raw/install.sh | sudo bash
set -euo pipefail
REPO="https://gist.githubusercontent.com/loxK/c4864e7bfa37e5e40dd034c53747a7f4/raw"
BIN_NAME="unattended-upgrades-extra-origins"
INSTALL_DIR="/usr/local/bin"
if [[ $EUID -ne 0 ]]; then
echo "Error: run as root or with sudo" >&2
exit 1
fi
echo "Installing ${BIN_NAME} to ${INSTALL_DIR}..."
curl -fsSL "${REPO}/${BIN_NAME}.sh" -o "${INSTALL_DIR}/${BIN_NAME}"
chmod +x "${INSTALL_DIR}/${BIN_NAME}"
echo "Done. Run: ${BIN_NAME} --help"
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# configure-unattended-origins.sh
#
# Copyright (C) 2025 Laurent Dinclaux <laurent@gecka.nc>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# ---------------------------------------------------------------------------
#
# Interactively enable unattended-upgrades for third-party APT sources.
# Scans .list and .sources files, resolves Origin/Suite from cached
# InRelease metadata, and writes an Origins-Pattern apt.conf.d snippet.
# Safe to re-run — only the content between marker comments is replaced.
#
# Usage: sudo ./configure-unattended-origins.sh [OPTIONS]
# --no-ansi Disable colored output
# --dry-run Preview without writing
# --output FILE Custom output path
# -y Accept all without prompting
# -h, --help Show help
# ---------------------------------------------------------------------------
set -euo pipefail
# ---- Defaults -------------------------------------------------------------
OUTPUT="/etc/apt/apt.conf.d/52unattended-upgrades-extra-origins"
BEGIN_MARKER="## generated content don't edit below this line"
END_MARKER="## end generated content"
NO_ANSI=false
DRY_RUN=false
YES_ALL=false
# ---- Functions -------------------------------------------------------------
usage() {
cat <<'EOF'
Usage: sudo ./configure-unattended-origins.sh [OPTIONS]
Enable unattended-upgrades for third-party APT sources (.list and .sources).
Options:
--no-ansi Disable colored output
--dry-run Preview without writing
--output FILE Custom output path
-y Accept all without prompting
-h, --help Show this help
EOF
}
setup_colors() {
if [[ "$NO_ANSI" == true ]] || [[ ! -t 1 ]]; then
BOLD="" RESET="" GREEN="" YELLOW="" CYAN="" RED="" DIM=""
else
BOLD=$'\033[1m' RESET=$'\033[0m'
GREEN=$'\033[32m' YELLOW=$'\033[33m'
CYAN=$'\033[36m' RED=$'\033[31m'
DIM=$'\033[2m'
fi
}
info() { echo -e "${CYAN}▸${RESET} $*"; }
ok() { echo -e "${GREEN}✔${RESET} $*"; }
warn() { echo -e "${YELLOW}⚠${RESET} $*"; }
error() { echo -e "${RED}✖${RESET} $*" >&2; }
# Normalize a URI the same way APT names its cache files:
# https://download.docker.com/linux/ubuntu -> download.docker.com_linux_ubuntu
uri_to_list_prefix() {
local uri="$1"
uri="${uri#*://}" # strip protocol
uri="${uri%/}" # strip trailing slash
echo "${uri//\//_}"
}
# Extract a top-level field from an InRelease file
inrelease_field() {
sed -n "s/^${1}: *//p" "$2" | head -1
}
# ---- Parse arguments -------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--no-ansi) NO_ANSI=true ;;
--dry-run) DRY_RUN=true ;;
--output) OUTPUT="${2:?--output requires a file path}"; shift ;;
-y) YES_ALL=true ;;
-h|--help) usage; exit 0 ;;
*) error "Unknown option: $1"; usage; exit 1 ;;
esac
shift
done
setup_colors
# ---- Preflight checks ------------------------------------------------------
if [[ "$DRY_RUN" == false && $EUID -ne 0 ]]; then
error "This script must be run as root (or use --dry-run to preview)."
exit 1
fi
if ! command -v lsb_release &>/dev/null; then
error "lsb_release is required. Install it with: apt install lsb-release"
exit 1
fi
DISTRO_ID=$(lsb_release -is)
DISTRO_CODENAME=$(lsb_release -cs)
info "Detected distribution: ${BOLD}${DISTRO_ID} ${DISTRO_CODENAME}${RESET}"
echo
# ---- Build a map of active third-party sources -----------------------------
# We parse both .list and .sources files to know which URI+suite combos are
# active and which file they come from. This map is then matched against the
# cached InRelease files for metadata extraction.
#
# Map key: "normalized_uri_prefix::suite"
# Map value: source filename (e.g. "docker.sources" or "ppa-example.list")
declare -A source_map
# Enable nullglob so that globs with no matches expand to nothing rather
# than being treated as literal strings.
shopt -s nullglob
# ---------- Parse traditional .list files -----------------------------------
for listfile in /etc/apt/sources.list.d/*.list; do
[[ -f "$listfile" ]] || continue
while IFS= read -r line; do
# Strip comments and leading whitespace
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
[[ -z "$line" ]] && continue
# Match: deb [options] URI suite [components...]
if [[ "$line" =~ ^deb[[:space:]] ]]; then
rest="${line#deb}"
rest="${rest#"${rest%%[![:space:]]*}"}"
# Skip optional [key=value ...] bracket block
if [[ "$rest" == "["* ]]; then
rest="${rest#*]}"
fi
rest="${rest#"${rest%%[![:space:]]*}"}"
read -r uri suite _components <<< "$rest"
[[ -z "$uri" || -z "$suite" ]] && continue
prefix=$(uri_to_list_prefix "$uri")
source_map["${prefix}::${suite}"]="$(basename "$listfile")"
fi
done < "$listfile"
done
# ---------- Parse DEB822 .sources files -------------------------------------
# Stanza-based format separated by blank lines. Relevant fields:
# Enabled: yes|no (default: yes)
# Types: deb [deb-src]
# URIs: https://... [https://...]
# Suites: suite1 [suite2 ...]
#
# Each field may contain multiple space-separated values; every combination
# of URI × Suite is registered as a separate source.
parse_sources_file() {
local file="$1"
local enabled="yes" types="" uris="" suites=""
# Flush the current stanza into source_map
flush_stanza() {
if [[ "$enabled" == "yes" && "$types" == *"deb"* && -n "$uris" && -n "$suites" ]]; then
for uri in $uris; do
for suite in $suites; do
local prefix
prefix=$(uri_to_list_prefix "$uri")
source_map["${prefix}::${suite}"]="$(basename "$file")"
done
done
fi
enabled="yes"; types=""; uris=""; suites=""
}
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip full-line comments
[[ "$line" =~ ^[[:space:]]*# ]] && continue
# Blank line = end of stanza
if [[ -z "${line// /}" ]]; then
flush_stanza
continue
fi
# Parse key: value (case-sensitive per DEB822 spec)
case "$line" in
Enabled:*) enabled="${line#Enabled:}"; enabled="${enabled#"${enabled%%[![:space:]]*}"}"; enabled="${enabled%% *}" ;;
Types:*) types="${line#Types:}" ;;
URIs:*) uris="${line#URIs:}" ;;
Suites:*) suites="${line#Suites:}" ;;
esac
done < "$file"
# Handle last stanza if file doesn't end with a blank line
flush_stanza
}
for srcfile in /etc/apt/sources.list.d/*.sources; do
[[ -f "$srcfile" ]] || continue
parse_sources_file "$srcfile"
done
if [[ ${#source_map[@]} -eq 0 ]]; then
warn "No third-party sources found in /etc/apt/sources.list.d/"
exit 0
fi
info "Scanning ${BOLD}${#source_map[@]}${RESET} active source entries across .list and .sources files"
echo
# ---- Match sources against InRelease metadata ------------------------------
# For each cached InRelease file, extract Origin/Suite/Label/Codename and
# see if it corresponds to one of the sources discovered above.
declare -A seen_keys
declare -a origin_keys=()
declare -A origin_display
declare -A os_count
# First pass: count origin:suite occurrences to detect when a label is needed
# to disambiguate (e.g. two PPAs from the same Launchpad owner)
for inrelease in /var/lib/apt/lists/*InRelease; do
[[ -f "$inrelease" ]] || continue
origin=$(inrelease_field Origin "$inrelease")
suite=$(inrelease_field Suite "$inrelease")
[[ -z "$origin" || -z "$suite" ]] && continue
# Skip base distribution and Ubuntu ESM (already in default config)
[[ "$origin" == "$DISTRO_ID" || "$origin" == UbuntuESM* ]] && continue
os_key="${origin}::${suite}"
os_count["$os_key"]=$(( ${os_count["$os_key"]:-0} + 1 ))
done
# Second pass: build the selectable list of origins
for inrelease in /var/lib/apt/lists/*InRelease; do
[[ -f "$inrelease" ]] || continue
origin=$(inrelease_field Origin "$inrelease")
suite=$(inrelease_field Suite "$inrelease")
label=$(inrelease_field Label "$inrelease")
codename=$(inrelease_field Codename "$inrelease")
[[ -z "$origin" || -z "$suite" ]] && continue
[[ "$origin" == "$DISTRO_ID" || "$origin" == UbuntuESM* ]] && continue
# Try to match this InRelease back to a source file in our map.
# The InRelease filename encodes the URI prefix and the suite, e.g.:
# download.docker.com_linux_ubuntu_dists_jammy_InRelease
basename_ir=$(basename "$inrelease")
source_file=""
for map_key in "${!source_map[@]}"; do
prefix="${map_key%%::*}"
map_suite="${map_key##*::}"
if [[ "$basename_ir" == "${prefix}_"* && "$basename_ir" == *"_${map_suite}_"* ]]; then
source_file="${source_map["$map_key"]}"
break
fi
done
# If we can't link it to a file in sources.list.d, skip it — it likely
# comes from /etc/apt/sources.list (base system) or is stale.
[[ -z "$source_file" ]] && continue
# Prefer codename for variable substitution (more precise than suite)
effective_suite="${codename:-$suite}"
# Substitute ${distro_codename} / ${distro_id} where applicable so the
# config survives distribution upgrades
if [[ "$effective_suite" == "$DISTRO_CODENAME" ]]; then
suite_val="\${distro_codename}"
else
suite_val="$effective_suite"
fi
origin_val="$origin"
# Build the Origins-Pattern value
os_key="${origin}::${effective_suite}"
pattern="o=${origin_val},a=${suite_val}"
# Include the label only when multiple repos share the same origin+suite
if [[ ${os_count["$os_key"]:-1} -gt 1 && -n "$label" ]]; then
pattern+=",l=${label}"
fi
# Deduplicate
if [[ -n "${seen_keys["$pattern"]+x}" ]]; then
continue
fi
seen_keys["$pattern"]=1
origin_keys+=("$pattern")
# Build a human-readable display block
desc=" ${BOLD}${origin}${RESET}"
desc+=" | Suite: ${BOLD}${effective_suite}${RESET}"
[[ -n "$label" ]] && desc+=" | Label: ${DIM}${label}${RESET}"
desc+=$'\n'" Source: ${DIM}${source_file}${RESET}"
origin_display["$pattern"]="$desc"
done
if [[ ${#origin_keys[@]} -eq 0 ]]; then
warn "No third-party InRelease metadata found."
warn "Make sure you have run ${BOLD}apt update${RESET} recently."
exit 0
fi
info "Found ${BOLD}${#origin_keys[@]}${RESET} third-party origin(s):"
echo
# ---- Interactive selection --------------------------------------------------
selected=()
for pattern in "${origin_keys[@]}"; do
echo -e "${origin_display["$pattern"]}"
echo -e " Pattern: ${DIM}\"${pattern}\"${RESET}"
if [[ "$YES_ALL" == true ]]; then
selected+=("$pattern")
ok "Enabled (auto)"
else
while true; do
read -rp " Enable automatic updates? [y/N] " answer
case "${answer,,}" in
y|yes) selected+=("$pattern"); ok "Enabled"; break ;;
n|no|"") warn "Skipped"; break ;;
*) echo " Please answer y or n." ;;
esac
done
fi
echo
done
# ---- Generate configuration ------------------------------------------------
if [[ ${#selected[@]} -eq 0 ]]; then
warn "No sources selected — nothing to write."
exit 0
fi
generated=""
generated+='Unattended-Upgrade::Origins-Pattern {'$'\n'
for pattern in "${selected[@]}"; do
generated+=" \"${pattern}\";"$'\n'
done
generated+="};"
# ---- Dry-run output --------------------------------------------------------
if [[ "$DRY_RUN" == true ]]; then
echo
info "Dry-run mode — configuration that would be written to ${BOLD}${OUTPUT}${RESET}:"
echo
echo "$BEGIN_MARKER"
echo "$generated"
echo "$END_MARKER"
exit 0
fi
# ---- Write to file ---------------------------------------------------------
write_between_markers() {
local file="$1" content="$2"
local tmpfile
tmpfile=$(mktemp)
awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" -v replacement="$content" '
BEGIN { inside = 0 }
index($0, begin) {
print
print replacement
inside = 1
next
}
inside && index($0, end) {
print
inside = 0
next
}
!inside { print }
' "$file" > "$tmpfile"
mv "$tmpfile" "$file"
}
if [[ -f "$OUTPUT" ]] && grep -qF "$BEGIN_MARKER" "$OUTPUT"; then
write_between_markers "$OUTPUT" "$generated"
ok "Updated existing configuration in ${BOLD}${OUTPUT}${RESET}"
else
{
[[ -f "$OUTPUT" ]] && echo
echo "$BEGIN_MARKER"
echo "$generated"
echo "$END_MARKER"
} >> "$OUTPUT"
ok "Wrote new configuration to ${BOLD}${OUTPUT}${RESET}"
fi
echo
info "Verifying configuration..."
echo
# Show the active origins-pattern entries from the file we just wrote
if command -v unattended-upgrades &>/dev/null; then
# Ask unattended-upgrades which packages it would upgrade, filtered to
# only show lines relevant to our newly configured origins.
dry_output=$(unattended-upgrades --dry-run -d 2>&1 || true)
# Show which of our patterns are recognized
matched=0
for pattern in "${selected[@]}"; do
# Extract the origin name from the pattern (o=VALUE)
pat_origin="${pattern#*o=}"
pat_origin="${pat_origin%%,*}"
# Replace variable placeholders for matching
pat_origin="${pat_origin//\$\{distro_id\}/$DISTRO_ID}"
pkg_count=$(echo "$dry_output" | grep -ciF "$pat_origin" || true)
if [[ "$pkg_count" -gt 0 ]]; then
ok "${pattern} → ${GREEN}recognized${RESET} (${pkg_count} matching log entries)"
matched=$((matched + 1))
else
warn "${pattern} → ${YELLOW}no packages matched${RESET} (repo may be up to date)"
fi
done
echo
# Show upgradable packages if any
upgradable=$(echo "$dry_output" | grep -i "packages that will be upgraded" || true)
if [[ -n "$upgradable" ]]; then
info "$upgradable"
else
info "All packages from selected origins are up to date."
fi
else
warn "unattended-upgrades is not installed — skipping verification."
info "Install it with: ${BOLD}apt install unattended-upgrades${RESET}"
fi
echo
info "Configuration written to: ${BOLD}${OUTPUT}${RESET}"
info "For full debug output, run: ${DIM}unattended-upgrades --dry-run -d${RESET}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment