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"; }