Wren Linux

wren-linux / lib / apt.sh

# shellcheck shell=bash
# lib/apt.sh: installing and removing packages, and third-party apt
# repositories. Sourced by lib.sh.

# Installing and removing packages without breaking each other.
#
# Several scripts need the same packages (QEMU for Whonix and mockvps-kvm,
# XWayland for every X11 app). So a script's packages are not installed
# directly: apt_install builds a tiny package, wren-NAME, that does
# nothing but depend on what NAME asked for, and installs that. The packages
# come in as its dependencies, which apt marks "automatic". apt_purge then
# removes wren-NAME and lets apt autoremove whatever nothing else needs:
# a package another script's marker (or you, with apt-mark manual) still
# needs is kept. NAME is the script's own name, or $WREN_OWNER.
# The marker also records, in its own field Wren-Brought, which of those
# packages weren't installed before the script asked for them: only those can
# be purged beyond what apt autoremoves. Debian's installer marks many of its
# own packages automatic too (gpgv, zstd), and a script that merely needed one
# must not take it away. A package another marker brought is recorded by each
# script that asks for it, so the last of them takes it away.

# apt_marker: the marker package for the running script
apt_marker() {
    local name=${WREN_OWNER:-$(basename -- "$0" .sh)}
    name=$(printf '%s' "$name" | tr 'A-Z_' 'a-z-' | tr -cd 'a-z0-9.+-')
    printf 'wren-%s\n' "${name:-script}"
}

# marker_depends MARKER: the packages an installed MARKER depends on
marker_depends() {
    dpkg-query -W -f='${Depends}' "$1" 2>/dev/null | tr ',' '\n' |
        sed 's/^ *//; s/ *(.*//; s/ *$//' | grep . || true
}

# marker_brought MARKER: the packages an installed MARKER brought in (that weren't
# installed before its script asked for them), one a line ("-" alone: none)
marker_brought() {
    dpkg-query -W -f='${Wren-Brought}' "$1" 2>/dev/null | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -vx -- '-' | grep . || true
}
# marker_has_record MARKER: it has Wren-Brought at all (one built before the field has none)
marker_has_record() { [ -n "$(dpkg-query -W -f='${Wren-Brought}' "$1" 2>/dev/null)" ]; }
# brought_by_other_marker PACKAGE: another installed Wren marker brought it in. A script asking for
# it then records it too, so whichever of them goes last may take it away.
brought_by_other_marker() {
    local m
    while read -r m; do
        [ "$m" != "$(apt_marker)" ] || continue
        if marker_brought "$m" | grep -qxF -- "$1"; then return 0; fi
    done < <(dpkg-query -W -f='${db:Status-Abbrev} ${Package}\n' 'wren-*' 2>/dev/null | awk '$1 == "ii" { print $2 }')
    return 1
}

# marker_build MARKER DIR PACKAGE...: writes DIR/MARKER.deb, an empty
# package that depends on the PACKAGEs (and records MARKER_BROUGHT, if set,
# as its Wren-Brought field)
marker_build() {
    local marker=$1 dir=$2 root deps
    shift 2
    deps=$(printf '%s\n' "$@" | sort -u | paste -sd, - | sed 's/,/, /g')
    root=$(mktemp -d)
    mkdir -p "$root/DEBIAN"
    # dpkg-deb refuses a control folder others can't read, and with the private
    # umask (parts/home-private.sh: 077) a new folder is yours alone
    chmod 0755 "$root" "$root/DEBIAN"
    {
        echo "Package: $marker"
        # Newer than any earlier one, so a second call replaces the first
        echo "Version: 0.$(date +%s%N)"
        echo "Architecture: all"
        echo "Maintainer: Wren Linux <wren@localhost>"
        echo "Section: misc"
        echo "Priority: optional"
        echo "Depends: $deps"
        # (always written, "-" for none: no field at all means a marker from before it)
        echo "Wren-Brought: ${MARKER_BROUGHT:--}"
        echo "Description: what ${marker#wren-} installed, written by Wren Linux"
        echo " Depends on the packages that Wren Linux's ${marker#wren-} script"
        echo " installed. Removing this package (or running that script with"
        echo " --remove) lets apt autoremove the ones nothing else needs."
    } >"$root/DEBIAN/control"
    dpkg-deb --root-owner-group --build "$root" "$dir/$marker.deb" >/dev/null
    # apt reads it as its own user, _apt
    chmod 0644 "$dir/$marker.deb"
    rm -rf "$root"
}

# apt_drop PACKAGE...: this script no longer wants these (it switched to
# another package that conflicts with them). They are taken out of its
# marker's list the next time apt_install runs.
apt_drop() { APT_DROP+=("$@"); }
APT_DROP=()

# apt_install PACKAGE|FILE.deb...: installs them through the running
# script's marker package. A .deb file is installed first (the marker then
# depends on the package inside it). PipeWire's packages (a sandbox's own sound
# server) are followed by user_audio_sync, every time, whichever script asks:
# Debian would otherwise start PipeWire for every user (lib/core.sh).
apt_install() {
    local a
    for a in "$@"; do
        case $a in pipewire | pipewire-bin | pipewire-pulse | wireplumber) _apt_install "$@"; user_audio_sync; return ;; esac
    done
    _apt_install "$@"
}
_apt_install() {
    local marker p name pkgs=() debs=() prev=() add=() all=() new=() brought=() tmp need_net=false stale=false
    marker=$(apt_marker)
    for p in "$@"; do
        if [[ $p == *.deb && -f $p ]]; then debs+=("$p"); else pkgs+=("$p"); fi
    done
    if is_installed "$marker"; then
        mapfile -t prev < <(marker_depends "$marker")
        mapfile -t brought < <(marker_brought "$marker")
    fi
    if [ ${#APT_DROP[@]} -gt 0 ] && [ ${#prev[@]} -gt 0 ]; then
        mapfile -t prev < <(printf '%s\n' "${prev[@]}" | grep -vxF -f <(printf '%s\n' "${APT_DROP[@]}") || true)
        # Even if nothing new is asked for, the marker must lose them
        stale=true
    fi
    for p in "${pkgs[@]}"; do
        if ! printf '%s\n' "${prev[@]}" | grep -qxF -- "$p"; then
            new+=("$p")
            # not there before: this script brings it, and may take it away again
            if ! is_installed "$p"; then
                need_net=true
                brought+=("$p")
            elif brought_by_other_marker "$p"; then
                brought+=("$p")
            fi
        fi
    done
    # Nothing new, so the marker already holds everything
    if [ ${#new[@]} -eq 0 ] && [ ${#debs[@]} -eq 0 ] && ! $stale; then return 0; fi

    if $need_net || [ ${#debs[@]} -gt 0 ]; then
        have_internet || die "No internet connection. Connect first: bash $WREN_DIR/wifi.sh"
        if [ -z "${WREN_APT_UPDATED:-}" ]; then
            $SUDO apt-get update
            export WREN_APT_UPDATED=1
        fi
    fi
    if [ ${#debs[@]} -gt 0 ]; then
        for p in "${debs[@]}"; do
            name=$(dpkg-deb -f "$p" Package)
            add+=("$name")
            # (asked before it is installed: after, every one is)
            is_installed "$name" || brought+=("$name")
        done
        $SUDO apt-get install -y --no-install-recommends "${debs[@]}"
    fi

    all=("${prev[@]}" "${pkgs[@]}" "${add[@]}")
    tmp=$(mktemp -d)
    # apt reads the file as its own user, which can't enter a private folder
    chmod 755 "$tmp"
    MARKER_BROUGHT=$(printf '%s\n' "${brought[@]}" | grep . | sort -u | paste -sd, - | sed 's/,/, /g') \
        marker_build "$marker" "$tmp" "${all[@]}"
    $SUDO apt-get install -y --no-install-recommends "$tmp/$marker.deb"
    rm -rf "$tmp"
    # A .deb installed from a file is "manual" to apt; make it automatic so
    # that removing the marker removes it. Packages that were already
    # installed stay as they are: they may belong to a script that installed
    # them before markers existed, and removing one script must not take
    # them from another.
    if [ ${#add[@]} -gt 0 ]; then $SUDO apt-mark auto "${add[@]}" >/dev/null; fi
}

# need_tools WHAT:PACKAGE...: what a script needs before it starts. WHAT is a
# command (gpgv) or a file (/usr/share/keyrings/debian-archive-keyring.gpg);
# the PACKAGEs of the missing ones are installed now, through the script's
# marker package (apt_install), instead of the script stopping half way with
# "install it yourself". Asks for the sudo password only if something is missing.
need_tools() {
    local t what pkg missing=()
    for t in "$@"; do
        what=${t%:*} pkg=${t##*:}
        case $what in
            /*) [ -e "$what" ] && continue ;;
            *) command -v "$what" >/dev/null && continue ;;
        esac
        case " ${missing[*]} " in *" $pkg "*) ;; *) missing+=("$pkg") ;; esac
    done
    [ ${#missing[@]} -gt 0 ] || return 0
    say "Installing what this needs first: ${missing[*]}"
    apt_install "${missing[@]}"
}

# apt_purge PACKAGE...: removes what the running script installed with
# apt_install, except what other Wren Linux scripts still need. PACKAGEs are
# used only when this script has no marker: it installed its packages before
# markers existed, or an earlier --remove purged the marker and was cut off.
apt_purge() {
    local marker p other pkgs=() left=() brought=() recorded=false
    marker=$(apt_marker)
    if is_installed "$marker"; then
        # (read before the marker goes: after, dpkg has none of its fields)
        mapfile -t pkgs < <(marker_depends "$marker")
        mapfile -t brought < <(marker_brought "$marker")
        if marker_has_record "$marker"; then recorded=true; fi
        $SUDO apt-get purge -y "$marker"
        $SUDO apt-get autoremove --purge -y
        # autoremove keeps what an installed package only suggests (man-db
        # suggests a www-browser, so w3m and Firefox stay). Take out what
        # this script brought in (its marker's Wren-Brought: not what was
        # installed before it asked, automatic or not; for a marker from before
        # that field, see below), unless another script wants it or an
        # installed package depends on it.
        left=()
        for p in "${pkgs[@]}"; do
            is_installed "$p" || continue
            if $recorded; then
                printf '%s\n' "${brought[@]}" | grep -qxF -- "$p" || continue
            else
                # A marker from before the record: what it brought isn't known. Then what
                # Debian counts as its base system stays (priority required, important or
                # standard: gpgv), and so does what an installed package recommends (zstd,
                # for initramfs-tools): what was there before, most likely
                case $(dpkg-query -W -f='${Priority}' "$p" 2>/dev/null) in required | important | standard) continue ;; esac
                [ -z "$(apt-cache rdepends --installed --no-depends --no-suggests --no-conflicts \
                    --no-breaks --no-replaces --no-enhances "$p" 2>/dev/null | tail -n +3)" ] || continue
            fi
            apt-mark showauto "$p" | grep -qxF -- "$p" || continue
            [ -z "$(apt_needed_by_marker "$p")" ] || continue
            [ -z "$(apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts \
                --no-breaks --no-replaces --no-enhances "$p" 2>/dev/null | tail -n +3)" ] || continue
            left+=("$p")
        done
        if [ ${#left[@]} -gt 0 ]; then
            $SUDO apt-get purge -y "${left[@]}"
            $SUDO apt-get autoremove --purge -y
        fi
        return 0
    fi
    for p in "$@"; do
        is_installed "$p" || continue
        other=$(apt_needed_by_marker "$p")
        # apt purges whatever depends on a package along with it (purging
        # openssl takes ca-certificates, and with it every https download):
        # keep one that an installed package outside this list depends on
        [ -n "$other" ] || other=$(apt_installed_dependent "$p" "$@")
        if [ -n "$other" ]; then
            note "Keeping $p: $other needs it."
        else
            pkgs+=("$p")
        fi
    done
    if [ ${#pkgs[@]} -gt 0 ]; then
        $SUDO apt-get purge -y "${pkgs[@]}"
        $SUDO apt-get autoremove --purge -y
    elif [ $# -eq 0 ]; then
        note "No packages were recorded for this script, so none are removed."
    fi
}

# apt_installed_dependent PACKAGE [IGNORE...]: the first installed package,
# other than the IGNOREd ones, that depends on PACKAGE
apt_installed_dependent() {
    local p=$1 d i
    shift
    while read -r d; do
        d=${d#|}
        for i in "$@"; do [ "$d" != "$i" ] || continue 2; done
        echo "$d"
        return 0
    done < <(apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts \
        --no-breaks --no-replaces --no-enhances "$p" 2>/dev/null | tail -n +3)
}

# apt_needed_by_marker PACKAGE: the first installed marker that depends on it
apt_needed_by_marker() {
    local m
    while read -r m; do
        if marker_depends "$m" | grep -qxF -- "$1"; then
            echo "${m#wren-}"
            return 0
        fi
    done < <(dpkg-query -W -f='${db:Status-Abbrev} ${Package}\n' 'wren-*' 2>/dev/null |
        awk '$1 == "ii" { print $2 }')
}

# apt_sources_ok DIR: an active (non-comment, no cdrom, no local file) line
# names the release in DIR's sources.list or .list/.sources files, so apt
# can fetch it
apt_sources_ok() {
    local cn=${VERSION_CODENAME:-}
    [ -n "$cn" ] || return 1
    { cat "$1/sources.list" 2>/dev/null
      cat "$1/sources.list.d/"*.list "$1/sources.list.d/"*.sources 2>/dev/null
    } | grep -vE '^\s*(#|$)|cdrom:|file:' | grep -q -- "$cn"
}

# apt_sources_ensure: an install without a network leaves no Debian sources
# (the installer's cdrom lines get commented out at the end), and a mirror
# question can be answered with none. Before packages are downloaded: if
# nothing in /etc/apt names the running release, add deb.debian.org's lines.
# http unless ca-certificates is already there, so the first apt-get works.
# (WREN_APT_DIR is where /etc/apt is, for tests.)
apt_sources_ensure() {
    local dir=${WREN_APT_DIR:-/etc/apt} proto=https src
    [ -n "${VERSION_CODENAME:-}" ] || { warn "No VERSION_CODENAME: not touching apt sources."; return 0; }
    is_installed ca-certificates || proto=http
    if apt_sources_ok "$dir"; then
        note "apt sources already name $VERSION_CODENAME."
        return 0
    fi
    src="deb $proto://deb.debian.org/debian $VERSION_CODENAME main non-free-firmware
deb $proto://deb.debian.org/debian $VERSION_CODENAME-updates main non-free-firmware
deb $proto://security.debian.org/debian-security $VERSION_CODENAME-security main non-free-firmware"
    printf '%s\n' "$src" | $SUDO tee -a "$dir/sources.list" >/dev/null
    note "Added deb.debian.org's sources for $VERSION_CODENAME (deb-src lines are left to you)."
}

# apt_early_upgrade: Debian's updates since the image was made, before anything is installed on top
# of them: a fresh install is as old as its image, and fixes published since are the ones people
# are attacked with. Online only; offline it says so and goes on, and so does a failure (a mirror
# down is not a reason to stop). Asks nothing: a settings file you changed is kept as it is. A kernel
# newer than the running one sets WREN_NEW_KERNEL=yes (a reboot is due; install-base.sh says so at the
# end). (WREN_MODULES is where the kernels' folders are, for tests.)
apt_early_upgrade() {
    local newest
    if [ -z "$(ip route show default 2>/dev/null)$(ip -6 route show default 2>/dev/null)" ]; then
        note "No network yet: Debian's updates since this image was made are not installed. Run: sudo apt update && sudo apt upgrade"
        return 0
    fi
    if ! $SUDO apt-get update; then
        warn "Couldn't fetch the package lists, so Debian's updates since this image was made are not installed."
        return 0
    fi
    if ! $SUDO env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade; then
        warn "The upgrade didn't finish. Run later: sudo apt upgrade"
        return 0
    fi
    # shellcheck disable=SC2012  # kernel versions: plain names
    newest=$(ls "${WREN_MODULES:-/lib/modules}" 2>/dev/null | sort -V | tail -n1)
    if [ -n "$newest" ] && [ "$newest" != "$(uname -r)" ]; then
        WREN_NEW_KERNEL=yes
        note "A newer kernel came in ($newest; this one is $(uname -r)): reboot when this install is done."
    fi
}

# key_ok FILE FPR: FILE holds exactly one key, it is FPR, and it hasn't
# expired or been revoked. apt trusts every key in a signed-by file, so a
# file with a second key next to the pinned one must not pass. It must also
# still be able to sign: the key itself, or a subkey of it that hasn't
# expired (the Tor Project's signs with a subkey that expires every few
# years; with that one expired, the next run fetches the renewed key).
key_ok() {
    local out
    out=$(gpg --show-keys --with-colons "$1" 2>/dev/null) || return 1
    [ "$(grep -c '^pub:' <<<"$out")" -eq 1 ] || return 1
    [ "$(awk -F: '/^fpr:/ { print $10; exit }' <<<"$out")" = "$2" ] || return 1
    awk -F: '/^pub:/ { exit ($2 ~ /[er]/) }' <<<"$out" || return 1
    awk -F: '/^(pub|sub):/ && $2 !~ /[erd]/ && $12 ~ /s/ { ok = 1 } END { exit !ok }' <<<"$out"
}

# install_apt_key FPR URL DEST: the signing key at URL, into DEST (for
# signed-by=), only if it is exactly the key with fingerprint FPR. A key
# already in DEST is checked again, and replaced if it no longer passes
# (expired, rotated, or tampered with).
install_apt_key() {
    local fpr=$1 url=$2 dest=$3 tmp
    command -v gpg >/dev/null || apt_install gpg
    if [ -s "$dest" ] && key_ok "$dest" "$fpr"; then return 0; fi
    tmp=$(mktemp)
    if ! wget -qO "$tmp" "$url"; then
        rm -f "$tmp"
        die "Couldn't download the signing key from $url"
    fi
    if ! key_ok "$tmp" "$fpr"; then
        rm -f "$tmp"
        die "The key at $url isn't exactly the pinned key $fpr (or it has expired). Not trusting it."
    fi
    $SUDO install -D -m 0644 "$tmp" "$dest"
    rm -f "$tmp"
    note "Key verified: $fpr"
}

# allow_unattended_updates NAME SITE: unattended-upgrades also installs the
# updates from the apt repository at SITE. By default it only takes
# Debian's, which would leave NAME's packages without security fixes.
allow_unattended_updates() {
    printf '%s\n' "// Written by Wren Linux ($1)" \
        "Unattended-Upgrade::Origins-Pattern:: \"site=$2\";" |
        $SUDO tee "/etc/apt/apt.conf.d/52wren-$1" >/dev/null
    note "Updates from $2 install themselves too."
}

forget_unattended_updates() { $SUDO rm -f "/etc/apt/apt.conf.d/52wren-$1"; }