diff --git a/bin/secupdates-ask.py b/bin/secupdates-ask.py index 8cf951c..9cb007a 100755 --- a/bin/secupdates-ask.py +++ b/bin/secupdates-ask.py @@ -53,7 +53,7 @@ def main(): try: subprocess.run( - ["host", "-W", "2", "archive.turnkeylinux.org"], + ["host", "-W", "2", "security.debian.org"], check=True, ) except subprocess.CalledProcessError: diff --git a/debian/changelog b/debian/changelog index 9a786b7..2c0dfd0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,34 @@ +inithooks (2.3.6+keel18) trixie; urgency=medium + + * 95secupdates reads its updates from + /etc/apt/sources.list.d/security.sources, Debian's security archive, + the file common's conf/bootstrap_apt now writes. It named + security.sources.sources, the file TurnKey's bootstrap wrote with an + archive.turnkeylinux.org stanza in it, so the first boot of a Keel image + fetched from the TurnKey archive. The path can be set with + SEC_UPDATES_SOURCES, for tests/test-secupdates.bats. + * 95secupdates fails when that file is missing, logged and in its log. + apt reads a missing sourcelist as an empty one, so the upgrade used to + succeed having installed nothing. + * secupdates-ask.py checks it can resolve security.debian.org, where the + updates come from, instead of archive.turnkeylinux.org. + * 95secupdates runs under pipefail. dist-upgrade, dpkg --configure and + apt-get update are piped into tee for the log, and a failure of any of + them used to end as tee's success. The listing of /lib/modules and + /boot no longer fails the hook where /boot does not exist. + * Offline, 95secupdates says so and the first boot goes on. Before + installing it fetches the InRelease of the security source with curl + (now a dependency); when that or apt-get update fails, it logs + "cannot reach ..." or "apt-get update failed" to the system log and to + its own log, names turnkey-install-security-updates, and exits 0. Under + -e an offline apt-get update used to stop the hook with nothing logged. + * force is recorded only after the install succeeded, not before it. + An install that failed, or never ran because the machine was offline, + leaves no record, so keel inspect does not report updates that were + never applied. + + -- Marcos Mendez Fri, 02 Oct 2026 17:00:00 +0000 + inithooks (2.3.6+keel17) trixie; urgency=medium * The console no longer looks frozen between first boot screens. On a diff --git a/debian/control b/debian/control index e82db2b..2df5475 100644 --- a/debian/control +++ b/debian/control @@ -12,6 +12,7 @@ Standards-Version: 4.7.2 Package: inithooks Architecture: all Depends: + curl, kbd, ${misc:Depends}, ${python3:Depends}, diff --git a/firstboot.d/95secupdates b/firstboot.d/95secupdates index d0e7e95..037cb2f 100755 --- a/firstboot.d/95secupdates +++ b/firstboot.d/95secupdates @@ -2,6 +2,9 @@ # install security updates # SEC_UPDATES: SKIP, FORCE (if none specified, will be interactive) +# every '| tee' below must carry the exit status of what it logs +set -o pipefail + INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" # shellcheck source=default/inithooks source "$INITHOOKS_DEFAULT" @@ -19,6 +22,10 @@ SEC_UPDATES="${SEC_UPDATES,,}" # keel inspect reads this line to report security.updates_at_first_boot. SEC_UPDATES_RECORD="${SEC_UPDATES_RECORD:-/var/lib/inithooks/sec-updates}" SEC_UPDATES_LOG="${SEC_UPDATES_LOG:-/var/log/inithooks/secupdates.log}" +# The one source the upgrade reads: Debian's security archive, written by +# common's conf/bootstrap_apt. Every other source is left out on purpose, +# so the first boot installs security fixes and nothing else. +SEC_UPDATES_SOURCES="${SEC_UPDATES_SOURCES:-/etc/apt/sources.list.d/security.sources}" record() { if ! { mkdir -p "$(dirname "$SEC_UPDATES_RECORD")" \ @@ -28,6 +35,34 @@ record() { fi } +# The listing of the kernel modules and /boot, to tell whether the upgrade +# installed a kernel and the machine needs a reboot. Containers have no +# /boot: ls then fails, which says nothing about the upgrade. +# +# SC2012: ls is wanted here for its detailed listing, not to iterate. +# shellcheck disable=SC2012 +modules_and_boot() { + ls -la /lib/modules /boot 2>/dev/null || true +} + +# offline MESSAGE: no update can be fetched. Said in the system log and in +# the hook's own log, and the first boot goes on: a machine with no network +# yet is not a broken one. Nothing is recorded, since nothing was installed. +offline() { + local msg="[95secupdates] $1; security updates not installed, run turnkey-install-security-updates once the network is up" + logger -t inithooks -p warn "$msg" + echo "WARNING: $msg" >> "$LOGFILE" + exit 0 +} + +# the InRelease of the first stanza of the security source +security_release_url() { + local uri suite + uri=$(awk '/^URIs:/ { print $2; exit }' "$SEC_UPDATES_SOURCES") + suite=$(awk '/^Suites:/ { print $2; exit }' "$SEC_UPDATES_SOURCES") + echo "${uri%/}/dists/$suite/InRelease" +} + install_updates() { # if registered with hub, update with status if grep SERVERID= /var/lib/hubclient/server.conf -q -s; then @@ -36,13 +71,20 @@ install_updates() { LOGFILE=$SEC_UPDATES_LOG mkdir -p "$(dirname "$LOGFILE")" - # 'ls' stderr is suppressed as containers don't have the checked paths. If - # any other errors occur we've got much bigger problems! - # SC2012 is a shellcheck warning re use of 'ls'. 'ls' used to provide - # detailed filesystem info which will highlight changes requiring reboot. - # - # shellcheck disable=SC2012 - OLDMD5=$(ls -la /lib/modules /boot 2>/dev/null | md5sum) + # apt reads a missing sourcelist as an empty one: the upgrade would + # succeed, install nothing and the boot would go on as if it had + if [[ ! -f "$SEC_UPDATES_SOURCES" ]]; then + msg="[95secupdates] no security source at $SEC_UPDATES_SOURCES" + logger -t inithooks -p err "$msg" + echo "ERROR: $msg" >> "$LOGFILE" + exit 1 + fi + local release + release=$(security_release_url) + if ! curl -fsS --max-time 15 -o /dev/null "$release" 2>/dev/null; then + offline "cannot reach ${release%/dists/*}" + fi + OLDMD5=$(modules_and_boot | md5sum) if [[ -n "$(dpkg --audit 2>/dev/null)" ]]; then msg="[95secupdates] dpkg in an inconsistent state (see $LOGFILE)" logger -t inithooks -p warn "$msg" @@ -51,18 +93,18 @@ install_updates() { fi DEBIAN_FRONTEND=noninteractive dpkg --force-confdef --force-confold \ --configure -a 2>&1 | tee -a "$LOGFILE" - apt-get update + if ! apt-get update 2>&1 | tee -a "$LOGFILE"; then + offline "apt-get update failed (see $LOGFILE)" + fi DEBIAN_FRONTEND=noninteractive apt-get autoclean -y DEBIAN_FRONTEND=noninteractive apt-get dist-upgrade -y \ -o APT::Get::Show-Upgraded=true \ -o Dir::Etc::sourceparts=/dev/null \ - -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/security.sources.sources \ + -o Dir::Etc::sourcelist="$SEC_UPDATES_SOURCES" \ -o DPkg::Options::=--force-confdef \ -o DPkg::Options::=--force-confold | tee -a "$LOGFILE" - # per above note re containers - # shellcheck disable=SC2012 - NEWMD5=$(ls -la /lib/modules /boot 2>/dev/null | md5sum) + NEWMD5=$(modules_and_boot | md5sum) if [[ "$NEWMD5" != "$OLDMD5" ]]; then chmod +x $INITHOOKS_PATH/firstboot.d/99reboot fi @@ -74,9 +116,11 @@ if [[ "$SEC_UPDATES" == "skip" ]]; then logger -t inithooks -p warn "[95secupdates] security updates skipped" exit 0 elif [[ "$SEC_UPDATES" == "force" ]]; then - record force logger -t inithooks "[95secupdates] security updates being installed" install_updates + # only an install that succeeded is recorded: install_updates exits + # before this on failure, and offline + record force exit 0 elif [[ -n "$SEC_UPDATES" ]]; then logger -t inithooks -p err "[95secupdates] invalid preseed value: $SEC_UPDATES" @@ -97,7 +141,7 @@ elif [[ $exit_code -ne 0 ]]; then exit "$exit_code" else # exit_code == 0 - record force logger -t inithooks "[95secupdates] security updates being installed" install_updates + record force fi diff --git a/tests/test-secupdates.bats b/tests/test-secupdates.bats index fd709e7..fd86318 100644 --- a/tests/test-secupdates.bats +++ b/tests/test-secupdates.bats @@ -18,12 +18,21 @@ REPO=$BATS_TEST_DIRNAME/.. setup() { setup_stubs stub logger - stub apt-get + # apt-get update exits UPDATE_STATUS; dist-upgrade prints a line and + # exits UPGRADE_STATUS, so a failure has to cross the pipe into tee + stub apt-get 'case "$*" in +update*) exit "${UPDATE_STATUS:-0}" ;; +*dist-upgrade*) echo "0 upgraded"; exit "${UPGRADE_STATUS:-0}" ;; +esac' + # curl answers the reachability check: exit CURL_STATUS + stub curl 'exit "${CURL_STATUS:-0}"' stub dpkg 'if [[ "$1" == --audit ]]; then echo "${DPKG_AUDIT-}"; fi' # the module and boot listing before and after the upgrade: the same - # unless LS_CHANGES is set, when the second call differs + # unless LS_CHANGES is set, when the second call differs; LS_STATUS is + # its exit status (2 where /boot does not exist, as in a container) stub ls 'n=$(wc -l < "'"$STUBS"'/ls.calls") -if [[ -n "${LS_CHANGES-}" ]]; then echo "listing $n"; else echo listing; fi' +if [[ -n "${LS_CHANGES-}" ]]; then echo "listing $n"; else echo listing; fi +exit "${LS_STATUS:-0}"' export INITHOOKS_PATH=$BATS_TEST_TMPDIR/inithooks mkdir -p "$INITHOOKS_PATH/bin" "$INITHOOKS_PATH/firstboot.d" @@ -40,7 +49,16 @@ if [[ -n "${LS_CHANGES-}" ]]; then echo "listing $n"; else echo listing; fi' } > "$INITHOOKS_DEFAULT" export SEC_UPDATES_RECORD=$BATS_TEST_TMPDIR/var/lib/inithooks/sec-updates export SEC_UPDATES_LOG=$BATS_TEST_TMPDIR/secupdates.log - unset SEC_UPDATES DPKG_AUDIT LS_CHANGES ASK_STATUS + export SEC_UPDATES_SOURCES=$BATS_TEST_TMPDIR/security.sources + printf 'Types: deb\nURIs: http://security.debian.org/debian-security\nSuites: trixie-security\nComponents: main\n' \ + > "$SEC_UPDATES_SOURCES" + unset SEC_UPDATES DPKG_AUDIT LS_CHANGES LS_STATUS ASK_STATUS \ + UPDATE_STATUS UPGRADE_STATUS CURL_STATUS +} + +# the value the dist-upgrade call passed for one apt option +apt_option() { + calls apt-get | grep -o -- "-o $1=[^ ]*" | sed "s|^-o $1=||" } @test "a preseeded SKIP installs nothing and records skip" { @@ -135,3 +153,131 @@ if [[ -n "${LS_CHANGES-}" ]]; then echo "listing $n"; else echo listing; fi' [ "$status" -eq 0 ] [ "$(cat "$SEC_UPDATES_RECORD")" = "skip" ] } + +# ------------------------------------------------- where the updates come from + +@test "the upgrade reads the security source file and no other" { + echo "export SEC_UPDATES=FORCE" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 0 ] + [ "$(apt_option Dir::Etc::sourcelist)" = "$SEC_UPDATES_SOURCES" ] + [ "$(apt_option Dir::Etc::sourceparts)" = /dev/null ] +} + +@test "the default security source is security.sources, the file images ship" { + # common's conf/bootstrap_apt writes it; it used to write + # security.sources.sources, and cron-apt and this hook named that + run grep -c 'SEC_UPDATES_SOURCES:-/etc/apt/sources.list.d/security.sources}' \ + "$REPO/firstboot.d/95secupdates" + [ "$output" = 1 ] + run ! grep -q 'security\.sources\.sources' "$REPO/firstboot.d/95secupdates" +} + +@test "a missing security source fails the hook instead of upgrading nothing" { + # apt reads a missing sourcelist as an empty one: the dist-upgrade + # succeeds, installs nothing and the boot says the updates were applied + echo "export SEC_UPDATES=FORCE" > "$INITHOOKS_CONF" + rm "$SEC_UPDATES_SOURCES" + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 1 ] + [[ "$(calls apt-get)" != *dist-upgrade* ]] + [[ "$(calls logger)" == *"no security source at $SEC_UPDATES_SOURCES"* ]] + [[ "$(cat "$SEC_UPDATES_LOG")" == *"no security source at $SEC_UPDATES_SOURCES"* ]] +} + +# ------------------------------------------- offline, failures and the record + +@test "the reachability check asks for the InRelease of the security source" { + echo "export SEC_UPDATES=FORCE" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 0 ] + [[ "$(calls curl)" == *"http://security.debian.org/debian-security/dists/trixie-security/InRelease"* ]] +} + +@test "offline, the boot goes on, says why, installs and records nothing" { + echo "export SEC_UPDATES=FORCE" > "$INITHOOKS_CONF" + export CURL_STATUS=7 + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 0 ] + [ ! -e "$SEC_UPDATES_RECORD" ] + [ -z "$(calls apt-get)" ] + local said="cannot reach http://security.debian.org/debian-security" + [[ "$(calls logger)" == *"$said"* ]] + [[ "$(calls logger)" == *turnkey-install-security-updates* ]] + [[ "$(cat "$SEC_UPDATES_LOG")" == *"$said"* ]] +} + +@test "offline after Install on the screen, the boot goes on too" { + export ASK_STATUS=0 CURL_STATUS=6 + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 0 ] + [ ! -e "$SEC_UPDATES_RECORD" ] + [[ "$(calls apt-get)" != *dist-upgrade* ]] +} + +@test "an apt-get update that fails is said, and the boot goes on" { + echo "export SEC_UPDATES=FORCE" > "$INITHOOKS_CONF" + export UPDATE_STATUS=100 + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 0 ] + [ ! -e "$SEC_UPDATES_RECORD" ] + [[ "$(calls apt-get)" != *dist-upgrade* ]] + [[ "$(calls logger)" == *"apt-get update failed"* ]] + [[ "$(cat "$SEC_UPDATES_LOG")" == *"apt-get update failed"* ]] +} + +@test "a dist-upgrade that fails fails the hook through tee, and records nothing" { + echo "export SEC_UPDATES=FORCE" > "$INITHOOKS_CONF" + export UPGRADE_STATUS=100 + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -ne 0 ] + [ ! -e "$SEC_UPDATES_RECORD" ] + [[ "$(cat "$SEC_UPDATES_LOG")" == *"0 upgraded"* ]] +} + +@test "a failed install after Install on the screen records nothing either" { + export ASK_STATUS=0 UPGRADE_STATUS=100 + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -ne 0 ] + [ ! -e "$SEC_UPDATES_RECORD" ] +} + +@test "force is recorded only after the upgrade ran" { + echo "export SEC_UPDATES=FORCE" > "$INITHOOKS_CONF" + # the record must not exist yet when dist-upgrade runs + stub apt-get 'case "$*" in +*dist-upgrade*) [ -e "'"$SEC_UPDATES_RECORD"'" ] && exit 42; exit 0 ;; +esac' + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 0 ] + [ "$(cat "$SEC_UPDATES_RECORD")" = "force" ] +} + +@test "a machine without /boot, where ls fails, still installs the updates" { + echo "export SEC_UPDATES=FORCE" > "$INITHOOKS_CONF" + export LS_STATUS=2 + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 0 ] + [ "$(cat "$SEC_UPDATES_RECORD")" = "force" ] + [[ "$(calls apt-get)" == *dist-upgrade* ]] +} diff --git a/tests/test_dialog_brand.py b/tests/test_dialog_brand.py index 6f6efae..166ffb0 100644 --- a/tests/test_dialog_brand.py +++ b/tests/test_dialog_brand.py @@ -29,12 +29,10 @@ ] # Strings that name TurnKey and stay: a command name kept for # compatibility (the error text tells the operator to run it, and it is -# what is installed), the host the update screen checks it can reach, and -# the variable keel-init sets for the hooks (setpass.py reads it), neither -# of which is ever shown. +# what is installed), and the variable keel-init sets for the hooks +# (setpass.py reads it), which is never shown. ALLOWED = { "turnkey-install-security-updates", - "archive.turnkeylinux.org", "_TURNKEY_INIT", } FIRST_BOOT = "Keel Linux - First boot configuration" @@ -114,6 +112,14 @@ def test_no_dialog_text_names_turnkey(self): with self.subTest(path=path, text=text[:60]): self.assertFalse(turnkey_in(text)) + def test_the_update_screen_checks_it_can_reach_debian_security(self): + # the updates come from security.debian.org (Keel-Linux/common + # conf/bootstrap_apt), so that is the host worth resolving; the + # TurnKey archive is no source of a Keel image + hosts = [s for s in strings("bin/secupdates-ask.py") if "." in s] + self.assertIn("security.debian.org", hosts) + self.assertNotIn("archive.turnkeylinux.org", hosts) + def test_the_security_alerts_mail_names_no_turnkey_address(self): with open(join(ROOT, "bin/secalerts.sh")) as fob: script = fob.read()