Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/secupdates-ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <mendez.foto@gmail.com> 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
Expand Down
1 change: 1 addition & 0 deletions debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Standards-Version: 4.7.2
Package: inithooks
Architecture: all
Depends:
curl,
kbd,
${misc:Depends},
${python3:Depends},
Expand Down
72 changes: 58 additions & 14 deletions firstboot.d/95secupdates
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")" \
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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
154 changes: 150 additions & 4 deletions tests/test-secupdates.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" {
Expand Down Expand Up @@ -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* ]]
}
14 changes: 10 additions & 4 deletions tests/test_dialog_brand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
Loading