Last active
February 18, 2026 05:53
-
-
Save loxK/c4864e7bfa37e5e40dd034c53747a7f4 to your computer and use it in GitHub Desktop.
Interactively enable unattended-upgrades for third-party APT sources.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 replacement | |
| inside = 1 | |
| next | |
| } | |
| inside && index($0, end) { | |
| 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