From c7318bba44d334f9fd69cd4f241eedc845093021 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 14:43:49 +0000 Subject: [PATCH] fix: images ship root locked, or the build fails; stamp the build date mk/turnkey/seal-root, last in root.patched/post, fails the build unless root's shadow field is '*', '!' or '!*' (never printed) and writes /etc/keel/build-date. ROOT_PASS is ignored at build time by rootpass and samba-rootpass, with a line that does not carry its value. inithooks' first boot offers Keep for a root password set at container creation only when it changed on or after this date; an image that shipped a password (ROOT_PASS, or U6aMy0wojraho in older WordPress images) must never have it offered as recommended. --- changes/turnkey.changelog | 14 +++++ conf/samba-rootpass | 14 ++--- conf/turnkey.d/rootpass | 14 +++-- mk/turnkey.mk | 3 + mk/turnkey/seal-root | 44 ++++++++++++++ tests/before-firstboot.bats | 4 +- tests/coverage.sh | 1 + tests/rootpass.bats | 28 ++++----- tests/samba-rootpass.bats | 29 +++------ tests/seal-root.bats | 117 ++++++++++++++++++++++++++++++++++++ 10 files changed, 219 insertions(+), 49 deletions(-) create mode 100755 mk/turnkey/seal-root create mode 100644 tests/seal-root.bats diff --git a/changes/turnkey.changelog b/changes/turnkey.changelog index c847fa64..fbe3ba4a 100644 --- a/changes/turnkey.changelog +++ b/changes/turnkey.changelog @@ -1,5 +1,19 @@ turnkey-core-19.0 (1) turnkey; urgency=low + * An image is exported with root locked, or the build fails. The last + step of root.patched, mk/turnkey/seal-root, accepts a root password + field of '*', '!' or '!*' and nothing else, without printing it, then + writes the build date (UTC) to /etc/keel/build-date. A build time + ROOT_PASS is ignored, with a line saying so and not its value, by + conf/turnkey.d/rootpass and conf/samba-rootpass: it gave every copy + of the image the same password, and five older WordPress images + shipped U6aMy0wojraho, the crypt() of the empty string. inithooks' + first boot offers to keep a root password set when the container was + created (pct create --password) only when the image shipped none and + the password changed on or after the build date. tests/seal-root.bats + (100 percent of its lines); the ROOT_PASS tests of rootpass.bats, + samba-rootpass.bats and before-firstboot.bats now expect it ignored. + * A login gets a UTF-8 locale. 'conf/turnkey.d/locale' wrote LC_ALL=C and LC_CTYPE=C into /etc/default/locale, which pam_env gives every login, so a shell ran in an ASCII locale and dialog, confconsole after diff --git a/conf/samba-rootpass b/conf/samba-rootpass index c4c166ff..01dc8b9b 100755 --- a/conf/samba-rootpass +++ b/conf/samba-rootpass @@ -17,14 +17,12 @@ NO_PASSWORD='*' passwd --unlock root # useful when testing in chroot +# A build time ROOT_PASS is not used, as in conf/turnkey.d/rootpass: the +# image ships both accounts without a password, and the first boot sets +# them (overlays/samba-fileserver, 35samba-*). if [ -n "$ROOT_PASS" ]; then - echo "root:$ROOT_PASS" | chpasswd -else - usermod -p "$NO_PASSWORD" root + echo "samba-rootpass: ROOT_PASS is ignored; the image ships root locked" >&2 fi -if [ -n "$ROOT_PASS" ]; then - (echo "$ROOT_PASS" ; echo "$ROOT_PASS" ) | smbpasswd -a -s root -else - smbpasswd -a -n root -fi +usermod -p "$NO_PASSWORD" root +smbpasswd -a -n root diff --git a/conf/turnkey.d/rootpass b/conf/turnkey.d/rootpass index 306a6422..fd4e92ae 100755 --- a/conf/turnkey.d/rootpass +++ b/conf/turnkey.d/rootpass @@ -11,13 +11,17 @@ # passwordless account"), exits 3 and leaves the rest of that hook undone. NO_PASSWORD='*' +# A build time ROOT_PASS is not used: it was a password every copy of the +# image shared, and the first boot could not tell it from one set when the +# container was created. The image ships root locked, which +# mk/turnkey/seal-root checks before export; the first boot sets it. +if [ -n "$ROOT_PASS" ]; then + echo "rootpass: ROOT_PASS is ignored; the image ships root locked" >&2 +fi + # chroot only environments don't need a root password if [ ! "$CHROOT_ONLY" ]; then - if [ -n "$ROOT_PASS" ]; then - echo "root:$ROOT_PASS" | chpasswd - else - usermod -p "$NO_PASSWORD" root - fi + usermod -p "$NO_PASSWORD" root else passwd --lock root fi diff --git a/mk/turnkey.mk b/mk/turnkey.mk index 1a483329..95e59914 100644 --- a/mk/turnkey.mk +++ b/mk/turnkey.mk @@ -71,6 +71,9 @@ define _root.patched/post fab-chroot $O/root.patched "dpkg -i *.deb && rm *.deb && rm -f /var/log/dpkg.log" fab-chroot $O/root.patched "which postsuper >/dev/null && postsuper -d ALL || true" + + # last: root locked or the build fails, and the build date stamped + $(FAB_PATH)/common/mk/turnkey/seal-root $O/root.patched endef root.patched/post += $(_root.patched/post) diff --git a/mk/turnkey/seal-root b/mk/turnkey/seal-root new file mode 100755 index 00000000..f8257dd0 --- /dev/null +++ b/mk/turnkey/seal-root @@ -0,0 +1,44 @@ +#!/bin/bash +# seal-root ROOTFS +# +# The last step of root.patched (mk/turnkey.mk): fails the build unless +# root's password field in ROOTFS/etc/shadow is '*', '!' or '!*' (what +# passwd --lock makes of '*'), then writes the build date, YYYY-MM-DD in +# UTC, to ROOTFS/etc/keel/build-date. +# +# inithooks' first boot offers to keep a root password set when the +# container was created (pct create --password) only when the password +# changed on or after that date: the image must ship none of its own. A +# build with ROOT_PASS shipped one every copy shared, and five older +# WordPress images shipped U6aMy0wojraho, the crypt() of the empty string. +# +# The field is compared and never printed. + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: seal-root ROOTFS" >&2 + exit 2 +fi +rootfs=$1 +shadow=$rootfs/etc/shadow + +if [[ ! -r "$shadow" ]]; then + echo "seal-root: cannot read $shadow" >&2 + exit 1 +fi + +if ! entry=$(grep -m 1 '^root:' "$shadow"); then + echo "seal-root: $shadow has no root entry" >&2 + exit 1 +fi +IFS=: read -r _ field _ <<< "$entry" + +if [[ "$field" != '*' ]] && [[ "$field" != '!' ]] && [[ "$field" != '!*' ]]; then + echo "seal-root: root has a password in $shadow; an image ships" \ + "root locked ('*'), and the first boot sets its password" >&2 + exit 1 +fi + +mkdir -p "$rootfs/etc/keel" +date -u +%F > "$rootfs/etc/keel/build-date" diff --git a/tests/before-firstboot.bats b/tests/before-firstboot.bats index 4cffe790..07b1534d 100644 --- a/tests/before-firstboot.bats +++ b/tests/before-firstboot.bats @@ -85,11 +85,11 @@ build_image() { [ "$status" -eq 0 ] } -@test "a build given a root password is held shut the same way" { +@test "a build given a root password is held shut, and the password is not set" { export ROOT_PASS=s3cret build_image run ! unit_would_start "$DROPIN" - run authenticates "s3cret" + run refuses "s3cret" [ "$status" -eq 0 ] run refuses "" [ "$status" -eq 0 ] diff --git a/tests/coverage.sh b/tests/coverage.sh index e5f03f19..f860daf4 100755 --- a/tests/coverage.sh +++ b/tests/coverage.sh @@ -24,6 +24,7 @@ targets=( "conf/samba-rootpass:tests/samba-rootpass.bats" "conf/desktop:tests/desktop.bats" "conf/turnkey.d/rootpass:tests/rootpass.bats" + "mk/turnkey/seal-root:tests/seal-root.bats" "conf/turnkey.d/webmin-enable:tests/webmin-enable.bats" "conf/turnkey.d/webmin-pam:tests/webmin-pam.bats" "conf/turnkey.d/webmin-net:tests/webmin-net.bats" diff --git a/tests/rootpass.bats b/tests/rootpass.bats index c2c131f2..4739b374 100644 --- a/tests/rootpass.bats +++ b/tests/rootpass.bats @@ -62,27 +62,27 @@ setup() { [ "$status" -eq 0 ] } -@test "a build time ROOT_PASS is the password, and nothing else is" { +# A build time ROOT_PASS was a password every copy of the image shared, and +# the first boot cannot tell it from one set when the container was created +# (mk/turnkey/seal-root). The image ships root locked whatever it says. + +@test "a build time ROOT_PASS is ignored and root stays locked" { export ROOT_PASS=s3cret - run "$SCRIPT" + run --separate-stderr "$SCRIPT" [ "$status" -eq 0 ] - run authenticates "s3cret" + [ "$(field_of root)" = '*' ] + run refuses "s3cret" [ "$status" -eq 0 ] - run refuses "" + run nothing_authenticates [ "$status" -eq 0 ] } -@test "a ROOT_PASS holding a glob character is set as written" { - mkdir -p "$IMAGE/build" - touch "$IMAGE/build/root:pass" - cd "$IMAGE/build" - export ROOT_PASS='p*ss' - run "$SCRIPT" - [ "$status" -eq 0 ] - run authenticates 'p*ss' - [ "$status" -eq 0 ] - run refuses 'pass' +@test "an ignored ROOT_PASS is said, without its value" { + export ROOT_PASS='n0t-in-the-log' + run --separate-stderr "$SCRIPT" [ "$status" -eq 0 ] + [[ "$stderr" == *"ROOT_PASS is ignored"* ]] + [[ "$output$stderr" != *"n0t-in-the-log"* ]] } @test "a chroot only build locks the account instead" { diff --git a/tests/samba-rootpass.bats b/tests/samba-rootpass.bats index 9473db38..45a96d83 100644 --- a/tests/samba-rootpass.bats +++ b/tests/samba-rootpass.bats @@ -40,29 +40,18 @@ setup() { [ "$(tail -1 "$STUB_LOG")" = "smbpasswd -a -n root" ] } -@test "a build time ROOT_PASS is the password for both, and nothing else is" { - export ROOT_PASS=s3cret - run "$SCRIPT" - [ "$status" -eq 0 ] - run authenticates s3cret - [ "$status" -eq 0 ] - run refuses "" - [ "$status" -eq 0 ] - [ "$(tail -1 "$STUB_LOG")" = "smbpasswd -a -s root" ] - [ "$(cat "$STUB_LOG.stdin")" = "$(printf 's3cret\ns3cret')" ] -} +# A build time ROOT_PASS was shared by every copy of the image; the image +# ships both accounts without one whatever it says (mk/turnkey/seal-root). -@test "a ROOT_PASS holding a glob character is set as written" { - mkdir -p "$IMAGE/build" - touch "$IMAGE/build/root:pass" - cd "$IMAGE/build" - export ROOT_PASS='p*ss' - run "$SCRIPT" - [ "$status" -eq 0 ] - run authenticates 'p*ss' +@test "a build time ROOT_PASS is ignored for both accounts" { + export ROOT_PASS=s3cret + run --separate-stderr "$SCRIPT" [ "$status" -eq 0 ] - run refuses 'pass' + [ "$(field_of root)" = '*' ] + run refuses s3cret [ "$status" -eq 0 ] + [ "$(tail -1 "$STUB_LOG")" = "smbpasswd -a -n root" ] + [[ "$stderr" == *"ROOT_PASS is ignored"* ]] } @test "the build password is not written to the build log" { diff --git a/tests/seal-root.bats b/tests/seal-root.bats new file mode 100644 index 00000000..e6c8d94a --- /dev/null +++ b/tests/seal-root.bats @@ -0,0 +1,117 @@ +#!/usr/bin/env bats +# Tests for mk/turnkey/seal-root, the last step of root.patched: an image is +# exported with root locked, or the build fails, and it carries the date it +# was built on. +# +# Why: inithooks' first boot offers to keep a root password set when the +# container was created (pct create --password). It can only tell such a +# password from one the image shipped if the image shipped none, and five +# older WordPress images shipped U6aMy0wojraho, the crypt() of the empty +# string; a build with ROOT_PASS set shipped a password every copy shares. + +bats_require_minimum_version 1.5.0 + +setup() { + TESTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + SCRIPT="$TESTS_DIR/../mk/turnkey/seal-root" + ROOTFS=$BATS_TEST_TMPDIR/rootfs + mkdir -p "$ROOTFS/etc" +} + +# shadow_root FIELD +# A shadow file whose root entry has password field FIELD. +shadow_root() { + printf 'daemon:*:20718:0:99999:7:::\nroot:%s:20718:0:99999:7:::\n' \ + "$1" > "$ROOTFS/etc/shadow" +} + +@test "a root field of '*' passes and the build date is stamped" { + shadow_root '*' + + run "$SCRIPT" "$ROOTFS" + + [ "$status" -eq 0 ] + [ "$(cat "$ROOTFS/etc/keel/build-date")" = "$(date -u +%F)" ] +} + +@test "a root field of '!' passes" { + shadow_root '!' + + run "$SCRIPT" "$ROOTFS" + + [ "$status" -eq 0 ] +} + +@test "a root field of '!*', what passwd --lock makes of '*', passes" { + shadow_root '!*' + + run "$SCRIPT" "$ROOTFS" + + [ "$status" -eq 0 ] +} + +@test "a real password hash fails the build and is not printed" { + shadow_root '$y$j9T$abcdefghijklmnop$qrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + + run --separate-stderr "$SCRIPT" "$ROOTFS" + + [ "$status" -eq 1 ] + [[ "${stderr:-}" == *"root has a password"* ]] + [[ "$output${stderr:-}" != *'$y$'* ]] + [ ! -e "$ROOTFS/etc/keel/build-date" ] +} + +@test "the hash of the empty password fails the build" { + shadow_root 'U6aMy0wojraho' + + run --separate-stderr "$SCRIPT" "$ROOTFS" + + [ "$status" -eq 1 ] + [[ "$output${stderr:-}" != *U6aMy0wojraho* ]] +} + +@test "an empty field, which lets anyone in, fails the build" { + shadow_root '' + + run "$SCRIPT" "$ROOTFS" + + [ "$status" -eq 1 ] +} + +@test "a locked real hash fails too: unlocking it gives the password back" { + shadow_root '!$y$j9T$abcdefghijklmnop$qrstuvwxyz' + + run "$SCRIPT" "$ROOTFS" + + [ "$status" -eq 1 ] +} + +@test "a shadow file with no root entry fails the build" { + printf 'daemon:*:20718:0:99999:7:::\n' > "$ROOTFS/etc/shadow" + + run --separate-stderr "$SCRIPT" "$ROOTFS" + + [ "$status" -eq 1 ] + [[ "${stderr:-}" == *"no root entry"* ]] +} + +@test "a root file system without a shadow file fails the build" { + run --separate-stderr "$SCRIPT" "$ROOTFS" + + [ "$status" -eq 1 ] + [[ "${stderr:-}" == *"cannot read"* ]] +} + +@test "the appliance build seals root.patched as the last step of its post hook" { + local mk=$TESTS_DIR/../mk/turnkey.mk + local hook + hook=$(sed -n '/^define _root.patched\/post/,/^endef/p' "$mk") + last=$(grep -v '^[[:space:]]*\(#.*\)\?$' <<< "$hook" | tail -2 | head -1) + [[ "$last" == *'common/mk/turnkey/seal-root $O/root.patched' ]] +} + +@test "no root file system given is a usage error" { + run "$SCRIPT" + + [ "$status" -eq 2 ] +}