From 61fc4952fb58e21b1fc8ad2d2df3ee1ce9f361e8 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 13:49:25 +0000 Subject: [PATCH 1/4] fix: no silent gap between first boot screens; keep a root password set at creation On a Proxmox VE container of the step8 Web image the console stayed on "Did you save the password?" after until the Keel Cloud screen came. Nothing hung. Measured on LXC: 2 s, 15 s with a quarter of a CPU, spent in the boot wait (before every hook from 30 on, eleven systemctl calls and up to 10 s of sleep each while the system is starting) and in 75keel-role's keel inspect, with nothing new on the screen. run waits for a starting system once per run and stops as soon as it is up; each first boot hook, and the wait, is named on the terminal in a box with the first boot backtitle ("Configuring keel-role... please wait"). Nothing is drawn off a terminal or with REDIRECT_OUTPUT. The password screen offers Keep first when root can already log in with a password at first boot (pct create --password, LXC writing /etc/shadow): passwd -S is the only question, the hash is never read. No prior password, a preseeded ROOT_PASS, secrets.root_password and keel-init behave as before. tests/test-firstboot-pty.bats runs run, 30rootpass, setpass.py and the real dialog on a pty under script, with a timeout, and fails on a hang or on a screen drawn into a pipe. --- .github/workflows/tests.yml | 6 +- bin/setpass.py | 56 ++++++++ debian/changelog | 42 ++++++ firstboot.d/30rootpass | 17 ++- libinithooks/dialog_wrapper.py | 53 ++++++-- run | 38 +++++- tests/README.md | 6 + tests/test-firstboot-pty.bats | 238 +++++++++++++++++++++++++++++++++ tests/test-run.bats | 125 +++++++++++++++++ tests/test_dialog_brand.py | 6 +- tests/test_dialog_wrapper.py | 71 ++++++++++ tests/test_setpass.py | 151 +++++++++++++++++++-- 12 files changed, 776 insertions(+), 33 deletions(-) create mode 100644 tests/test-firstboot-pty.bats diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ee3f7f1..ba248e5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,8 +18,10 @@ jobs: with: threshold: 98 # tests/test-packaging.bats runs debhelper over debian/ and reads the - # maintainer scripts it generates; in CI a missing debhelper fails it - apt-packages: debhelper + # maintainer scripts it generates, and tests/test-firstboot-pty.bats + # runs the first boot screens with the real dialog; in CI a missing + # package fails them + apt-packages: debhelper dialog python3-dialog python: uses: keel-linux/.github/.github/workflows/test-python.yml@main with: diff --git a/bin/setpass.py b/bin/setpass.py index 97c5a12..d4a9a01 100755 --- a/bin/setpass.py +++ b/bin/setpass.py @@ -7,14 +7,33 @@ Options: -p --pass= if not provided, will ask interactively + +Asked interactively at first boot, an account that can already log in with +a password (`pct create --password`, or LXC writing /etc/shadow in the root +file system before the first boot) is offered Keep, first: the password +stays as it is. Only `passwd -S` is asked, for the status; the password and +its hash are never read. keel-init (_TURNKEY_INIT) asks as before. """ +import os import sys import getopt import subprocess import signal from typing import NoReturn +# `passwd -S` status of an account that can log in with a password: L is +# locked (a hash starting with ! or *, which is how images ship root), NP +# has none. +USABLE = "P" +PASSWD_TIMEOUT = 10 +# systemd writes it in a container, whatever the container manager +CONTAINER_MARKER = "/run/systemd/container" +EXPLICIT_RUN = "_TURNKEY_INIT" +# Each fits beside the Generate tag in the widest menu dialog_wrapper draws +KEEP_CONTAINER = "Password set when the container was created (recommended)" +KEEP_MACHINE = "Password already set on this machine (recommended)" + def fatal( msg: str | subprocess.TimeoutExpired | subprocess.CalledProcessError, @@ -31,6 +50,35 @@ def usage(msg: str | getopt.GetoptError = "") -> NoReturn: sys.exit(1) +def password_usable(username: str) -> bool: + """Whether USERNAME can log in with a password now, by `passwd -S` + + Anything but a clear yes (passwd missing, failing, slow, or another + status) is no, and the screen is the one without Keep. + """ + try: + out = subprocess.run( + ["passwd", "-S", username], + capture_output=True, + text=True, + check=False, + timeout=PASSWD_TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired): + return False + fields = out.stdout.split() + return out.returncode == 0 and len(fields) > 1 and fields[1] == USABLE + + +def keep_offer(username: str) -> str: + """The description of Keep for USERNAME, or "" for no Keep""" + if os.environ.get(EXPLICIT_RUN) or not password_usable(username): + return "" + if os.path.exists(CONTAINER_MARKER): + return KEEP_CONTAINER + return KEEP_MACHINE + + def main(): signal.signal(signal.SIGINT, signal.SIG_IGN) try: @@ -56,7 +104,15 @@ def main(): password = d.get_password( f"{username.capitalize()} Password", f"Please enter new password for the {username} account.", + keep=keep_offer(username), ) + if password is None: + print( + f"setpass: the {username} password set before the first" + " boot was kept", + file=sys.stderr, + ) + return assert password command = ["chpasswd"] diff --git a/debian/changelog b/debian/changelog index 37e53ba..27346b8 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,45 @@ +inithooks (2.3.6+keel17) trixie; urgency=medium + + * The console no longer looks frozen between first boot screens. On a + Proxmox VE container the maintainer pressed on "Did you save + the password?" and the screen stayed on it until the Keel Cloud + screen came. Nothing hung: what ran in between drew nothing. Measured + on an LXC container from the step8 Web image, the gap is 2 s, and + 15 s with a quarter of a CPU: the boot wait ran before every hook from + 30 on, asking systemctl eleven times each and sleeping up to 10 s per + hook while the system was still starting, then 75keel-role ran keel + inspect. run now waits for a starting system once per run, stops + waiting as soon as it is up, and says so on the screen. Each first + boot hook is named on the screen while it runs ("Configuring + keel-role... please wait"), in a box with the first boot backtitle, + so a step that works without a screen of its own never leaves the + last one up. Nothing is drawn when the output is not a terminal or + goes to the log (REDIRECT_OUTPUT), and a notice that cannot be drawn + is not an error. + * A root password set when the container was created is kept if the + operator wants. pct create --password (or LXC writing /etc/shadow + before the first boot) gave root a password, and the first boot made + the operator replace it. The images ship root locked; when root can + already log in with a password at first boot, the password screen + offers Keep first, as the default and the recommendation, with + Generate and Manual below it. Only passwd -S is asked, for the + status: the password and its hash are never read, printed or logged. + Without such a password the screen is as before, a preseeded + ROOT_PASS or a declared secrets.root_password still draws no screen, + and keel-init still asks for a new one. Dialog.get_password() takes + keep, the description of the Keep entry, and returns None when it is + chosen; a menu is drawn wide enough for its longest entry, up to 76 + columns. 30rootpass reads INITHOOKS_DEFAULT like the other hooks. + * tests/test-firstboot-pty.bats runs run, the real 30rootpass, + setpass.py and dialog on a pty under script, typing each key when its + screen has reached the pty, with a timeout on the whole run: Generate + then Saved, New, Manual, Keep, Generate below Keep and a preseeded + password each go on to the next hook's screen. A run that stops + answering fails it, and so does a screen drawn into a pipe, which + never reaches the pty. CI installs dialog and python3-dialog for it. + + -- Marcos Mendez Fri, 02 Oct 2026 16:00:00 +0000 + inithooks (2.3.6+keel16) trixie; urgency=medium * A generated password is shown on one screen only. The screen that shows diff --git a/firstboot.d/30rootpass b/firstboot.d/30rootpass index 231862a..3c5528f 100755 --- a/firstboot.d/30rootpass +++ b/firstboot.d/30rootpass @@ -1,11 +1,18 @@ #!/bin/bash -e # set root password +# +# ROOT_PASS preseeded (or rendered from secrets.root_password by +# 00declarative) sets it without a screen. Otherwise setpass.py asks, and +# offers to keep a password set before the first boot (pct create +# --password, or LXC in the root file system) when there is one. USERNAME=root -. /etc/default/inithooks -[ "$(echo $SUDOADMIN | tr [A-Z] [a-z] )" = "true" ] && USERNAME=admin - -[ -e $INITHOOKS_CONF ] && . $INITHOOKS_CONF -$INITHOOKS_PATH/bin/setpass.py $USERNAME --pass="$ROOT_PASS" +INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" +# shellcheck source=default/inithooks +. "$INITHOOKS_DEFAULT" +[ "$(echo "$SUDOADMIN" | tr '[:upper:]' '[:lower:]')" = "true" ] && USERNAME="admin" +# shellcheck disable=SC1090 +[ -e "$INITHOOKS_CONF" ] && . "$INITHOOKS_CONF" +"$INITHOOKS_PATH/bin/setpass.py" "$USERNAME" --pass="$ROOT_PASS" diff --git a/libinithooks/dialog_wrapper.py b/libinithooks/dialog_wrapper.py index 5e5936e..08992b3 100644 --- a/libinithooks/dialog_wrapper.py +++ b/libinithooks/dialog_wrapper.py @@ -61,6 +61,14 @@ def password_complexity(password: str) -> int: PASSWORD_UPPER + PASSWORD_LOWER + PASSWORD_DIGITS + PASSWORD_SYMBOLS ) GENERATED_LENGTH = 20 +# The menu tag of get_password(keep=...): the account keeps its password. +KEEP = "Keep" +# Columns a menu box takes besides its longest tag and description, and the +# widest box drawn, which an 80 column console shows whole (measured with +# dialog 1.3 on tty1 of an LXC container: a box 76 wide shows 58 columns of +# description beside an 8 column tag). +MENU_MARGIN = 10 +MENU_MAX_WIDTH = 76 GENERATED_MIN_LENGTH = 12 # Generated candidates tried against the caller's rules before the operator # is asked to type a password instead (a pass_req regex can refuse them all). @@ -386,12 +394,17 @@ def menu( """Titled message with single choice of options & 'ok' button. choices is a list of options, each a tuple of the option tag and its short description: [(opt1, opt1_info), (opt2, opt2_info)] + The box is self.width wide, wider when an option needs it, up to + MENU_MAX_WIDTH, so that no description is cut off. Returns the selected option tag - e.g. 'opt1'""" + needed = MENU_MARGIN + max(len(tag) for tag, _ in choices) + max( + len(info) for _, info in choices + ) _, choice = self.wrapper( # return_code, choice "menu", text, self.height, - self.width, + max(self.width, min(needed, MENU_MAX_WIDTH)), menu_height=len(choices) + 1, title=title, choices=choices, @@ -408,16 +421,21 @@ def get_password( blacklist: list[str] | None = None, offer_generate: bool = True, gen_length: int = GENERATED_LENGTH, + keep: str = "", ) -> str | None: - """Validated password, generated or typed. + """Validated password, generated or typed; None when kept. When offer_generate is True (the default), a menu comes first: - - Generate (recommended): a random password (generate_password), - shown to the operator, who must confirm it was saved; 'New' - discards it and shows another. + - Keep, only when KEEP is given: the password the account has + already, which KEEP describes. It is first, the default and + the recommendation, and choosing it returns None. + - Generate (recommended without Keep): a random password + (generate_password), shown to the operator, who must confirm + it was saved; 'New' discards it and shows another. - Manual: the password box below. Existing callers get the menu without any change. Pass - offer_generate=False for the password box alone, as before. + offer_generate=False for the password box alone, as before; Keep + needs the menu, and asking for both raises ValueError. The generated password satisfies the same rules as a typed one (pass_req, min_complexity, blacklist): it is gen_length characters @@ -428,7 +446,9 @@ def get_password( ESC never skips the password: every dialog of it is shown again. - Returns password""" + Returns password, or None when the operator chose Keep""" + if keep and not offer_generate: + raise ValueError("get_password(): keep needs the menu") required = self._value_required self._value_required = True try: @@ -440,6 +460,7 @@ def get_password( list(blacklist or []), offer_generate, gen_length, + keep, ) finally: self._value_required = required @@ -453,16 +474,24 @@ def _get_password( blacklist: list[str], offer_generate: bool, gen_length: int, - ) -> str: + keep: str = "", + ) -> str | None: if offer_generate: + generate = "A strong random password" + choices = [(KEEP, keep)] if keep else [] + if not keep: + generate += " (recommended)" + choices += [ + ("Generate", generate), + ("Manual", "Type my own password"), + ] choice = self.menu( title, f"{text}\n\nChoose how to set this password:", - [ - ("Generate", "A strong random password (recommended)"), - ("Manual", "Type my own password"), - ], + choices, ) + if choice == KEEP: + return None if choice == "Generate": password = self._generate_password_flow( title, pass_req, min_complexity, blacklist, gen_length diff --git a/run b/run index b30f7e0..075fa8e 100755 --- a/run +++ b/run @@ -44,15 +44,40 @@ mkdir -p "$(dirname "$INITHOOKS_LOGFILE")" touch "$INITHOOKS_LOGFILE" chmod 640 "$INITHOOKS_LOGFILE" +# The back title of the first boot screens (libinithooks/dialog_wrapper.py, +# BRAND), on the notices below as well. +FIRSTBOOT_TITLE="Keel Linux - First boot configuration" +BOOT_WAITED= + +# notice TEXT +# Shows TEXT in a box on the terminal the hooks draw on, and goes on: the +# screen of the hook before stays up otherwise, and it looks frozen while a +# hook works or the boot is waited for (2026-10-02, a Proxmox console left +# on "Did you save the password?" after ). Nothing is drawn when the +# output is not a terminal or goes to the log (REDIRECT_OUTPUT), and a +# notice that cannot be drawn is not an error. +notice() { + [[ -t 1 ]] && [[ "$REDIRECT_OUTPUT" != "true" ]] || return 0 + dialog --backtitle "$FIRSTBOOT_TITLE" --infobox "$1" 5 60 2>/dev/null \ + || true +} + wait_for_boot() { # wait up to 10 secs for system to be running before starting; minimizes chance - # of journal overwriting inithook dialog/confconsole + # of journal overwriting inithook dialog/confconsole. Once per run: the + # wait was before every hook from 30 on, 10 s each on a system still + # starting, with nothing on the screen. + [[ -z "$BOOT_WAITED" ]] || return 0 + BOOT_WAITED=true + local count logger -t inithooks "systemctl is-system-running: $(systemctl is-system-running)" for count in {1..10}; do - if [[ "$(systemctl is-system-running)" == "starting" ]]; then - logger -t inithooks "Waiting for boot to finish ($count/10 seconds)" - sleep 1 + if [[ "$(systemctl is-system-running)" != "starting" ]]; then + return 0 fi + logger -t inithooks "Waiting for boot to finish ($count/10 seconds)" + notice "Waiting for the system to finish starting... please wait ($count/10 s)" + sleep 1 done } @@ -110,6 +135,11 @@ exec_scripts() { continue fi log info "[$script] running" + if [[ -n "$firstboot" ]] && [[ "$script" =~ ^[0-9]*(.+)$ ]]; then + # until the hook draws its own screen, or for as long as it + # works without one + notice "Configuring ${BASH_REMATCH[1]}... please wait" + fi # the hook does not get the lock's descriptor: see lib/init-lock.sh "$script_executable" {INIT_LOCK_FD}>&- exit_code=$? diff --git a/tests/README.md b/tests/README.md index eba3bd3..9979fdd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,6 +13,12 @@ the package would carry, for one that `keel-host-keys.service` is enabled by the generated postinst. Needs `debhelper`; skipped without it, except in CI. +- `test-firstboot-pty.bats`: the first boot on a pty under `script`: `run`, + the real `30rootpass`, `setpass.py` and `dialog`, keys typed when their + screen is on the pty, and a timeout on the whole run. It fails on a run + that stops answering and on a screen drawn into a pipe, which never + reaches the pty. Only `chpasswd` and `passwd` are stubs. Needs `dialog` + and `python3-dialog`; skipped without them, except in CI. - `helpers.bash`: the stub helpers (`setup_stubs`, `stub`, `calls`), and `eventually` and `let_go` for tests that wait on another process. - `test_init_lock.py`: pytest tests of the first boot lock diff --git a/tests/test-firstboot-pty.bats b/tests/test-firstboot-pty.bats new file mode 100644 index 0000000..7903ee9 --- /dev/null +++ b/tests/test-firstboot-pty.bats @@ -0,0 +1,238 @@ +#!/usr/bin/env bats +# The first boot on a terminal: run, the real 30rootpass and setpass.py, +# the real dialog, on a pty that `script` gives them, the way +# inithooks.service gives them tty1. +# +# The keys are typed when the screen asks for them, not on a timer, and the +# whole run has TIMEOUT seconds: a run that stops answering fails here +# instead of leaving the console on its last screen. Each screen is looked +# for in what reached the pty, so a dialog drawn into a pipe (the bug of +# keel-mariadb's dbpass.py) is missing from it, and the test fails. +# +# Only chpasswd and passwd are stand-ins: chpasswd records the account it +# was given, never the password, and passwd -S answers the status the test +# sets. Needs dialog and python3-dialog; skipped without them, except in CI. + +bats_require_minimum_version 1.5.0 + +load helpers + +TIMEOUT=60 +REAL_SLEEP=$(command -v sleep) + +setup() { + if ! command -v dialog > /dev/null \ + || ! /usr/bin/python3 -c 'import dialog' 2> /dev/null; then + if [[ -n "${CI:-}" ]]; then + echo "dialog and python3-dialog are required in CI" >&2 + return 1 + fi + skip "needs dialog and python3-dialog" + fi + setup_stubs + REPO=$(cd "$BATS_TEST_DIRNAME/.." && pwd) + ROOT=$BATS_TEST_TMPDIR + LIB=$ROOT/lib + SCREEN=$ROOT/typescript + NEXT=$ROOT/next-hook-ran + mkdir -p "$LIB/firstboot.d" "$LIB/bin" + cp "$REPO/firstboot.d/30rootpass" "$LIB/firstboot.d/" + ln -s "$REPO/bin/setpass.py" "$LIB/bin/setpass.py" + # the screen after the password, drawn as the next hook draws its own + cat > "$LIB/firstboot.d/80next" <> '$ROOT/chpasswd'" + passwd_status L + + DEFAULT=$ROOT/default-inithooks + cat > "$DEFAULT" < "$SCREEN" +} + +# passwd_status STATUS +# passwd -S answers STATUS (P usable, L locked, NP empty) for any account. +passwd_status() { + stub passwd "echo \"\${2:-root} $1 2026-10-02 0 99999 7 -1\"" +} + +teardown() { + if [[ -s "${ROOT:-}/hung" ]]; then + kill "$(cat "$ROOT/hung")" 2> /dev/null || true + fi +} + +# within FILE WORD +# Waits until WORD is in FILE, at most TIMEOUT seconds. +within() { + local deadline=$((SECONDS + TIMEOUT)) + until grep -qa -- "$2" "$1" 2> /dev/null; do + if (( SECONDS >= deadline )); then + echo "never in $1: $2" >&2 + return 1 + fi + "$REAL_SLEEP" 0.2 + done +} + +# operator WORD KEYS [WORD KEYS]... +# Types each KEYS (printf format) once its WORD has reached the pty, then +# keeps the pty's input open until the run has logged its end. A screen +# that never comes keeps the input open too, until the timeout ends the +# run: the end of the input would answer the dialog on the screen. +operator() { + while (( $# )); do + if ! within "$SCREEN" "$1"; then + "$REAL_SLEEP" "$TIMEOUT" + return 1 + fi + "$REAL_SLEEP" 0.3 + # shellcheck disable=SC2059 + printf "$2" + shift 2 + done + within "$ROOT/inithooks.log" 'Inithooks run completed' || true +} + +# first_boot WORD KEYS... +# The run on a pty under script, answered by operator, within TIMEOUT; it +# fails when a screen never came or the run did not end in time. +first_boot() { + set -o pipefail + operator "$@" | timeout "$TIMEOUT" script -qfec "$REPO/run" "$SCREEN" \ + > /dev/null 2>&1 +} + +# done_logged +# The run reached its end: script does not show what run logs. +done_logged() { + grep -q 'Inithooks run completed' "$ROOT/inithooks.log" +} + +assert_next_screen() { + [ -e "$NEXT" ] + grep -qa 'NEXT-SCREEN-SHOWN' "$SCREEN" + done_logged +} + +@test "Generate, then Saved, goes on to the next screen" { + run first_boot 'Choose' '\r' 'manager.' '\r' 'discard' '\r' \ + 'NEXT-SCREEN-SHOWN' '' + + [ "$status" -eq 0 ] + assert_next_screen + [ "$(cat "$ROOT/chpasswd")" = root ] + # the confirmation reached the terminal, not a pipe + grep -qa 'Saved' "$SCREEN" + # and the step after it said what it was doing before drawing + grep -qa 'Configuring next' "$SCREEN" +} + +@test "New shows another password, then Saved goes on" { + run first_boot 'Choose' '\r' 'manager.' '\r' 'discard' '\t\r' \ + 'manager.' '\r' 'discard' '\r' 'NEXT-SCREEN-SHOWN' '' + + [ "$status" -eq 0 ] + assert_next_screen + [ "$(wc -l < "$ROOT/chpasswd")" -eq 1 ] +} + +@test "Manual sets the typed password and goes on" { + run first_boot 'Choose' 'M\r' 'Requirements' 'Abcdefg1\r' \ + 'Confirm' 'Abcdefg1\r' 'NEXT-SCREEN-SHOWN' '' + + [ "$status" -eq 0 ] + assert_next_screen + [ "$(cat "$ROOT/chpasswd")" = root ] +} + +@test "a password set at creation is kept with Enter and nothing replaces it" { + passwd_status P + + run first_boot 'Choose' '\r' 'NEXT-SCREEN-SHOWN' '' + + [ "$status" -eq 0 ] + assert_next_screen + grep -qa 'container' "$SCREEN" + [ ! -e "$ROOT/chpasswd" ] +} + +@test "Generate below Keep replaces the password set at creation" { + passwd_status P + + run first_boot 'Choose' 'G\r' 'manager.' '\r' 'discard' '\r' \ + 'NEXT-SCREEN-SHOWN' '' + + [ "$status" -eq 0 ] + assert_next_screen + [ "$(cat "$ROOT/chpasswd")" = root ] +} + +@test "a preseeded password draws no password screen" { + passwd_status P + echo "export ROOT_PASS='Preseeded-123'" > "$ROOT/inithooks.conf" + + run first_boot 'NEXT-SCREEN-SHOWN' '' + + [ "$status" -eq 0 ] + assert_next_screen + run ! grep -qa 'Choose' "$SCREEN" + [ "$(cat "$ROOT/chpasswd")" = root ] +} + +@test "a screen drawn into a pipe never reaches the pty, and is caught" { + # the guard itself: the dbpass.py bug, a hook reading a screen's + # output through a pipe, leaves the screen out of the pty + cat > "$LIB/firstboot.d/80next" < "$LIB/firstboot.d/50hangs" < '$ROOT/hung' +exec '$REAL_SLEEP' 600 +EOF + chmod +x "$LIB/firstboot.d/50hangs" + TIMEOUT=5 + + run first_boot 'Choose' '\r' 'manager.' '\r' 'discard' '\r' + + [ "$status" -ne 0 ] + [ ! -e "$NEXT" ] +} diff --git a/tests/test-run.bats b/tests/test-run.bats index cd9450c..66e2aa9 100644 --- a/tests/test-run.bats +++ b/tests/test-run.bats @@ -267,3 +267,128 @@ PROBE [ "$status" -eq 0 ] [ -e "$INITHOOKS_COMPLETE" ] } + +# The silent gaps of a first boot (2026-10-02, a Web container on Proxmox +# VE: the console stayed on "Did you save the password?" after +# until the next screen came). Before each hook from 30 on, run waited up +# to 10 s for a system still starting, without a word on the screen: 30 s +# before the Keel Cloud screen alone, more on a slow host. + +@test "a starting system is waited for once, not before every hook" { + stub systemctl 'echo starting' + probe 30first + probe 75second + probe 80third + + run_runner + + [ "$status" -eq 0 ] + [ "$(wc -l < "$SEEN")" -eq 3 ] + # one second at a time (the 2 s before confconsole is not a wait) + [ "$(calls sleep | grep -cx 1)" -eq 10 ] +} + +@test "the wait ends as soon as the system is running" { + # starting for the first two questions, running from the third on + stub systemctl "n=\$(wc -l < '$STUBS/systemctl.calls') +if (( n <= 2 )); then echo starting; else echo running; fi" + probe 30first + probe 75second + + run_runner + + [ "$status" -eq 0 ] + [ "$(calls sleep | grep -cx 1)" -eq 1 ] + # the log line, then one question per second waited + [ "$(calls systemctl | wc -l)" -le 4 ] +} + +# run_on_terminal +# The runner with a terminal for its standard output, the way +# inithooks.service gives it tty1. +run_on_terminal() { + INITHOOKS_DEFAULT=$DEFAULT run script -qec "$BATS_TEST_DIRNAME/../run" \ + /dev/null < /dev/null +} + +@test "each first boot hook is named on the terminal while it runs" { + stub dialog + probe 15regen-sslcert + probe 75keel-role + + run_on_terminal + + [ "$status" -eq 0 ] + grep -q -- '--infobox Configuring regen-sslcert... please wait' \ + "$STUBS/dialog.calls" + grep -q -- '--infobox Configuring keel-role... please wait' \ + "$STUBS/dialog.calls" + grep -q -- '--backtitle Keel Linux - First boot configuration' \ + "$STUBS/dialog.calls" +} + +@test "the wait for a starting system is shown on the terminal" { + stub dialog + stub systemctl 'echo starting' + probe 30first + + run_on_terminal + + [ "$status" -eq 0 ] + grep -q -- '--infobox Waiting for the system to finish starting' \ + "$STUBS/dialog.calls" +} + +@test "nothing is drawn when the output is not a terminal" { + stub dialog + stub systemctl 'echo starting' + probe 30first + + run_runner + + [ "$status" -eq 0 ] + [ -z "$(calls dialog)" ] +} + +@test "a run whose output goes to the log draws nothing either" { + sed -i 's/REDIRECT_OUTPUT=false/REDIRECT_OUTPUT=true/' "$DEFAULT" + # the xen marker: the log is sent to the console by another service, + # so this run starts no tail of its own + mkdir -p "$ROOT/turnkey-info" + touch "$ROOT/turnkey-info/xen" + echo "TKLINFO=$ROOT/turnkey-info" >> "$DEFAULT" + stub dialog + probe 30first + + run_on_terminal + + [ "$status" -eq 0 ] + [ -z "$(calls dialog)" ] +} + +@test "everyboot hooks are not announced" { + stub dialog + mkdir -p "$LIB/everyboot.d" + cat > "$LIB/everyboot.d/01quiet" <&2; exit 255' + probe 15first + probe 30second + + run_on_terminal + + [ "$status" -eq 0 ] + [ "$(wc -l < "$SEEN")" -eq 2 ] + [[ "$output" != *"Error opening terminal"* ]] +} diff --git a/tests/test_dialog_brand.py b/tests/test_dialog_brand.py index f64b415..6f6efae 100644 --- a/tests/test_dialog_brand.py +++ b/tests/test_dialog_brand.py @@ -29,11 +29,13 @@ ] # 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), and the host the update screen checks it can reach, -# which it never shows. +# 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. ALLOWED = { "turnkey-install-security-updates", "archive.turnkeylinux.org", + "_TURNKEY_INIT", } FIRST_BOOT = "Keel Linux - First boot configuration" diff --git a/tests/test_dialog_wrapper.py b/tests/test_dialog_wrapper.py index 0e31d40..8af4bf6 100644 --- a/tests/test_dialog_wrapper.py +++ b/tests/test_dialog_wrapper.py @@ -291,6 +291,77 @@ def test_blacklist_leaving_no_letter_or_digit_falls_back_to_manual(self): self.assertEqual(d.console.calls[1][3]["title"], "Error") +KEEP = (OK, "Keep") +KEEP_TEXT = "Password set when the container was created (recommended)" + + +class TestGetPasswordKeep(unittest.TestCase): + def test_keep_is_offered_first_and_is_the_only_recommendation(self): + d = dialog(KEEP) + d.get_password("Root Password", "text", keep=KEEP_TEXT) + _, _, _, kwargs = d.console.calls[0] + tags = [tag for tag, _ in kwargs["choices"]] + self.assertEqual(tags, ["Keep", "Generate", "Manual"]) + self.assertEqual(kwargs["choices"][0][1], KEEP_TEXT) + recommended = [tag for tag, info in kwargs["choices"] + if "recommended" in info] + self.assertEqual(recommended, ["Keep"]) + + def test_the_menu_is_wide_enough_for_keep_on_an_80_column_console(self): + d = dialog(KEEP) + d.get_password("t", "x", keep=KEEP_TEXT) + _, _, args, _ = d.console.calls[0] + width = args[1] + # nothing cut off: the description, the tag column and the margin + self.assertEqual( + width, len(KEEP_TEXT) + len("Generate") + dw.MENU_MARGIN + ) + self.assertLessEqual(width, dw.MENU_MAX_WIDTH) + + def test_the_menu_keeps_its_width_without_keep(self): + d = dialog(GENERATE, OK, OK) + d.get_password("t", "x") + self.assertEqual(d.console.calls[0][2][1], d.width) + + def test_a_menu_wider_than_the_console_is_capped(self): + d = dialog((OK, "a")) + d.menu("t", "x", [("a", "y" * 200)]) + self.assertEqual(d.console.calls[0][2][1], dw.MENU_MAX_WIDTH) + + def test_keep_returns_none_and_asks_nothing_else(self): + d = dialog(KEEP) + self.assertIsNone(d.get_password("t", "x", keep=KEEP_TEXT)) + self.assertEqual(d.console.widgets(), ["menu"]) + + def test_generate_below_keep_still_shows_and_confirms(self): + d = dialog(GENERATE, OK, OK) + password = d.get_password("t", "x", keep=KEEP_TEXT) + self.assertEqual(len(password), 20) + self.assertEqual(d.console.widgets(), ["menu", "msgbox", "yesno"]) + + def test_manual_below_keep_is_the_password_box(self): + d = dialog(MANUAL, (OK, "Abcdefg1"), (OK, "Abcdefg1")) + self.assertEqual(d.get_password("t", "x", keep=KEEP_TEXT), "Abcdefg1") + + def test_escape_on_the_menu_with_keep_shows_it_again(self): + d = dialog((ESC, ""), KEEP) + self.assertIsNone(d.get_password("t", "x", keep=KEEP_TEXT)) + self.assertEqual(d.console.widgets(), ["menu", "menu"]) + + def test_no_keep_without_a_description(self): + d = dialog(GENERATE, OK, OK) + d.get_password("t", "x") + tags = [tag for tag, _ in d.console.calls[0][3]["choices"]] + self.assertEqual(tags, ["Generate", "Manual"]) + + def test_keep_without_the_menu_is_refused(self): + # offer_generate=False is the password box alone: there is no menu + # to put Keep on, and a caller asking for both is told so + with self.assertRaises(ValueError): + dialog().get_password("t", "x", offer_generate=False, + keep=KEEP_TEXT) + + class TestGetPasswordManual(unittest.TestCase): def test_manual_is_the_old_prompt_twice(self): d = dialog(MANUAL, (OK, "Abcdefg1"), (OK, "Abcdefg1")) diff --git a/tests/test_setpass.py b/tests/test_setpass.py index 24ab04a..0782b6d 100644 --- a/tests/test_setpass.py +++ b/tests/test_setpass.py @@ -4,9 +4,15 @@ before, "really quit?" and Yes exited 0 without calling chpasswd, and 30rootpass carried on with the root (or admin) password unchanged. chpasswd is replaced at the subprocess boundary and records what it was given. + +A password set before the first boot, by `pct create --password` or by LXC +in the root file system, is offered as Keep, first: passwd -S is replaced +at the same boundary, and it is the only thing asked about that password, +whose hash is never read. """ import importlib.util +import subprocess import unittest from os.path import abspath, dirname, join from unittest import mock @@ -17,6 +23,10 @@ SETPASS = join(dirname(dirname(abspath(__file__))), "bin", "setpass.py") +USABLE = "root P 2026-10-02 0 99999 7 -1\n" +LOCKED = "root L 2026-10-02 0 99999 7 -1\n" +EMPTY = "root NP 2026-10-02 0 99999 7 -1\n" + def load_setpass(): spec = importlib.util.spec_from_file_location("setpass", SETPASS) @@ -25,30 +35,48 @@ def load_setpass(): return module -class TestSetpass(unittest.TestCase): - def run_setpass(self, *answers): - """Run setpass.py root with the dialogs answering ANSWERS; return - what chpasswd read and the console""" +def passwd_answering(stdout: str = LOCKED, code: int = 0): + """A subprocess.run stand-in for `passwd -S` printing STDOUT""" + return mock.MagicMock( + return_value=subprocess.CompletedProcess([], code, stdout, "") + ) + + +class SetpassCase(unittest.TestCase): + def run_setpass(self, *answers, status=LOCKED, argv=("root",), + environ=None, container=True): + """Run setpass.py ARGV with the dialogs answering ANSWERS and + passwd -S printing STATUS; return what chpasswd read ("" when it + was not run), the console and the passwd -S stand-in""" setpass = load_setpass() console = FakeConsole(*answers) chpasswd = mock.MagicMock() chpasswd.return_value.communicate.return_value = (b"", b"") + passwd = passwd_answering(status) stdin = mock.MagicMock(encoding="utf-8") with ( mock.patch.object(dw.dialog, "Dialog", return_value=console), mock.patch.object(setpass.subprocess, "Popen", chpasswd), + mock.patch.object(setpass.subprocess, "run", passwd), mock.patch.object(setpass.signal, "signal"), - mock.patch.object(setpass.sys, "argv", ["setpass.py", "root"]), + mock.patch.object(setpass.sys, "argv", ["setpass.py", *argv]), mock.patch.object(setpass.sys, "stdin", stdin), + mock.patch.dict(setpass.os.environ, environ or {}, clear=True), + mock.patch.object(setpass.os.path, "exists", + return_value=container), ): setpass.main() + if not chpasswd.called: + return "", console, passwd chpasswd.assert_called_once() self.assertEqual(chpasswd.call_args.args[0], ["chpasswd"]) given = chpasswd.return_value.communicate.call_args.args[0] - return given.decode(), console + return given.decode(), console, passwd + +class TestSetpass(SetpassCase): def test_escape_in_the_password_box_still_sets_the_password(self): - given, console = self.run_setpass( + given, console, _ = self.run_setpass( (OK, "Manual"), (ESC, ""), (OK, "Abcdefg1"), @@ -58,7 +86,7 @@ def test_escape_in_the_password_box_still_sets_the_password(self): self.assertNotIn("yesno", console.widgets()) def test_escape_in_the_generate_flow_still_sets_the_password(self): - given, console = self.run_setpass( + given, console, _ = self.run_setpass( (ESC, ""), (OK, "Generate"), ESC, OK, ESC, OK ) user, _, password = given.partition(":") @@ -73,5 +101,112 @@ def test_escape_in_the_generate_flow_still_sets_the_password(self): ) +class TestKeepThePasswordOfTheContainer(SetpassCase): + def menu_tags(self, console) -> list[str]: + return [tag for tag, _ in console.calls[0][3]["choices"]] + + def test_a_usable_password_is_offered_as_keep_first(self): + _, console, passwd = self.run_setpass((OK, "Keep"), status=USABLE) + self.assertEqual(self.menu_tags(console), ["Keep", "Generate", "Manual"]) + keep = console.calls[0][3]["choices"][0][1] + self.assertIn("set when the container was created", keep) + self.assertIn("recommended", keep) + passwd.assert_called_once() + self.assertEqual(passwd.call_args.args[0], ["passwd", "-S", "root"]) + + def test_keep_leaves_the_password_alone(self): + given, console, _ = self.run_setpass((OK, "Keep"), status=USABLE) + self.assertEqual(given, "") + self.assertEqual(console.widgets(), ["menu"]) + + def test_generate_below_keep_replaces_it(self): + given, console, _ = self.run_setpass( + (OK, "Generate"), OK, OK, status=USABLE + ) + user, _, password = given.partition(":") + self.assertEqual((user, len(password)), ("root", dw.GENERATED_LENGTH)) + self.assertEqual(console.widgets(), ["menu", "msgbox", "yesno"]) + + def test_manual_below_keep_replaces_it(self): + given, _, _ = self.run_setpass( + (OK, "Manual"), (OK, "Abcdefg1"), (OK, "Abcdefg1"), status=USABLE + ) + self.assertEqual(given, "root:Abcdefg1") + + def test_a_locked_password_is_not_offered(self): + _, console, _ = self.run_setpass( + (OK, "Generate"), OK, OK, status=LOCKED + ) + self.assertEqual(self.menu_tags(console), ["Generate", "Manual"]) + + def test_an_empty_password_is_not_offered(self): + _, console, _ = self.run_setpass( + (OK, "Generate"), OK, OK, status=EMPTY + ) + self.assertEqual(self.menu_tags(console), ["Generate", "Manual"]) + + def test_passwd_failing_is_not_offered(self): + setpass = load_setpass() + for failure in ( + passwd_answering("", code=1), + mock.MagicMock(side_effect=OSError("no passwd")), + mock.MagicMock(side_effect=subprocess.TimeoutExpired("passwd", 1)), + ): + with mock.patch.object(setpass.subprocess, "run", failure): + self.assertFalse(setpass.password_usable("root")) + + def test_the_hash_is_never_asked_for(self): + # passwd -S prints the status; no other command, no file is read + setpass = load_setpass() + passwd = passwd_answering(USABLE) + with ( + mock.patch.object(setpass.subprocess, "run", passwd), + mock.patch("builtins.open") as opened, + ): + self.assertTrue(setpass.password_usable("root")) + opened.assert_not_called() + self.assertEqual(passwd.call_args.args[0], ["passwd", "-S", "root"]) + + def test_outside_a_container_keep_names_this_machine(self): + _, console, _ = self.run_setpass( + (OK, "Keep"), status=USABLE, container=False + ) + keep = console.calls[0][3]["choices"][0][1] + self.assertNotIn("container", keep) + self.assertIn("already set on this machine", keep) + + def test_every_keep_text_fits_the_widest_menu(self): + # a box 76 wide on an 80 column console cut the first wording off + # at "(recommende" + setpass = load_setpass() + for text in (setpass.KEEP_CONTAINER, setpass.KEEP_MACHINE): + self.assertLessEqual( + len("Generate") + len(text) + dw.MENU_MARGIN, + dw.MENU_MAX_WIDTH, + ) + + def test_keel_init_asks_as_before(self): + # an explicit run is there to set the password: no Keep + _, console, passwd = self.run_setpass( + (OK, "Generate"), OK, OK, status=USABLE, + environ={"_TURNKEY_INIT": "y"}, + ) + self.assertEqual(self.menu_tags(console), ["Generate", "Manual"]) + passwd.assert_not_called() + + def test_a_preseeded_password_asks_nothing(self): + given, console, passwd = self.run_setpass( + status=USABLE, argv=("root", "--pass=Preseeded1") + ) + self.assertEqual(given, "root:Preseeded1") + self.assertEqual(console.calls, []) + passwd.assert_not_called() + + def test_the_admin_account_is_asked_about_itself(self): + _, _, passwd = self.run_setpass((OK, "Keep"), status=USABLE, + argv=("admin",)) + self.assertEqual(passwd.call_args.args[0], ["passwd", "-S", "admin"]) + + if __name__ == "__main__": unittest.main() From bd1d11283b6b668ba64e20b0c9f27ee057b76dd1 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 14:16:18 +0000 Subject: [PATCH 2/4] test: the first boot pty test runs as a user who cannot write /var/log dialog_wrapper logged to /var/log/dialog.log unconditionally, so setpass.py failed for the CI runner's user and every password screen of the pty test was missing. DIALOG_LOG names another file; the test points it into its scratch directory and lets PYTHONPATH carry what the caller set. The Keep assertion reads the recommendation, which a runner outside a container shows too. --- libinithooks/dialog_wrapper.py | 6 +++++- tests/test-firstboot-pty.bats | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/libinithooks/dialog_wrapper.py b/libinithooks/dialog_wrapper.py index 08992b3..3b1f0ea 100644 --- a/libinithooks/dialog_wrapper.py +++ b/libinithooks/dialog_wrapper.py @@ -21,8 +21,12 @@ if "DIALOG_DEBUG" in environ.keys(): LOG_LEVEL = logging.DEBUG +# DIALOG_LOG names another file, for tests that run a hook as a user who +# cannot write /var/log logging.basicConfig( - filename="/var/log/dialog.log", encoding="utf-8", level=LOG_LEVEL + filename=environ.get("DIALOG_LOG", "/var/log/dialog.log"), + encoding="utf-8", + level=LOG_LEVEL, ) diff --git a/tests/test-firstboot-pty.bats b/tests/test-firstboot-pty.bats index 7903ee9..04d62c8 100644 --- a/tests/test-firstboot-pty.bats +++ b/tests/test-firstboot-pty.bats @@ -66,7 +66,8 @@ EOF export INITHOOKS_DEFAULT=$DEFAULT export INITHOOKS_LOCK=$ROOT/inithooks.lock export INITHOOKS_COMPLETE=$ROOT/inithooks-complete - export PYTHONPATH=$REPO + export PYTHONPATH=$REPO${PYTHONPATH:+:$PYTHONPATH} + export DIALOG_LOG=$ROOT/dialog.log export TERM=linux LINES=25 COLUMNS=80 : > "$SCREEN" } @@ -174,7 +175,8 @@ assert_next_screen() { [ "$status" -eq 0 ] assert_next_screen - grep -qa 'container' "$SCREEN" + # Keep was offered, and only Keep is recommended when it is + grep -qa '(recommended)' "$SCREEN" [ ! -e "$ROOT/chpasswd" ] } From 575fd13d4f5f8070389a1aaedd1e1d1e95132414 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 14:16:27 +0000 Subject: [PATCH 3/4] docs: changelog names DIALOG_LOG --- debian/changelog | 3 +++ 1 file changed, 3 insertions(+) diff --git a/debian/changelog b/debian/changelog index 27346b8..390c9f1 100644 --- a/debian/changelog +++ b/debian/changelog @@ -37,6 +37,9 @@ inithooks (2.3.6+keel17) trixie; urgency=medium password each go on to the next hook's screen. A run that stops answering fails it, and so does a screen drawn into a pipe, which never reaches the pty. CI installs dialog and python3-dialog for it. + DIALOG_LOG names the file dialog_wrapper logs to (default + /var/log/dialog.log), so the test runs as a user who cannot write + /var/log. -- Marcos Mendez Fri, 02 Oct 2026 16:00:00 +0000 From 6d5eccc98610db08828bbefef5f0cb2ca25e52e2 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 14:47:07 +0000 Subject: [PATCH 4/4] fix: Keep only a root password the image did not ship Review of #35: passwd -S says P for a password a build with ROOT_PASS left, and for U6aMy0wojraho, which five older WordPress images ship, so Keep was offered as recommended for a password every copy shares. Keep now also needs the image's build date (/etc/keel/build-date, from common's seal-root, which fails a build whose root is not locked) and a last change on or after it, and a shadow field that is neither empty nor a known placeholder. The field is compared, never printed or logged; an image without the date gets no Keep. --- bin/setpass.py | 67 ++++++++++++- debian/changelog | 11 ++- tests/test-firstboot-pty.bats | 27 ++++++ tests/test_setpass.py | 172 +++++++++++++++++++++++++++++----- 4 files changed, 249 insertions(+), 28 deletions(-) diff --git a/bin/setpass.py b/bin/setpass.py index d4a9a01..138bba8 100755 --- a/bin/setpass.py +++ b/bin/setpass.py @@ -9,12 +9,18 @@ -p --pass= if not provided, will ask interactively Asked interactively at first boot, an account that can already log in with -a password (`pct create --password`, or LXC writing /etc/shadow in the root -file system before the first boot) is offered Keep, first: the password -stays as it is. Only `passwd -S` is asked, for the status; the password and -its hash are never read. keel-init (_TURNKEY_INIT) asks as before. +a password set when the container was created (`pct create --password`, or +LXC writing /etc/shadow in the root file system before the first boot) is +offered Keep, first: the password stays as it is. Only one the image did +not ship: `passwd -S` says it is usable, the image carries its build date +(/etc/keel/build-date, written by common's seal-root, which fails a build +whose root is not locked) and the password last changed on or after it, +and the shadow field is neither empty nor a placeholder older images +shipped. The field is compared and never printed or logged. keel-init +(_TURNKEY_INIT) asks as before. """ +import datetime import os import sys import getopt @@ -29,6 +35,16 @@ PASSWD_TIMEOUT = 10 # systemd writes it in a container, whatever the container manager CONTAINER_MARKER = "/run/systemd/container" +# Where the account database and the image's build date are read; the +# variables point a test at scratch files. +SHADOW = "/etc/shadow" +BUILD_DATE = "/etc/keel/build-date" +SHADOW_VAR = "INITHOOKS_SHADOW" +BUILD_DATE_VAR = "INITHOOKS_BUILD_DATE" +EPOCH = datetime.date(1970, 1, 1) +# Password fields images shipped: the crypt() of the empty string, in five +# older WordPress images. +PLACEHOLDERS = frozenset({"U6aMy0wojraho"}) EXPLICIT_RUN = "_TURNKEY_INIT" # Each fits beside the Generate tag in the widest menu dialog_wrapper draws KEEP_CONTAINER = "Password set when the container was created (recommended)" @@ -70,10 +86,53 @@ def password_usable(username: str) -> bool: return out.returncode == 0 and len(fields) > 1 and fields[1] == USABLE +def build_day(path: str) -> int | None: + """The day the image was built, in days since 1970-01-01 as shadow + counts them, from PATH (YYYY-MM-DD); None when it cannot be read""" + try: + with open(path) as fob: + built = datetime.date.fromisoformat(fob.read().strip()) + except (OSError, ValueError): + return None + return (built - EPOCH).days + + +def set_after_build(username: str) -> bool: + """Whether USERNAME's password was set on this machine, not shipped + + Its shadow entry must hold a field that is neither empty nor one of + PLACEHOLDERS, last changed on or after the build day. The same day + counts: shadow keeps days, a container is often created the day its + image was built, and the image left the build with root locked. + """ + built = build_day(os.environ.get(BUILD_DATE_VAR, BUILD_DATE)) + if built is None: + return False + try: + with open(os.environ.get(SHADOW_VAR, SHADOW)) as fob: + entry = next( + (line.rstrip("\n").split(":") for line in fob + if line.split(":", 1)[0] == username), + None, + ) + except OSError: + return False + if entry is None or len(entry) < 3: + return False + if not entry[1] or entry[1] in PLACEHOLDERS: + return False + try: + return int(entry[2]) >= built > 0 + except ValueError: + return False + + def keep_offer(username: str) -> str: """The description of Keep for USERNAME, or "" for no Keep""" if os.environ.get(EXPLICIT_RUN) or not password_usable(username): return "" + if not set_after_build(username): + return "" if os.path.exists(CONTAINER_MARKER): return KEEP_CONTAINER return KEEP_MACHINE diff --git a/debian/changelog b/debian/changelog index 390c9f1..9a786b7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -22,8 +22,15 @@ inithooks (2.3.6+keel17) trixie; urgency=medium the operator replace it. The images ship root locked; when root can already log in with a password at first boot, the password screen offers Keep first, as the default and the recommendation, with - Generate and Manual below it. Only passwd -S is asked, for the - status: the password and its hash are never read, printed or logged. + Generate and Manual below it. Only a password the image did not ship: + passwd -S must say it is usable, the image must carry its build date + (/etc/keel/build-date, from common's seal-root, which fails a build + whose root is not locked) and the password must have changed on or + after it, and the shadow field must be neither empty nor + U6aMy0wojraho, which older WordPress images shipped; a build with + ROOT_PASS, or an image without the date, gets no Keep. The field is + compared, never printed or logged. INITHOOKS_SHADOW and + INITHOOKS_BUILD_DATE point the tests at scratch files. Without such a password the screen is as before, a preseeded ROOT_PASS or a declared secrets.root_password still draws no screen, and keel-init still asks for a new one. Dialog.get_password() takes diff --git a/tests/test-firstboot-pty.bats b/tests/test-firstboot-pty.bats index 04d62c8..4c82b8c 100644 --- a/tests/test-firstboot-pty.bats +++ b/tests/test-firstboot-pty.bats @@ -68,10 +68,22 @@ EOF export INITHOOKS_COMPLETE=$ROOT/inithooks-complete export PYTHONPATH=$REPO${PYTHONPATH:+:$PYTHONPATH} export DIALOG_LOG=$ROOT/dialog.log + # an image built today, whose root got a password today, after it + export INITHOOKS_BUILD_DATE=$ROOT/build-date + export INITHOOKS_SHADOW=$ROOT/shadow + date -u +%F > "$INITHOOKS_BUILD_DATE" + shadow_root '$y$j9T$scratch$scratchhashscratchhash' export TERM=linux LINES=25 COLUMNS=80 : > "$SCREEN" } +# shadow_root FIELD +# The scratch shadow file: root with FIELD, last changed today. +shadow_root() { + printf 'root:%s:%d:0:99999:7:::\n' "$1" $(( $(date -u +%s) / 86400 )) \ + > "$INITHOOKS_SHADOW" +} + # passwd_status STATUS # passwd -S answers STATUS (P usable, L locked, NP empty) for any account. passwd_status() { @@ -180,6 +192,21 @@ assert_next_screen() { [ ! -e "$ROOT/chpasswd" ] } +@test "a password the image shipped is not offered to keep" { + # passwd -S says P for it all the same + passwd_status P + shadow_root 'U6aMy0wojraho' + + run first_boot 'Choose' '\r' 'manager.' '\r' 'discard' '\r' \ + 'NEXT-SCREEN-SHOWN' '' + + [ "$status" -eq 0 ] + assert_next_screen + [ "$(cat "$ROOT/chpasswd")" = root ] + run ! grep -qa 'U6aMy0wojraho' "$SCREEN" "$ROOT/inithooks.log" \ + "$DIALOG_LOG" +} + @test "Generate below Keep replaces the password set at creation" { passwd_status P diff --git a/tests/test_setpass.py b/tests/test_setpass.py index 0782b6d..6b91cf1 100644 --- a/tests/test_setpass.py +++ b/tests/test_setpass.py @@ -6,14 +6,24 @@ is replaced at the subprocess boundary and records what it was given. A password set before the first boot, by `pct create --password` or by LXC -in the root file system, is offered as Keep, first: passwd -S is replaced -at the same boundary, and it is the only thing asked about that password, -whose hash is never read. +in the root file system, is offered as Keep, first, but only one the image +did not ship: passwd -S must say it is usable, the image must carry its +build date (/etc/keel/build-date, written by common's seal-root) and the +password must have changed on or after it, and the shadow field must be +neither empty nor a known placeholder. passwd -S is replaced at the +subprocess boundary; the shadow file and the build date are scratch files. +The field is compared, never printed or logged. """ +import datetime import importlib.util +import io +import logging +import os import subprocess +import tempfile import unittest +from contextlib import contextmanager, redirect_stderr, redirect_stdout from os.path import abspath, dirname, join from unittest import mock @@ -27,6 +37,11 @@ LOCKED = "root L 2026-10-02 0 99999 7 -1\n" EMPTY = "root NP 2026-10-02 0 99999 7 -1\n" +BUILT = datetime.date(2026, 10, 2) +BUILD_DAY = (BUILT - datetime.date(1970, 1, 1)).days +HASH = "$y$j9T$scratchsaltscratch$scratchhashscratchhashscratchhash12" +PLACEHOLDER = "U6aMy0wojraho" + def load_setpass(): spec = importlib.util.spec_from_file_location("setpass", SETPASS) @@ -35,6 +50,26 @@ def load_setpass(): return module +@contextmanager +def capture_logs(): + """Every record logged meanwhile, at any level, as text""" + records: list[str] = [] + + class Keep(logging.Handler): + def emit(self, record): + records.append(record.getMessage()) + + root = logging.getLogger() + handler, level = Keep(level=logging.DEBUG), root.level + root.addHandler(handler) + root.setLevel(logging.DEBUG) + try: + yield records + finally: + root.removeHandler(handler) + root.setLevel(level) + + def passwd_answering(stdout: str = LOCKED, code: int = 0): """A subprocess.run stand-in for `passwd -S` printing STDOUT""" return mock.MagicMock( @@ -43,8 +78,43 @@ def passwd_answering(stdout: str = LOCKED, code: int = 0): class SetpassCase(unittest.TestCase): + def setUp(self): + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + self.dir = scratch.name + self.shadow = join(self.dir, "shadow") + self.build_date = join(self.dir, "build-date") + self.marker = join(self.dir, "container") + self.write_shadow() + self.write_build_date(BUILT.isoformat()) + self.in_container(True) + + def write_shadow(self, field=HASH, changed=BUILD_DAY + 1, user="root"): + """A shadow file whose USER entry has FIELD, last changed on day + CHANGED (an int, or the text of the field)""" + with open(self.shadow, "w") as fob: + fob.write("daemon:*:20000:0:99999:7:::\n") + fob.write(f"{user}:{field}:{changed}:0:99999:7:::\n") + + def write_build_date(self, text): + with open(self.build_date, "w") as fob: + fob.write(text + "\n") + + def in_container(self, yes): + if yes: + open(self.marker, "w").close() + elif os.path.exists(self.marker): + os.remove(self.marker) + + def env(self, extra=None): + return { + "INITHOOKS_SHADOW": self.shadow, + "INITHOOKS_BUILD_DATE": self.build_date, + **(extra or {}), + } + def run_setpass(self, *answers, status=LOCKED, argv=("root",), - environ=None, container=True): + environ=None): """Run setpass.py ARGV with the dialogs answering ANSWERS and passwd -S printing STATUS; return what chpasswd read ("" when it was not run), the console and the passwd -S stand-in""" @@ -54,6 +124,7 @@ def run_setpass(self, *answers, status=LOCKED, argv=("root",), chpasswd.return_value.communicate.return_value = (b"", b"") passwd = passwd_answering(status) stdin = mock.MagicMock(encoding="utf-8") + self.printed = io.StringIO() with ( mock.patch.object(dw.dialog, "Dialog", return_value=console), mock.patch.object(setpass.subprocess, "Popen", chpasswd), @@ -61,11 +132,18 @@ def run_setpass(self, *answers, status=LOCKED, argv=("root",), mock.patch.object(setpass.signal, "signal"), mock.patch.object(setpass.sys, "argv", ["setpass.py", *argv]), mock.patch.object(setpass.sys, "stdin", stdin), - mock.patch.dict(setpass.os.environ, environ or {}, clear=True), - mock.patch.object(setpass.os.path, "exists", - return_value=container), + mock.patch.dict(setpass.os.environ, self.env(environ), + clear=True), + mock.patch.object(setpass, "CONTAINER_MARKER", self.marker), + redirect_stderr(self.printed), + redirect_stdout(self.printed), + capture_logs() as self.logged, ): setpass.main() + # whatever happened, the shadow field went nowhere + for said in (self.printed.getvalue(), *self.logged, console.shown()): + self.assertNotIn(HASH, said) + self.assertNotIn(PLACEHOLDER, said) if not chpasswd.called: return "", console, passwd chpasswd.assert_called_once() @@ -155,22 +233,70 @@ def test_passwd_failing_is_not_offered(self): with mock.patch.object(setpass.subprocess, "run", failure): self.assertFalse(setpass.password_usable("root")) - def test_the_hash_is_never_asked_for(self): - # passwd -S prints the status; no other command, no file is read + def assert_not_offered(self): + _, console, _ = self.run_setpass( + (OK, "Generate"), OK, OK, status=USABLE + ) + self.assertEqual(self.menu_tags(console), ["Generate", "Manual"]) + + # The image must not have shipped the password (review of #35): a + # build with ROOT_PASS, or an older WordPress image with the + # placeholder, has passwd -S say P all the same. + + def test_a_password_changed_on_the_build_day_is_offered(self): + # common's seal-root fails a build whose root is not locked, so a + # usable password on a stamped image was set after it; a container + # created the day the image was built is the maintainer's case + self.write_shadow(changed=BUILD_DAY) + _, console, _ = self.run_setpass((OK, "Keep"), status=USABLE) + self.assertEqual(self.menu_tags(console)[0], "Keep") + + def test_a_password_older_than_the_image_is_not_offered(self): + self.write_shadow(changed=BUILD_DAY - 1) + self.assert_not_offered() + + def test_an_image_without_a_build_date_offers_nothing(self): + os.remove(self.build_date) + self.assert_not_offered() + + def test_an_unreadable_build_date_offers_nothing(self): + self.write_build_date("yesterday") + self.assert_not_offered() + + def test_the_placeholder_of_older_images_is_not_offered(self): + self.write_shadow(field=PLACEHOLDER) + self.assert_not_offered() + + def test_an_empty_field_is_not_offered_whatever_passwd_says(self): + self.write_shadow(field="") + self.assert_not_offered() + + def test_a_last_change_of_zero_or_none_is_not_offered(self): + for changed in (0, "", "x"): + with self.subTest(changed=changed): + self.write_shadow(changed=changed) + self.assert_not_offered() + + def test_an_account_missing_from_shadow_is_not_offered(self): + self.write_shadow(user="someoneelse") + self.assert_not_offered() + + def test_a_short_shadow_entry_is_not_offered(self): + with open(self.shadow, "w") as fob: + fob.write("root\n") + self.assert_not_offered() + + def test_an_unreadable_shadow_file_is_not_offered(self): + os.remove(self.shadow) + self.assert_not_offered() + + def test_the_build_date_is_read_as_utc_days(self): setpass = load_setpass() - passwd = passwd_answering(USABLE) - with ( - mock.patch.object(setpass.subprocess, "run", passwd), - mock.patch("builtins.open") as opened, - ): - self.assertTrue(setpass.password_usable("root")) - opened.assert_not_called() - self.assertEqual(passwd.call_args.args[0], ["passwd", "-S", "root"]) + self.assertEqual(setpass.build_day(self.build_date), BUILD_DAY) def test_outside_a_container_keep_names_this_machine(self): - _, console, _ = self.run_setpass( - (OK, "Keep"), status=USABLE, container=False - ) + self.in_container(False) + _, console, _ = self.run_setpass((OK, "Keep"), status=USABLE) keep = console.calls[0][3]["choices"][0][1] self.assertNotIn("container", keep) self.assertIn("already set on this machine", keep) @@ -203,8 +329,10 @@ def test_a_preseeded_password_asks_nothing(self): passwd.assert_not_called() def test_the_admin_account_is_asked_about_itself(self): - _, _, passwd = self.run_setpass((OK, "Keep"), status=USABLE, - argv=("admin",)) + self.write_shadow(user="admin") + _, console, passwd = self.run_setpass((OK, "Keep"), status=USABLE, + argv=("admin",)) + self.assertEqual(self.menu_tags(console)[0], "Keep") self.assertEqual(passwd.call_args.args[0], ["passwd", "-S", "admin"])