diff --git a/COVERAGE.md b/COVERAGE.md index 600605b..a09880f 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -4,6 +4,25 @@ Measured on 2026-09-24 against upstream master (33c43b8), following the project decision 0003 (90 percent floor per repository, 95 percent for every file our changes touch). +## Branch fix/headless-first-boot: shell 99.69, Python 99 (2026-10-03) + +A first boot nobody can answer, the hosts entry and the certificate's +name. `lib/console.sh` is new, 22/22, and `lib/sslcert.sh`, 51/51, takes +the body of `firstboot.d/15regen-sslcert` (9/9 now). The new +`tests/test-console.bats` (23 tests) runs the rule on ptys whose master +is never read, unsized and sized, on a read pty under `script`, on the +controlling terminal and with none, and every hook that draws a screen +on both unread ptys within a deadline, its screen a stand-in that never +returns (31fqdn's the real `bin/fqdn.py`). The python pty harnesses of +this file and `test-run.bats` keep kcov's trace descriptor open +(`close_fds=False`): with it closed, what ran under them was not +measured. `firstboot.d/31fqdn` 43/43, with the hosts entry after SKIP, an +empty answer and nobody to answer, and the certificate made again after +a rename (real openssl), kept when it is for the name, signed by an +authority, or unreadable. `bin/fqdn.py` and `libinithooks/fqdn.py` stay +at 100 percent with `--machine`, `in_hosts` and `machine`. 367 bats, 530 +pytest; shell total 99.69, Python 99. + ## Branch fix/first-boot-without-journal-or-console: shell 99.64 (2026-10-03) Two first boot stalls of the published core booted headless. diff --git a/README.rst b/README.rst index 4326b2f..c69421e 100644 --- a/README.rst +++ b/README.rst @@ -378,14 +378,40 @@ configuration variables into it before the first system boot. For example:: export DB_PASS=supersecretmysqlpass export APP_EMAIL=admin@example.com export APP_PASS=webappadminpassword + export FQDN=blog.example.org export SEC_ALERTS=admin@example.com export SEC_UPDATES=FORCE export HUB_APIKEY=SKIP EOF +FQDN is the machine's fully qualified domain name (firstboot.d/31fqdn): it +becomes the hostname, the /etc/hosts entry and the name of the self-signed +certificate, and is recorded in the instance description. FQDN=SKIP keeps +the name the machine has. + This preseeding mechanism makes it relatively easy to integrate TurnKey with custom control panels, virtualization solutions, etc. +A first boot nobody can answer skips the questions. When the console has +no size (the tty of an LXC container nobody is attached to with pct +console or lxc-console), does not take a write (one nobody reads), or +there is no terminal at all, every hook that would draw a screen asks +nothing and does what it does without an answer, saying so in one line +of /var/log/inithooks.log and the journal (lib/console.sh): + +- 30rootpass keeps the password the machine has (pct create --password), + or none; +- 31fqdn keeps the name the machine has and records it in the instance + description when it has a domain; +- 75keel-role and 80keel-cloud leave the node standalone, without a Keel + Cloud key; +- 85secalerts sets no alert email; +- 95secupdates installs the security updates, and 99reboot reboots for a + new kernel, as SEC_UPDATES=FORCE does. + +A preseeded value is always used. Run keel-init afterwards to answer what +was skipped. + Don't worry about leaving sensitive passwords in there: after the first boot, inithooks blanks /etc/inithooks.conf out so important passwords aren't accidentally left in the clear. Although obviously if the conf file is diff --git a/bin/fqdn.py b/bin/fqdn.py index 9d63cf8..a5de971 100755 --- a/bin/fqdn.py +++ b/bin/fqdn.py @@ -2,9 +2,9 @@ # Copyright (c) 2026 Keel Linux maintainers """Ask the machine's fully qualified domain name, and record it -Run by firstboot.d/31fqdn, three times: to ask, to record the answer in -the instance description, and, once the machine is renamed, to write the -/etc/hosts entry. +Run by firstboot.d/31fqdn: to ask, to record the answer in the instance +description, and, once the machine is renamed, to write the /etc/hosts +entry; and, when the machine keeps its name, to say what that name is. Options: --fqdn= the name; if not provided, will ask interactively, @@ -17,6 +17,9 @@ --hosts write the name given with --hostname and --fqdn into the hosts file, and ask nothing --hostname= with --record or --hosts: the hostname + --machine print the name the machine has, given with --current, + the way an answer is printed, and ask nothing: its + domain is the one its line in the hosts file gives it Asked or preseeded, the answer is printed as two lines for the hook: HOSTNAME= and FQDN=. The @@ -116,6 +119,17 @@ def record(hostname: str, name: str) -> None: fatal(e) +def machine(current: str) -> None: + """Print the name the machine has, as an answer is printed""" + try: + text = fqdn.read_hosts(hosts_path()) + except fqdn.FqdnError as e: + fatal(e) + hostname, name = fqdn.machine(current, text) + print(f"HOSTNAME={hostname}") + print(f"FQDN={name}") + + def hosts(hostname: str, name: str) -> None: try: fqdn.write_hosts(hosts_path(), hostname, name) @@ -127,7 +141,7 @@ def main(): signal.signal(signal.SIGINT, signal.SIG_IGN) try: l_opts = ["help", "fqdn=", "current=", "record", "hosts", - "hostname="] + "hostname=", "machine"] opts, args = getopt.gnu_getopt(sys.argv[1:], "h", l_opts) except getopt.GetoptError as e: usage(e) @@ -146,9 +160,14 @@ def main(): current = val elif opt == "--hostname": hostname = val - else: # --record or --hosts, the writes + else: # --record, --hosts or --machine, which ask nothing action = opt + if action == "--machine": + if not current: + usage("--machine needs --current") + machine(current) + return if action: if not hostname: usage(f"{action} needs --hostname") diff --git a/debian/changelog b/debian/changelog index a13c40e..2fcb994 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,38 @@ +inithooks (2.3.6+keel22) trixie; urgency=medium + + * A first boot nobody can answer asks nothing. The Web 19.0-3 booted + headless in an LXC container (2026-10-03) stopped for good at 31fqdn, + whose screen waited on a tty1 nobody was attached to. lib/console.sh + is now the one rule, for run's notices and for every hook that draws + a screen: nobody can answer a console with no size (stty 0 0), one + that does not take a probe larger than a pty's buffer within 2 s, or + no terminal at all. run asks once and hands the answer to the hooks + (INITHOOKS_UNATTENDED), whose output then goes to the inithooks log. + Each hook does what it does without an answer and says so in one + line: 30rootpass keeps the password, 31fqdn keeps the name and + records it as instance.hostname and instance.fqdn when it has a + domain, 75keel-role and 80keel-cloud run confconsole's screens + without a terminal (the node stays standalone, a preseeded key is + still stored), 85secalerts sets no email, 95secupdates installs and + 99reboot reboots as SEC_UPDATES=FORCE does. The README's preseed + example lists FQDN and says so. + * The /etc/hosts entry for the machine's name is written on every first + boot. The image ships none (common's seal-hostname), and 31fqdn wrote + it only for an answer, so after FQDN=SKIP or an empty answer hostname + -f could fail. 31fqdn now always ends with `127.0.1.1 + `, or `127.0.1.1 ` without a domain, the form keel + spec apply --system writes; a kept name keeps the domain its hosts + line already gives it (fqdn.py --machine). + * The self-signed certificate is for the machine's name. Given no + names, turnkey-make-ssl-cert takes them from `hostname -A`, a reverse + lookup of the machine's addresses, and a Web container named web + served CN=core. 15regen-sslcert now gives it the fqdn and the + hostname (lib/sslcert.sh) and --ip for the addresses, and 31fqdn + makes the certificate again when the name it settles is not the CN of + a self-signed one; one an authority signed is kept. + + -- Marcos Mendez Sat, 03 Oct 2026 18:00:00 +0000 + inithooks (2.3.6+keel21) trixie; urgency=medium * A first boot without a journal finishes. 15regen-sslcert ran under diff --git a/firstboot.d/15regen-sslcert b/firstboot.d/15regen-sslcert index 25d3cd8..bda8ddc 100755 --- a/firstboot.d/15regen-sslcert +++ b/firstboot.d/15regen-sslcert @@ -1,71 +1,21 @@ #!/bin/bash -e -# Regenerate self-signed TLS/SSL cert & key +# Regenerate self-signed TLS/SSL cert & key, for the name the machine has +# (lib/sslcert.sh); 31fqdn makes it again when it settles another name. [[ -n "$_TURNKEY_INIT" ]] && exit 0 -[[ -e $INITHOOKS_CONF ]] && . "$INITHOOKS_CONF" - -_hook=$(basename "$0") - - -# The journal is a side effect of a hook whose job is the certificate: -# under -e a logger that fails (journald down, "socket /dev/log: Connection -# refused", the published core 19.0-6 booted headless on 2026-10-03) killed -# the hook at its first line, so it may not fail the hook. -log() { - local level=$1 - shift - logger -t inithooks -p "$level" "[$_hook] $*" 2>/dev/null || true -} - -fatal() { log 3 "$*"; echo "FATAL: [$_hook] $*" 1>&2 ; exit 1 ; } -info() { log 5 "$*"; echo "INFO: [$_hook] $*" ; } - +INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" +# shellcheck source=default/inithooks +source "$INITHOOKS_DEFAULT" +# shellcheck source=lib/sslcert.sh +source "$INITHOOKS_PATH/lib/sslcert.sh" -# Check for 'turnkey-make-ssl-cert' - should be provided by -# turnkey-ssl package. -turnkey_make_ssl_cert=$(which turnkey-make-ssl-cert) \ - || fatal "turnkey-make-ssl-cert executable not found." - -# We use predefined 4096 bits default dhparams file for TLS1.2 (not needed for -# TLS1.3) -# See https://github.com/turnkeylinux/tracker/issues/1653 for more info. -info "Generating SSL/TLS cert & key." -$turnkey_make_ssl_cert --default --force - -# Restart relevant services -SERVICES=(nginx apache2 lighttpd tomcat10 tomcat11 webmin) - -# make sure that generated keys are ready to use - avoids occasional race -# condition where cert & key don't (yet) match. a single sleep should be plenty -# but let's be sure -# modulus_md5 KIND FILE: the md5 of the modulus of the x509 or rsa FILE -modulus_md5() { openssl "$1" -noout -modulus -in "$2" | openssl md5; } - -for _wait in {1..5}; do - cert_md5=$(modulus_md5 x509 /etc/ssl/private/cert.pem) - key_md5=$(modulus_md5 rsa /etc/ssl/private/cert.key) - if [[ "$cert_md5" == "$key_md5" ]]; then - info "SSL cert and key have been written - ready to restart services" - break - elif [[ $_wait -eq 1 ]]; then - info "Waiting for updated ssl cert & key to be written to disk" - else - echo "..." - fi - sleep 1 -done +[[ -e $INITHOOKS_CONF ]] && . "$INITHOOKS_CONF" -info "Restarting relevant services." -for service in "${SERVICES[@]}"; do - # only restart services that are running - if systemctl is-active --quiet "${service}.service"; then - info "$service running; restarting..." - systemctl restart --quiet "$service" - fi -done +HOOK=$(basename "$0") -# final tidy up -update-ca-certificates +names=$(sslcert_names) +# shellcheck disable=SC2086 # the names are words +sslcert_make $names exit 0 diff --git a/firstboot.d/30rootpass b/firstboot.d/30rootpass index 3c5528f..cf44561 100755 --- a/firstboot.d/30rootpass +++ b/firstboot.d/30rootpass @@ -4,7 +4,9 @@ # 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. +# --password, or LXC in the root file system) when there is one. When +# nobody can answer the console (lib/console.sh), the password stays as +# the machine has it, set before the first boot or locked, as Keep does. USERNAME=root @@ -13,6 +15,14 @@ INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" . "$INITHOOKS_DEFAULT" [ "$(echo "$SUDOADMIN" | tr '[:upper:]' '[:lower:]')" = "true" ] && USERNAME="admin" +# shellcheck source=lib/console.sh +. "$INITHOOKS_PATH/lib/console.sh" + # shellcheck disable=SC1090 [ -e "$INITHOOKS_CONF" ] && . "$INITHOOKS_CONF" + +if [ -z "$ROOT_PASS" ] && console_unattended; then + console_skipped 30rootpass "the $USERNAME password stays as the machine has it (pct create --password, or locked); keel-init asks it" + exit 0 +fi "$INITHOOKS_PATH/bin/setpass.py" "$USERNAME" --pass="$ROOT_PASS" diff --git a/firstboot.d/31fqdn b/firstboot.d/31fqdn index 69a4737..9ca8b4d 100755 --- a/firstboot.d/31fqdn +++ b/firstboot.d/31fqdn @@ -15,12 +15,28 @@ # leaves a machine whose description says what it should be, not one # renamed with no record of it. The /etc/hosts entry comes after the # rename, which replaces the old name wherever it stands. An answer that -# changes nothing writes nothing. +# changes nothing leaves every file as it was. # # FQDN: SKIP, the name (if none specified, will be interactive). The # instance description renders instance.fqdn as FQDN (00declarative), so a # described machine is not asked. # +# Whatever the answer, and with none, the hook ends with the /etc/hosts +# entry for the name the machine has, `127.0.1.1 `, or +# `127.0.1.1 ` without a domain, the form keel spec apply +# --system writes: the image ships no such line (common's seal-hostname), +# so the first boot is its one writer, also after SKIP or an empty +# answer. The machine keeps its name then, with the domain its line in +# /etc/hosts already gives it (bin/fqdn.py --machine). Then the self-signed +# certificate is made again when it is not for that name +# (lib/sslcert.sh): 15regen-sslcert made it before the name was settled. +# +# When nobody can answer the console (lib/console.sh), nothing is asked: +# the machine keeps its name, as with an empty answer, and when that name +# is a fully qualified one (pct create --hostname, or the domain of its +# hosts line) it is recorded as instance.hostname and instance.fqdn, so +# the description says what the machine is. +# # Position: an interactive hook is numbered 30 or above (run waits for the # boot to finish from 30 on, so the screen is not drawn over); after # 30rootpass, which stays the first screen, and before the application's @@ -31,37 +47,84 @@ INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" source "$INITHOOKS_DEFAULT" # shellcheck source=lib/hostname.sh source "$INITHOOKS_PATH/lib/hostname.sh" +# shellcheck source=lib/console.sh +source "$INITHOOKS_PATH/lib/console.sh" +# shellcheck source=lib/sslcert.sh +source "$INITHOOKS_PATH/lib/sslcert.sh" if [[ -e "$INITHOOKS_CONF" ]]; then # shellcheck source=/dev/null source "$INITHOOKS_CONF" fi -[[ "${FQDN^^}" == "SKIP" ]] && exit 0 +HOOK=31fqdn + +# names ANSWER +# Reads the HOSTNAME= and FQDN= lines fqdn.py prints into name_hostname +# and name_fqdn. +names() { + local key value + name_hostname= + name_fqdn= + while IFS='=' read -r key value; do + case $key in + HOSTNAME) name_hostname=$value ;; + FQDN) name_fqdn=$value ;; + *) echo "31fqdn: unexpected answer from fqdn.py: $key" >&2; exit 1 ;; + esac + done <<< "$1" + if [[ -z "$name_hostname" ]]; then + echo "31fqdn: fqdn.py answered without a hostname" >&2 + exit 1 + fi +} + +# settle: the hosts entry and the certificate for name_hostname and +# name_fqdn. The certificate's lines go to stderr, the journal: the +# console may not take them. +settle() { + "$INITHOOKS_PATH/bin/fqdn.py" --hosts --hostname="$name_hostname" \ + --fqdn="$name_fqdn" + sslcert_follow >&2 +} + +# keep: the machine keeps the name it has +keep() { + names "$("$INITHOOKS_PATH/bin/fqdn.py" --machine --current="$(hostname)")" +} + +if [[ "${FQDN^^}" == "SKIP" ]]; then + keep + settle + exit 0 +fi + +if [[ -z "$FQDN" ]] && console_unattended; then + keep + if [[ -n "$name_fqdn" ]]; then + "$INITHOOKS_PATH/bin/fqdn.py" --record --hostname="$name_hostname" \ + --fqdn="$name_fqdn" + console_skipped 31fqdn "the machine keeps its name $name_fqdn, recorded as instance.fqdn" + else + console_skipped 31fqdn "the machine keeps its name $name_hostname, which has no domain: no instance.fqdn recorded" + fi + settle + exit 0 +fi # the screen prints HOSTNAME= and FQDN= once the operator has answered, # and nothing when the machine keeps its name answer=$("$INITHOOKS_PATH/bin/fqdn.py" --fqdn="$FQDN" --current="$(hostname)") -[[ -n "$answer" ]] || exit 0 - -new_hostname= -new_fqdn= -while IFS='=' read -r key value; do - case $key in - HOSTNAME) new_hostname=$value ;; - FQDN) new_fqdn=$value ;; - *) echo "31fqdn: unexpected answer from fqdn.py: $key" >&2; exit 1 ;; - esac -done <<< "$answer" -if [[ -z "$new_hostname" ]]; then - echo "31fqdn: fqdn.py answered without a hostname" >&2 - exit 1 +if [[ -z "$answer" ]]; then + keep + settle + exit 0 fi +names "$answer" -"$INITHOOKS_PATH/bin/fqdn.py" --record --hostname="$new_hostname" \ - --fqdn="$new_fqdn" +"$INITHOOKS_PATH/bin/fqdn.py" --record --hostname="$name_hostname" \ + --fqdn="$name_fqdn" -hostname_set "$new_hostname" +hostname_set "$name_hostname" -"$INITHOOKS_PATH/bin/fqdn.py" --hosts --hostname="$new_hostname" \ - --fqdn="$new_fqdn" +settle diff --git a/firstboot.d/85secalerts b/firstboot.d/85secalerts index 1433521..ad3dd4f 100755 --- a/firstboot.d/85secalerts +++ b/firstboot.d/85secalerts @@ -1,12 +1,25 @@ #!/bin/bash -e # enable security alert emails # SEC_ALERTS: SKIP, $EMAIL (if none specified, will be interactive) +# +# When nobody can answer the console (lib/console.sh), no email is set, +# as Skip on the screen does. -. /etc/default/inithooks +INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" +# shellcheck source=default/inithooks +. "$INITHOOKS_DEFAULT" +# shellcheck source=lib/console.sh +. "$INITHOOKS_PATH/lib/console.sh" -[ -e $INITHOOKS_CONF ] && . $INITHOOKS_CONF +# shellcheck disable=SC1090 +[ -e "$INITHOOKS_CONF" ] && . "$INITHOOKS_CONF" [ "$SEC_ALERTS" == "SKIP" ] && exit 0 -$INITHOOKS_PATH/bin/secalerts.py \ +if [ -z "$SEC_ALERTS" ] && console_unattended; then + console_skipped 85secalerts "no alert email is set, as Skip does; preseed SEC_ALERTS to set one" + exit 0 +fi + +"$INITHOOKS_PATH/bin/secalerts.py" \ --email="$SEC_ALERTS" \ - --email-placeholder="$($INITHOOKS_PATH/bin/inithooks_cache.py APP_EMAIL)" + --email-placeholder="$("$INITHOOKS_PATH/bin/inithooks_cache.py" APP_EMAIL)" diff --git a/firstboot.d/95secupdates b/firstboot.d/95secupdates index 69c8b6f..0ce330d 100755 --- a/firstboot.d/95secupdates +++ b/firstboot.d/95secupdates @@ -1,6 +1,11 @@ #!/bin/bash -e # install security updates # SEC_UPDATES: SKIP, FORCE (if none specified, will be interactive) +# +# When nobody can answer the console (lib/console.sh), the updates are +# installed as FORCE installs them, what the preseed of a headless build +# says (README.rst), and 99reboot reboots for a new kernel as it does +# after FORCE. # every '| tee' below must carry the exit status of what it logs set -o pipefail @@ -8,6 +13,8 @@ set -o pipefail INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" # shellcheck source=default/inithooks source "$INITHOOKS_DEFAULT" +# shellcheck source=lib/console.sh +source "$INITHOOKS_PATH/lib/console.sh" if [[ -e "$INITHOOKS_CONF" ]]; then # shellcheck disable=SC1090 source "$INITHOOKS_CONF" @@ -134,6 +141,14 @@ elif [[ -n "$SEC_UPDATES" ]]; then exit 1 fi +if console_unattended; then + console_skipped 95secupdates "security updates installed, as SEC_UPDATES=FORCE does" + journal "[95secupdates] security updates being installed" + install_updates + record force + exit 0 +fi + # interactive exit_code=0 $INITHOOKS_PATH/bin/secupdates-ask.py || exit_code=$? diff --git a/firstboot.d/99reboot b/firstboot.d/99reboot index beb89b1..1a2e1e0 100644 --- a/firstboot.d/99reboot +++ b/firstboot.d/99reboot @@ -1,11 +1,20 @@ #!/bin/bash -e # reboot system (kernel upgrade, set chmod +x by 95secupdates) # will be skipped if running live or REBOOT set to SKIP +# +# When nobody can answer the console (lib/console.sh), the machine reboots +# as it does after SEC_UPDATES=FORCE: 95secupdates installed the updates +# the same way. -chmod -x $0 # self-deactivating +chmod -x "$0" # self-deactivating -. /etc/default/inithooks -[ -e $INITHOOKS_CONF ] && . $INITHOOKS_CONF +INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" +# shellcheck source=default/inithooks +. "$INITHOOKS_DEFAULT" +# shellcheck source=lib/console.sh +. "$INITHOOKS_PATH/lib/console.sh" +# shellcheck disable=SC1090 +[ -e "$INITHOOKS_CONF" ] && . "$INITHOOKS_CONF" grep -qs boot=live /proc/cmdline && exit 2 @@ -18,6 +27,9 @@ reboot() { if [ "$SEC_UPDATES" == "FORCE" ]; then echo "rebooting due to kernel security upgrade..." reboot +elif console_unattended; then + console_skipped 99reboot "rebooting for the new kernel, as SEC_UPDATES=FORCE does" + reboot else - $INITHOOKS_PATH/bin/reboot-ask.py && reboot + "$INITHOOKS_PATH/bin/reboot-ask.py" && reboot fi diff --git a/lib/console.sh b/lib/console.sh new file mode 100644 index 0000000..d3ce220 --- /dev/null +++ b/lib/console.sh @@ -0,0 +1,81 @@ +# Whether anybody can answer a first boot screen. run asks it before its +# notices, and every hook that draws a screen asks it before drawing one: +# 30rootpass, 31fqdn, 75keel-role and 80keel-cloud (lib/keel-firstboot.sh), +# 85secalerts, 95secupdates and 99reboot. When nobody can, the hook asks +# nothing and does what it does with no answer, and says so in one line +# (console_skipped). +# +# The published Web 19.0-3 booted headless in an LXC container on +# 2026-10-03 stopped at 31fqdn for good: its screen was drawn on tty1, a pty +# whose master only an attached console reads (pct console, lxc-console), +# and waited for an answer nobody could give. +# +# The console is the terminal dialog draws on: standard output when it is +# a terminal, else the controlling terminal (screen_on_terminal() of +# libinithooks/dialog_wrapper.py). Nobody can answer it when +# - there is none; +# - it has no size: stty answers 0 0 on the tty of an LXC container +# nobody is attached to, where a VT or an attached console answers its +# rows and columns; +# - it does not take CONSOLE_PROBE_BYTES NUL bytes within +# CONSOLE_TIMEOUT seconds. A pty whose master nobody reads takes about +# 17 KB (Linux 6.12) and then blocks every write, so a small write +# would pass and the screen after it would wait for good; the probe is +# larger than any pty's buffer, and a terminal shows nothing for a NUL. +# +# The answer is exported in INITHOOKS_UNATTENDED, "no" when somebody can +# answer and otherwise the reason nobody can, so that run asks once and +# its hooks inherit what it found. Set beforehand, it is taken as it is. + +CONSOLE_TIMEOUT="${CONSOLE_TIMEOUT:-${NOTICE_TIMEOUT:-2}}" +CONSOLE_PROBE_BYTES="${CONSOLE_PROBE_BYTES:-131072}" +CONSOLE_TTY="${CONSOLE_TTY:-/dev/tty}" + +# console_unattended +# Succeeds when nobody can answer the console, the reason then being in +# INITHOOKS_UNATTENDED. Run it in the shell that keeps the answer, not in +# a command substitution. +console_unattended() { + if [[ -z "${INITHOOKS_UNATTENDED:-}" ]]; then + if [[ -t 1 ]]; then + console_check 3>&1 + elif { : > "$CONSOLE_TTY"; } 2>/dev/null; then + console_check 3>"$CONSOLE_TTY" + else + INITHOOKS_UNATTENDED="there is no terminal" + fi + export INITHOOKS_UNATTENDED="${INITHOOKS_UNATTENDED:-no}" + fi + [[ "$INITHOOKS_UNATTENDED" != "no" ]] +} + +# console_check +# Sets INITHOOKS_UNATTENDED to why nobody can answer the console on fd 3, +# when nobody can. +console_check() { + local size + size=$(stty size <&3 2>/dev/null) || size= + if [[ -z "$size" ]] || [[ "$size" == "0 0" ]]; then + INITHOOKS_UNATTENDED="the console has no size, nobody is attached to it" + elif ! console_probe; then + INITHOOKS_UNATTENDED="the console did not take a write in ${CONSOLE_TIMEOUT} s, nobody is reading it" + fi +} + +# console_probe: writes the probe on fd 3, within CONSOLE_TIMEOUT +console_probe() { + timeout --foreground "$CONSOLE_TIMEOUT" \ + head -c "$CONSOLE_PROBE_BYTES" /dev/zero >&3 2>/dev/null +} + +# console_skipped HOOK WHAT +# The one line a hook leaves when it asks nothing: on stderr, which +# inithooks.service sends to the journal, and in the inithooks log. Never +# on the console, which may not take it. +console_skipped() { + local line="[$1] not asked, nobody can answer ($INITHOOKS_UNATTENDED): $2" + echo "$line" >&2 + if [[ -n "${INITHOOKS_LOGFILE:-}" ]]; then + { echo "INFO: $line" >> "$INITHOOKS_LOGFILE"; } 2>/dev/null || true + fi +} diff --git a/lib/keel-firstboot.sh b/lib/keel-firstboot.sh index 49d7e6e..396da6d 100644 --- a/lib/keel-firstboot.sh +++ b/lib/keel-firstboot.sh @@ -10,7 +10,13 @@ # keelfirstboot.py decides everything else: it skips a step the instance # description or a preseeded HUB_APIKEY already answers, asks again under # keel-init, and draws on the terminal. Its reasons go to stderr, which -# inithooks.service sends to the journal. +# inithooks.service sends to the journal. When nobody can answer the +# console (lib/console.sh) it is given no terminal: it still does what a +# preseed or the description asks, and draws nothing, so the node stays +# standalone and holds no Keel Cloud key. + +# shellcheck source=lib/console.sh +source "$INITHOOKS_PATH/lib/console.sh" KEEL_FIRSTBOOT="${KEEL_FIRSTBOOT:-/usr/lib/confconsole/keelfirstboot.py}" @@ -29,5 +35,10 @@ keel_firstboot() { "is not installed: not asked, this node stays standalone" >&2 return 0 fi + if console_unattended; then + console_skipped "$(basename "$0")" "confconsole's $step screen is run without a terminal and draws nothing" + python3 "$KEEL_FIRSTBOOT" "$step" < /dev/null + return + fi python3 "$KEEL_FIRSTBOOT" "$step" } diff --git a/lib/sslcert.sh b/lib/sslcert.sh new file mode 100644 index 0000000..cda938a --- /dev/null +++ b/lib/sslcert.sh @@ -0,0 +1,131 @@ +# The machine's self-signed TLS certificate, made at first boot for the +# name the machine has: by firstboot.d/15regen-sslcert, and again by +# firstboot.d/31fqdn when the name it settles is not the one the +# certificate holds. +# +# The names are given to turnkey-make-ssl-cert (turnkey-ssl), the fqdn +# first, so that it is the CN, with the addresses of the machine (--ip). +# Without names it takes them from `hostname -A`, the reverse lookup of +# every address of the machine, which answers whatever name the network's +# DNS keeps for the address: a Web container named web served CN=core on +# 2026-10-03. The names here are the ones 31fqdn writes into /etc/hosts +# (bin/fqdn.py --machine), so nothing is asked of the network. +# +# The hook that sources this sets HOOK, its name, for the log lines. + +SSLCERT_PEM="${SSLCERT_PEM:-/etc/ssl/private/cert.pem}" +SSLCERT_KEY="${SSLCERT_KEY:-/etc/ssl/private/cert.key}" +SSLCERT_SERVICES=(nginx apache2 lighttpd tomcat10 tomcat11 webmin) + +# The journal is a side effect of a hook whose job is the certificate: +# under -e a logger that fails (journald down, "socket /dev/log: Connection +# refused", the published core 19.0-6 booted headless on 2026-10-03) killed +# the hook at its first line, so it may not fail the hook. +sslcert_log() { + local level=$1 + shift + logger -t inithooks -p "$level" "[$HOOK] $*" 2>/dev/null || true +} + +sslcert_info() { sslcert_log 5 "$*"; echo "INFO: [$HOOK] $*"; } + +sslcert_fatal() { + sslcert_log 3 "$*" + echo "FATAL: [$HOOK] $*" 1>&2 + exit 1 +} + +# sslcert_names +# The names the certificate is for, on one line: the fqdn when the machine +# has one, then the hostname. +sslcert_names() { + local answer key value hostname= fqdn= + answer=$("$INITHOOKS_PATH/bin/fqdn.py" --machine --current="$(hostname)") + while IFS='=' read -r key value; do + case $key in + HOSTNAME) hostname=$value ;; + FQDN) fqdn=$value ;; + esac + done <<< "$answer" + echo "${fqdn:+$fqdn }$hostname" +} + +# modulus_md5 KIND FILE: the md5 of the modulus of the x509 or rsa FILE +sslcert_modulus_md5() { openssl "$1" -noout -modulus -in "$2" | openssl md5; } + +# sslcert_make NAME... +# Makes the certificate and key for NAME..., then restarts the services +# that serve it. +sslcert_make() { + local make _wait cert_md5 key_md5 service + # Check for 'turnkey-make-ssl-cert' - should be provided by + # turnkey-ssl package. + make=$(which turnkey-make-ssl-cert) \ + || sslcert_fatal "turnkey-make-ssl-cert executable not found." + + # We use predefined 4096 bits default dhparams file for TLS1.2 (not + # needed for TLS1.3) + # See https://github.com/turnkeylinux/tracker/issues/1653 for more info. + sslcert_info "Generating SSL/TLS cert & key for $*." + "$make" --default --force --ip "$@" + + # make sure that generated keys are ready to use - avoids occasional + # race condition where cert & key don't (yet) match. a single sleep + # should be plenty but let's be sure + for _wait in {1..5}; do + cert_md5=$(sslcert_modulus_md5 x509 "$SSLCERT_PEM") + key_md5=$(sslcert_modulus_md5 rsa "$SSLCERT_KEY") + if [[ "$cert_md5" == "$key_md5" ]]; then + sslcert_info "SSL cert and key have been written - ready to restart services" + break + elif [[ $_wait -eq 1 ]]; then + sslcert_info "Waiting for updated ssl cert & key to be written to disk" + else + echo "..." + fi + sleep 1 + done + + sslcert_info "Restarting relevant services." + for service in "${SSLCERT_SERVICES[@]}"; do + # only restart services that are running + if systemctl is-active --quiet "${service}.service"; then + sslcert_info "$service running; restarting..." + systemctl restart --quiet "$service" + fi + done + + # final tidy up + update-ca-certificates +} + +# sslcert_hashes: the subject's hash, then the issuer's, of the certificate +sslcert_hashes() { + openssl x509 -in "$SSLCERT_PEM" -noout -subject_hash -issuer_hash \ + 2>/dev/null +} + +# sslcert_follow +# Makes the certificate again when it is the machine's own, self-signed, +# and its CN is not the name the machine has now. A certificate an +# authority signed (confconsole's Let's Encrypt writes the same files) is +# kept, and so is a machine without one. +sslcert_follow() { + local names subject hashes cn + [[ -e "$SSLCERT_PEM" ]] || return 0 + names=$(sslcert_names) + if ! hashes=$(sslcert_hashes); then + sslcert_info "$SSLCERT_PEM cannot be read as a certificate; kept" + return 0 + fi + if [[ "$(sed -n 1p <<< "$hashes")" != "$(sed -n 2p <<< "$hashes")" ]]; then + sslcert_info "$SSLCERT_PEM is signed by an authority; kept" + return 0 + fi + subject=$(openssl x509 -in "$SSLCERT_PEM" -noout -subject -nameopt RFC2253) + cn=$(sed -En 's/^subject=(.*,)?CN=([^,]*).*/\2/p' <<< "$subject") + [[ "$cn" != "${names%% *}" ]] || return 0 + sslcert_info "$SSLCERT_PEM is for ${cn:-no name}, the machine is ${names%% *}" + # shellcheck disable=SC2086 # the names are words + sslcert_make $names +} diff --git a/libinithooks/fqdn.py b/libinithooks/fqdn.py index 1a441c0..bef8af6 100644 --- a/libinithooks/fqdn.py +++ b/libinithooks/fqdn.py @@ -122,7 +122,7 @@ def hosts_with_name(text: str, hostname: str, fqdn: str) -> str: blanks included, stays as it was; without a line to rewrite the entry is appended, at LOOPBACK. """ - names = [fqdn, hostname] if fqdn else [hostname] + names = [fqdn, hostname] if fqdn and fqdn != hostname else [hostname] lines = text.splitlines() address = next((line.split()[0] for line in lines if _superseded(line, hostname)), LOOPBACK) @@ -141,6 +141,58 @@ def hosts_with_name(text: str, hostname: str, fqdn: str) -> str: return "".join(f"{line}\n" for line in kept) +def in_hosts(text: str, hostname: str) -> str: + """The dotted name /etc/hosts TEXT gives HOSTNAME, "" for none + + Read from the first line that names the host, as a whole name or as + the first label of a dotted one, as keel inspect reads it + (keel.inspect.hostname.fqdn_in_hosts): a resolver answers from the + first line that carries the name, so a later line does not give the + host a name `hostname -f` answers. + """ + for line in text.splitlines(): + fields = line.split() + if line.strip().startswith("#") or len(fields) < 2: + continue + names = [one.lower() for one in fields[1:]] + if not any(one.split(".")[0] == hostname.lower() for one in names): + continue + return next((one for one in names if "." in one), "") + return "" + + +def machine(current: str, hosts_text: str) -> tuple[str, str]: + """(hostname, fqdn): the name the machine has, for a first boot that + keeps it (nobody can answer, or FQDN=SKIP) + + CURRENT is what `hostname` answers, the name pct create --hostname + gave a container. A dotted one is the fqdn, and its first label the + hostname; otherwise the fqdn is the dotted name the host's line in + /etc/hosts gives it (pct writes `127.0.1.1 name.domain name` with the + host's search domain), else none. A name that is not a domain name + gives no fqdn, and the hostname is kept as it is. + """ + name, problem = normalize(current) + if problem or not name: + return current, "" + hostname, fqdn = split(name) + if fqdn: + return hostname, fqdn + found, problem = normalize(in_hosts(hosts_text, hostname)) + return hostname, "" if problem else found + + +def read_hosts(path: str) -> str: + """The hosts file at PATH, "" when there is none""" + if not os.path.exists(path): + return "" + try: + with open(path) as fob: + return fob.read() + except OSError as error: + raise FqdnError(f"{path}: {error.strerror}") + + def _superseded(line: str, hostname: str) -> bool: fields = line.split() if line.strip().startswith("#") or len(fields) < 2: @@ -243,13 +295,7 @@ def write_spec(path: str, document: Mapping[str, Any]) -> None: def write_hosts(path: str, hostname: str, fqdn: str) -> None: """Write the entry for HOSTNAME and FQDN into the hosts file at PATH""" - text = "" - if os.path.exists(path): - try: - with open(path) as fob: - text = fob.read() - except OSError as error: - raise FqdnError(f"{path}: {error.strerror}") + text = read_hosts(path) staged = _write_beside(path, hosts_with_name(text, hostname, fqdn), ".fqdn-tmp") os.chmod(staged, _mode(path, HOSTS_MODE)) diff --git a/run b/run index e2b5131..47d6a3c 100755 --- a/run +++ b/run @@ -8,6 +8,10 @@ # shellcheck source=lib/init-lock.sh source "$(dirname "${BASH_SOURCE[0]}")/lib/init-lock.sh" init_lock_take "$INITHOOKS_LOCK" || exit 1 +# whether anybody can answer the console, asked once for the notices and +# handed to the hooks in INITHOOKS_UNATTENDED +# shellcheck source=lib/console.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib/console.sh" # load/set general global vars INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" @@ -53,6 +57,8 @@ BOOT_WAITED= # given up for this run. NOTICE_TIMEOUT="${NOTICE_TIMEOUT:-2}" NOTICES_OFF= +# where the hooks print, once nobody can answer the console +HOOK_OUTPUT= # notice TEXT # Shows TEXT in a box on the terminal the hooks draw on, and goes on: the @@ -66,33 +72,42 @@ NOTICES_OFF= # master only an attached console (pct console, lxc-console) reads: with # nobody attached, the published core booted headless on 2026-10-03 wrote # one box per hook until the pty's buffer was full and the next write -# blocked for good, after 30turnkey-init-fence. Such a console has no size -# (stty answers 0 0; a VT or an attached console answers its rows and -# columns), so no notice is drawn on it; and a notice that does not reach -# the console within NOTICE_TIMEOUT is given up, with the ones after it. -# Each case is said once, in the log, so an operator can tell. +# blocked for good, after 30turnkey-init-fence. So no notice is drawn on a +# console nobody can answer (lib/console.sh: one with no size, or one that +# does not take a write), and a notice that does not reach the console +# within NOTICE_TIMEOUT is given up, with the ones after it. Either way the +# hooks are told (unattended), and print into the log from then on. Each +# case is said once, in the log, so an operator can tell. notice() { [[ -z "$NOTICES_OFF" ]] || return 0 if [[ ! -t 1 ]] || [[ "$REDIRECT_OUTPUT" == "true" ]]; then notices_off "the output is not a terminal" return 0 fi - # the console on fd 3 while the substitution runs: inside it fd 1 is - # the pipe that captures the answer, not the console - local size - { size=$(stty size <&3 2>/dev/null || true); } 3<&1 - if [[ -z "$size" ]] || [[ "$size" == "0 0" ]]; then - notices_off "the console has no size, nobody is attached to it" + if console_unattended; then + unattended return 0 fi timeout --foreground "$NOTICE_TIMEOUT" dialog --backtitle "$FIRSTBOOT_TITLE" \ --infobox "$1" 5 60 2>/dev/null if [[ $? -eq 124 ]]; then - notices_off "the console did not take a notice in ${NOTICE_TIMEOUT} s, nobody is reading it" + INITHOOKS_UNATTENDED="the console did not take a notice in ${NOTICE_TIMEOUT} s, nobody is reading it" + unattended fi return 0 } +# unattended: nobody can answer the console, for INITHOOKS_UNATTENDED's +# reason. No more notices, the hooks are told, and what they print goes to +# the log: a console nobody reads blocks its writer once its buffer is +# full (95secupdates prints the whole upgrade). +unattended() { + export INITHOOKS_UNATTENDED + notices_off "$INITHOOKS_UNATTENDED" + HOOK_OUTPUT=$INITHOOKS_LOGFILE + log info "the first boot questions are not asked, and the hooks print into $INITHOOKS_LOGFILE" +} + # notices_off REASON: no more notices this run, said once notices_off() { NOTICES_OFF=true @@ -178,7 +193,11 @@ exec_scripts() { 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}>&- + if [[ -n "$HOOK_OUTPUT" ]]; then + "$script_executable" {INIT_LOCK_FD}>&- >> "$HOOK_OUTPUT" + else + "$script_executable" {INIT_LOCK_FD}>&- + fi exit_code=$? if [[ "$exit_code" -eq 0 ]]; then log info "[$script] successfully completed" diff --git a/tests/README.md b/tests/README.md index 9979fdd..27c5803 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,6 +19,10 @@ 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. +- `test-console.bats`: `lib/console.sh`, whether anybody can answer a first + boot screen, and every hook that draws one, each run on a pty whose + master is never read, with no size and with one, within a deadline: a + hook that asks fails instead of hanging. - `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-console.bats b/tests/test-console.bats new file mode 100644 index 0000000..a1b9cd8 --- /dev/null +++ b/tests/test-console.bats @@ -0,0 +1,308 @@ +#!/usr/bin/env bats +# Tests for lib/console.sh, whether anybody can answer a first boot screen, +# and for every hook that draws one: on a console nobody can answer, each +# hook asks nothing and finishes, as if no answer had been given, and says +# so in one line. +# +# The published Web 19.0-3 booted headless in an LXC container on +# 2026-10-03 stopped at 31fqdn: its screen was drawn on tty1, a pty whose +# master nobody read, and waited for an answer nobody could give. The +# consoles here are ptys whose master is never read, one with no size (an +# LXC tty nobody is attached to) and one with a size (a console that was +# sized and then left); the pty is the hook's terminal, standard input and +# output, as inithooks.service gives it tty1. Every screen a hook could +# draw is a stand-in that never returns, and the whole hook has DEADLINE +# seconds: a hook that asks fails here instead of hanging. + +bats_require_minimum_version 1.5.0 + +load helpers + +REPO=$BATS_TEST_DIRNAME/.. +DEADLINE=30 + +setup() { + setup_stubs + stub logger + export INITHOOKS_PATH=$BATS_TEST_TMPDIR/inithooks + mkdir -p "$INITHOOKS_PATH/bin" "$INITHOOKS_PATH/firstboot.d" + ln -s "$REPO/lib" "$INITHOOKS_PATH/lib" + export INITHOOKS_CONF=$BATS_TEST_TMPDIR/inithooks.conf + export INITHOOKS_DEFAULT=$BATS_TEST_TMPDIR/default-inithooks + { + echo "INITHOOKS_PATH=$INITHOOKS_PATH" + echo "INITHOOKS_CONF=$INITHOOKS_CONF" + } > "$INITHOOKS_DEFAULT" + export INITHOOKS_LOGFILE=$BATS_TEST_TMPDIR/inithooks.log + export CONSOLE_TIMEOUT=1 + OUT=$BATS_TEST_TMPDIR/out + write_decided + unset INITHOOKS_UNATTENDED +} + +# on_pty SIZED CMD... +# CMD with a new pty for its controlling terminal, standard input and +# output, whose master is never read; SIZED is sized (24x80) or unsized +# (0 0, what stty answers on an LXC tty nobody is attached to). Standard +# error is the test's. Fails when CMD has not finished in DEADLINE s. +on_pty() { + timeout "$DEADLINE" python3 - "$@" <<'PY' +import fcntl, os, pty, struct, subprocess, sys, termios +sized, cmd = sys.argv[1], sys.argv[2:] +master, slave = pty.openpty() +if sized == "sized": + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) +def controlling(): + os.setsid() + fcntl.ioctl(0, termios.TIOCSCTTY, 0) +# close_fds=False: the descriptor kcov traces on (tests/coverage.sh) must +# reach CMD +proc = subprocess.run(cmd, stdin=slave, stdout=slave, preexec_fn=controlling, + close_fds=False) +sys.exit(proc.returncode) +PY +} + +# write_decided +# DECIDED, a script that asks console_unattended and writes its status +# and INITHOOKS_UNATTENDED, as a child process sees it, into OUT. +write_decided() { + DECIDED=$BATS_TEST_TMPDIR/decided + cat > "$DECIDED" < '$OUT' +EOF +} + +# hangs NAME: the screen NAME under bin, which never returns +hangs() { + printf '#!/bin/bash\nexec sleep 600\n' > "$INITHOOKS_PATH/bin/$1" + chmod +x "$INITHOOKS_PATH/bin/$1" +} + +# --- the rule ----------------------------------------------------------- + +@test "a decision already made is not made again" { + export INITHOOKS_UNATTENDED=no + run bash -c "source '$REPO/lib/console.sh'; console_unattended" + [ "$status" -eq 1 ] + + export INITHOOKS_UNATTENDED="the console has no size" + run bash -c "source '$REPO/lib/console.sh'; console_unattended" + [ "$status" -eq 0 ] +} + +@test "nobody answers a console with no size, and children inherit it" { + run on_pty unsized bash "$DECIDED" + + [ "$status" -eq 0 ] + [ "$(cat "$OUT")" = "0 the console has no size, nobody is attached to it" ] +} + +@test "nobody answers a sized console whose master is never read" { + run on_pty sized bash "$DECIDED" + + [ "$status" -eq 0 ] + [ "$(cat "$OUT")" = "0 the console did not take a write in 1 s, nobody is reading it" ] +} + +@test "somebody answers a sized console that is read" { + run script -qec "stty rows 24 cols 80; bash $DECIDED" /dev/null < /dev/null + + [ "$status" -eq 0 ] + [ "$(cat "$OUT")" = "1 no" ] +} + +@test "the controlling terminal is asked when the output is not one" { + # dialog draws on the controlling terminal then (dialog_wrapper.py) + run script -qec "stty rows 24 cols 80; bash $DECIDED > /dev/null" \ + /dev/null < /dev/null + + [ "$status" -eq 0 ] + [ "$(cat "$OUT")" = "1 no" ] +} + +@test "nobody answers where there is no terminal at all" { + run setsid -w bash "$DECIDED" < /dev/null > /dev/null + + [ "$status" -eq 0 ] + [ "$(cat "$OUT")" = "0 there is no terminal" ] +} + +@test "a hook that does not ask says why in one line, on stderr and in the log" { + export INITHOOKS_UNATTENDED="the console has no size" + + run --separate-stderr bash -c "source '$REPO/lib/console.sh' +console_skipped 31fqdn 'the machine keeps its name web'" + + [ "$status" -eq 0 ] + [ -z "$output" ] + [ "$stderr" = "[31fqdn] not asked, nobody can answer (the console has no size): the machine keeps its name web" ] + [ "$(cat "$INITHOOKS_LOGFILE")" = "INFO: $stderr" ] +} + +@test "a log that cannot be written does not stop the hook" { + export INITHOOKS_UNATTENDED="the console has no size" + export INITHOOKS_LOGFILE=$BATS_TEST_TMPDIR/no/such/dir/log + + run --separate-stderr bash -c "set -e; source '$REPO/lib/console.sh' +console_skipped 85secalerts 'no alert email'; echo went-on" + + [ "$status" -eq 0 ] + [ "$output" = went-on ] + [[ "$stderr" == "[85secalerts] not asked"* ]] +} + +# --- the hooks ------------------------------------------------------------ + +# hook_finishes SIZED HOOK +# Runs HOOK, a copy under INITHOOKS_PATH, on the console; it must end with +# status 0 and leave its one line in the log. +hook_finishes() { + # 99reboot is armed by 95secupdates, and ships not executable + install -m 755 "$REPO/firstboot.d/$2" "$INITHOOKS_PATH/firstboot.d/$2" + run --separate-stderr on_pty "$1" "$INITHOOKS_PATH/firstboot.d/$2" + echo "$stderr" >&2 + [ "$status" -eq 0 ] + [ "$(grep -c "^INFO: \[$2\] not asked, nobody can answer" "$INITHOOKS_LOGFILE")" -eq 1 ] +} + +setup_rootpass() { + hangs setpass.py +} + +setup_fqdn() { + # the real screen: it asks on the terminal, and never gets an answer + ln -s "$REPO/bin/fqdn.py" "$INITHOOKS_PATH/bin/fqdn.py" + export PYTHONPATH=$REPO${PYTHONPATH:+:$PYTHONPATH} + export INITHOOKS_HOSTS=$BATS_TEST_TMPDIR/hosts + export INITHOOKS_DECL=$BATS_TEST_TMPDIR/instance.yaml + export HOSTNAME_ROOT=$BATS_TEST_TMPDIR/root + export SSLCERT_PEM=$BATS_TEST_TMPDIR/cert.pem + mkdir -p "$HOSTNAME_ROOT/etc" + printf '127.0.0.1 localhost\n' > "$INITHOOKS_HOSTS" + stub hostname 'if [[ $# -eq 0 ]]; then echo web; fi' +} + +setup_keel() { + # confconsole's screen, drawn whenever it has a terminal + export KEEL_FIRSTBOOT=$BATS_TEST_TMPDIR/keelfirstboot.py + printf 'import os, time\nif os.isatty(0):\n time.sleep(600)\n' \ + > "$KEEL_FIRSTBOOT" +} + +setup_secalerts() { + hangs secalerts.py + printf '#!/bin/bash\necho admin@example.com\n' \ + > "$INITHOOKS_PATH/bin/inithooks_cache.py" + chmod +x "$INITHOOKS_PATH/bin/inithooks_cache.py" +} + +setup_secupdates() { + hangs secupdates-ask.py + # installed as SEC_UPDATES=FORCE installs, here without a network + stub curl 'exit 7' + export SEC_UPDATES_RECORD=$BATS_TEST_TMPDIR/sec-updates + export SEC_UPDATES_LOG=$BATS_TEST_TMPDIR/secupdates.log + export SEC_UPDATES_SOURCES=$BATS_TEST_TMPDIR/security.sources + printf 'URIs: http://security.debian.org/debian-security\nSuites: trixie-security\n' \ + > "$SEC_UPDATES_SOURCES" +} + +setup_reboot() { + hangs reboot-ask.py + stub init +} + +@test "30rootpass on a console with no size keeps the password" { + setup_rootpass + hook_finishes unsized 30rootpass +} + +@test "30rootpass on a sized console nobody reads keeps the password" { + setup_rootpass + hook_finishes sized 30rootpass +} + +@test "30rootpass with a preseeded password sets it without asking" { + stub chpasswd + setup_rootpass + echo "export ROOT_PASS=Preseeded-123" > "$INITHOOKS_CONF" + printf '#!/bin/bash\necho "$*" > %q\n' "$BATS_TEST_TMPDIR/setpass" \ + > "$INITHOOKS_PATH/bin/setpass.py" + chmod +x "$INITHOOKS_PATH/bin/setpass.py" + cp "$REPO/firstboot.d/30rootpass" "$INITHOOKS_PATH/firstboot.d/" + + run on_pty unsized "$INITHOOKS_PATH/firstboot.d/30rootpass" + + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/setpass")" = "root --pass=Preseeded-123" ] +} + +@test "31fqdn on a console with no size keeps the name" { + setup_fqdn + hook_finishes unsized 31fqdn + [ "$(cat "$INITHOOKS_HOSTS")" = "$(printf '127.0.0.1 localhost\n127.0.1.1 web')" ] +} + +@test "31fqdn on a sized console nobody reads keeps the name" { + setup_fqdn + hook_finishes sized 31fqdn + [ "$(cat "$INITHOOKS_HOSTS")" = "$(printf '127.0.0.1 localhost\n127.0.1.1 web')" ] +} + +@test "75keel-role on a console with no size asks nothing" { + setup_keel + hook_finishes unsized 75keel-role +} + +@test "75keel-role on a sized console nobody reads asks nothing" { + setup_keel + hook_finishes sized 75keel-role +} + +@test "80keel-cloud on a console with no size asks nothing" { + setup_keel + hook_finishes unsized 80keel-cloud +} + +@test "80keel-cloud on a sized console nobody reads asks nothing" { + setup_keel + hook_finishes sized 80keel-cloud +} + +@test "85secalerts on a console with no size sets no alert email" { + setup_secalerts + hook_finishes unsized 85secalerts +} + +@test "85secalerts on a sized console nobody reads sets no alert email" { + setup_secalerts + hook_finishes sized 85secalerts +} + +@test "95secupdates on a console with no size installs as FORCE does" { + setup_secupdates + hook_finishes unsized 95secupdates + grep -q "cannot reach" "$SEC_UPDATES_LOG" +} + +@test "95secupdates on a sized console nobody reads installs as FORCE does" { + setup_secupdates + hook_finishes sized 95secupdates + grep -q "cannot reach" "$SEC_UPDATES_LOG" +} + +@test "99reboot on a console with no size reboots as FORCE does" { + setup_reboot + hook_finishes unsized 99reboot + [ "$(calls init)" = 6 ] +} + +@test "99reboot on a sized console nobody reads reboots as FORCE does" { + setup_reboot + hook_finishes sized 99reboot + [ "$(calls init)" = 6 ] +} diff --git a/tests/test-firstboot-pty.bats b/tests/test-firstboot-pty.bats index eba746b..918a769 100644 --- a/tests/test-firstboot-pty.bats +++ b/tests/test-firstboot-pty.bats @@ -37,6 +37,7 @@ setup() { NEXT=$ROOT/next-hook-ran mkdir -p "$LIB/firstboot.d" "$LIB/bin" cp "$REPO/firstboot.d/30rootpass" "$LIB/firstboot.d/" + ln -s "$REPO/lib" "$LIB/lib" 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" < "$INITHOOKS_PATH/bin/fqdn.py" <> '$STUBS/fqdn.py.calls' -if [[ " \$* " != *" --record "* ]] && [[ " \$* " != *" --hosts "* ]]; then - printf '%s' "\${ANSWER-}" -fi +case " \$* " in + *" --record "*|*" --hosts "*) ;; + *" --machine "*) printf '%s' "\${MACHINE-HOSTNAME=blog +FQDN= +}" ;; + *) printf '%s' "\${ANSWER-}" ;; +esac exit "\${ASK_STATUS:-0}" EOF chmod +x "$INITHOOKS_PATH/bin/fqdn.py" @@ -51,7 +65,11 @@ EOF echo "INITHOOKS_PATH=$INITHOOKS_PATH" echo "INITHOOKS_CONF=$INITHOOKS_CONF" } > "$INITHOOKS_DEFAULT" - unset ANSWER ASK_STATUS FQDN + export INITHOOKS_UNATTENDED=no + export INITHOOKS_LOGFILE=$BATS_TEST_TMPDIR/inithooks.log + export SSLCERT_PEM=$BATS_TEST_TMPDIR/ssl/cert.pem + export SSLCERT_KEY=$BATS_TEST_TMPDIR/ssl/cert.key + unset ANSWER ASK_STATUS FQDN MACHINE } @test "the screen is asked with the name the machine has" { @@ -107,14 +125,15 @@ EOF [ "$(calls fqdn.py | sed -n 2p)" = "--record --hostname=web --fqdn=" ] } -@test "an empty answer changes nothing" { +@test "an empty answer keeps the name and writes its hosts entry" { export ANSWER= run "$REPO/firstboot.d/31fqdn" [ "$status" -eq 0 ] [ "$(calls hostname)" = "" ] - [ "$(calls fqdn.py | wc -l)" -eq 1 ] + [ "$(calls fqdn.py | sed 1d)" = "$(printf '%s\n' '--machine --current=blog' \ + '--hosts --hostname=blog --fqdn=')" ] [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = blog ] } @@ -128,16 +147,56 @@ EOF [ "$(calls fqdn.py | head -1)" = "--fqdn=blog.example.org --current=blog" ] } -@test "a preseeded SKIP asks nothing and changes nothing" { +@test "a preseeded SKIP asks nothing, keeps the name and writes its hosts entry" { echo "export FQDN=skip" > "$INITHOOKS_CONF" + export MACHINE=$'HOSTNAME=blog\nFQDN=blog.example.org\n' run "$REPO/firstboot.d/31fqdn" [ "$status" -eq 0 ] - [ -z "$(calls fqdn.py)" ] + [ "$(calls fqdn.py)" = "$(printf '%s\n' '--machine --current=blog' \ + '--hosts --hostname=blog --fqdn=blog.example.org')" ] [ -z "$(calls hostname)" ] } +@test "nobody to answer: the name is kept, recorded with its domain, and said" { + export INITHOOKS_UNATTENDED="the console has no size" + export MACHINE=$'HOSTNAME=blog\nFQDN=blog.example.org\n' + + run --separate-stderr "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(calls fqdn.py)" = "$(printf '%s\n' '--machine --current=blog' \ + '--record --hostname=blog --fqdn=blog.example.org' \ + '--hosts --hostname=blog --fqdn=blog.example.org')" ] + [ -z "$(calls hostname)" ] + [ "$(cat "$INITHOOKS_LOGFILE")" = "INFO: [31fqdn] not asked, nobody can answer (the console has no size): the machine keeps its name blog.example.org, recorded as instance.fqdn" ] +} + +@test "nobody to answer: a name without a domain is kept, and not recorded" { + export INITHOOKS_UNATTENDED="the console has no size" + + run --separate-stderr "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(calls fqdn.py)" = "$(printf '%s\n' '--machine --current=blog' \ + '--hosts --hostname=blog --fqdn=')" ] + [[ "$stderr" == *"keeps its name blog, which has no domain: no instance.fqdn recorded"* ]] +} + +@test "a preseeded FQDN is not asked even when somebody could answer" { + # the preseed is the answer: the console is not looked at + export INITHOOKS_UNATTENDED="the console has no size" + echo "export FQDN=web.example.org" > "$INITHOOKS_CONF" + export ANSWER=$'HOSTNAME=web\nFQDN=web.example.org\n' + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(calls fqdn.py | head -1)" = "--fqdn=web.example.org --current=blog" ] + [ ! -e "$INITHOOKS_LOGFILE" ] +} + @test "a screen that fails is reported by its status, for run to log" { export ASK_STATUS=1 @@ -261,3 +320,167 @@ real_fqdn_py() { [ -z "$(calls hostname)" ] [ ! -e "$INITHOOKS_DECL" ] } + +# --- the hosts entry the image no longer ships ------------------------------- + +# without_hosts_entry: /etc/hosts as the image ships it since common#35, +# without a 127.0.1.1 line +without_hosts_entry() { + printf '127.0.0.1\tlocalhost\n::1\tlocalhost ip6-localhost\n' \ + > "$HOSTNAME_ROOT/etc/hosts" +} + +@test "a preseeded SKIP writes the hosts entry the image does not ship" { + real_fqdn_py + without_hosts_entry + echo "export FQDN=SKIP" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(cat "$HOSTNAME_ROOT/etc/hosts")" = "$(printf '127.0.0.1\tlocalhost\n::1\tlocalhost ip6-localhost\n127.0.1.1 blog')" ] + [ ! -e "$INITHOOKS_DECL" ] +} + +@test "nobody to answer: the hosts entry is written for the name without a domain" { + real_fqdn_py + without_hosts_entry + export INITHOOKS_UNATTENDED="the console has no size" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(tail -1 "$HOSTNAME_ROOT/etc/hosts")" = "127.0.1.1 blog" ] + [ ! -e "$INITHOOKS_DECL" ] + [ "$(grep -c '\[31fqdn\]' "$INITHOOKS_LOGFILE")" -eq 1 ] +} + +@test "nobody to answer: the domain pct gave the container is kept and recorded" { + real_fqdn_py + stub hostname 'if [[ $# -eq 0 ]]; then echo keel-web1; fi' + printf '127.0.0.1 localhost\n127.0.1.1 keel-web1.pop.coop keel-web1\n' \ + > "$HOSTNAME_ROOT/etc/hosts" + export INITHOOKS_UNATTENDED="the console has no size" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(cat "$HOSTNAME_ROOT/etc/hosts")" = "$(printf '127.0.0.1 localhost\n127.0.1.1 keel-web1.pop.coop keel-web1')" ] + [ "$(sed -n '2,4p' "$INITHOOKS_DECL")" = "$(printf 'instance:\n hostname: keel-web1\n fqdn: keel-web1.pop.coop')" ] + [ -z "$(calls hostname)" ] +} + +@test "the hosts entry has the form keel apply --system writes" { + # keel.system.hosts: `127.0.1.1 `, in place of the + # short line; the two writers must agree, or apply rewrites it + real_fqdn_py + without_hosts_entry + echo "export FQDN=blog.example.org" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(grep -c '^127\.0\.1\.1 ' "$HOSTNAME_ROOT/etc/hosts")" -eq 1 ] + grep -qx '127.0.1.1 blog.example.org blog' "$HOSTNAME_ROOT/etc/hosts" +} + +# --- the certificate follows the name ------------------------------------------ + +# real_certificate CN +# The self-signed certificate the machine has, for CN, and a +# turnkey-make-ssl-cert that makes one for the first name it is given, as +# the real one does, in the scratch SSLCERT_PEM. hostname answers the name +# it was last given. +real_certificate() { + mkdir -p "$(dirname "$SSLCERT_PEM")" + stub turnkey-make-ssl-cert 'names=() +for arg; do [[ "$arg" == -* ]] || names+=("$arg"); done +san=$(printf "DNS:%s," "${names[@]}") +openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=${names[0]}" \ + -addext "subjectAltName=${san%,}" -keyout "$SSLCERT_KEY" \ + -out "$SSLCERT_PEM.crt" 2>/dev/null +cat "$SSLCERT_PEM.crt" "$SSLCERT_KEY" > "$SSLCERT_PEM"' + stub systemctl 'exit 3' + stub update-ca-certificates + stub sleep + turnkey-make-ssl-cert --default --force "$1" + rm "$STUBS/turnkey-make-ssl-cert.calls" + echo blog > "$BATS_TEST_TMPDIR/name" + stub hostname 'name='"$BATS_TEST_TMPDIR"'/name +if [[ $# -eq 0 ]]; then cat "$name"; else echo "$1" > "$name"; fi' +} + +cn() { + openssl x509 -in "$SSLCERT_PEM" -noout -subject -nameopt RFC2253 \ + | sed 's/^subject=CN=//' +} + +@test "after a rename the certificate is made for the new name" { + real_fqdn_py + real_certificate core + echo "export FQDN=web.example.org" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(calls turnkey-make-ssl-cert)" = "--default --force --ip web.example.org web" ] + [ "$(cn)" = web.example.org ] + openssl x509 -in "$SSLCERT_PEM" -noout -ext subjectAltName \ + | grep -q 'DNS:web.example.org, DNS:web' +} + +@test "nobody to answer: the certificate is made for the name the machine keeps" { + real_fqdn_py + real_certificate core + export INITHOOKS_UNATTENDED="the console has no size" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(cn)" = blog ] +} + +@test "a certificate already for the name is not made again" { + real_fqdn_py + real_certificate web.example.org + echo "export FQDN=web.example.org" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ -z "$(calls turnkey-make-ssl-cert)" ] +} + +@test "a certificate an authority signed is kept" { + # confconsole's Let's Encrypt writes the same files + real_fqdn_py + real_certificate core + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=Authority \ + -keyout "$BATS_TEST_TMPDIR/ca.key" -out "$BATS_TEST_TMPDIR/ca.crt" \ + 2>/dev/null + openssl req -new -key "$SSLCERT_KEY" -subj /CN=blog.example.org \ + 2>/dev/null | openssl x509 -req -days 1 -CA "$BATS_TEST_TMPDIR/ca.crt" \ + -CAkey "$BATS_TEST_TMPDIR/ca.key" -out "$SSLCERT_PEM" 2>/dev/null + echo "export FQDN=web.example.org" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ -z "$(calls turnkey-make-ssl-cert)" ] + [ "$(cn)" = blog.example.org ] + [[ "$output" == *"is signed by an authority; kept"* ]] +} + +@test "a certificate that cannot be read is kept, and said" { + real_fqdn_py + mkdir -p "$(dirname "$SSLCERT_PEM")" + echo garbage > "$SSLCERT_PEM" + stub turnkey-make-ssl-cert + echo "export FQDN=web.example.org" > "$INITHOOKS_CONF" + + run --separate-stderr "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ -z "$(calls turnkey-make-ssl-cert)" ] + [[ "$stderr" == *"cannot be read as a certificate; kept"* ]] +} diff --git a/tests/test-keel-firstboot.bats b/tests/test-keel-firstboot.bats index f796042..b4e8273 100644 --- a/tests/test-keel-firstboot.bats +++ b/tests/test-keel-firstboot.bats @@ -18,6 +18,7 @@ REPO=$BATS_TEST_DIRNAME/.. setup() { setup_stubs stub python3 'echo "HUB_APIKEY=${HUB_APIKEY-unset}" >> "'"$STUBS"'/python3.env" +if [[ -t 0 ]]; then echo terminal; else echo none; fi >> "'"$STUBS"'/python3.stdin" exit "${PYTHON_STATUS:-0}"' export INITHOOKS_PATH=$BATS_TEST_TMPDIR/inithooks @@ -32,6 +33,10 @@ exit "${PYTHON_STATUS:-0}"' export KEEL_FIRSTBOOT=$BATS_TEST_TMPDIR/keelfirstboot.py touch "$KEEL_FIRSTBOOT" + # somebody can answer the console (lib/console.sh), unless a test + # says otherwise + export INITHOOKS_UNATTENDED=no + export INITHOOKS_LOGFILE=$BATS_TEST_TMPDIR/inithooks.log unset HUB_APIKEY } @@ -42,6 +47,28 @@ exit "${PYTHON_STATUS:-0}"' [ "$(calls python3)" = "$KEEL_FIRSTBOOT role" ] } +@test "nobody to answer: the screen is run without a terminal, and it is said" { + # keelfirstboot.py still stores a preseeded key then, and draws + # nothing (draw_on_terminal: no terminal on its standard input) + export INITHOOKS_UNATTENDED="the console has no size" + echo "export HUB_APIKEY=key-123" > "$INITHOOKS_CONF" + + run --separate-stderr script -qec "$REPO/firstboot.d/80keel-cloud" /dev/null + + [ "$status" -eq 0 ] + [ "$(calls python3)" = "$KEEL_FIRSTBOOT cloud" ] + [ "$(cat "$STUBS/python3.stdin")" = none ] + [ "$(cat "$STUBS/python3.env")" = "HUB_APIKEY=key-123" ] + [ "$(cat "$INITHOOKS_LOGFILE")" = "INFO: [80keel-cloud] not asked, nobody can answer (the console has no size): confconsole's cloud screen is run without a terminal and draws nothing" ] +} + +@test "somebody to answer: the screen gets the terminal" { + run script -qec "$REPO/firstboot.d/75keel-role" /dev/null < /dev/null + + [ "$status" -eq 0 ] + [ "$(cat "$STUBS/python3.stdin")" = terminal ] +} + @test "80keel-cloud asks for the Keel Cloud key" { run "$REPO/firstboot.d/80keel-cloud" diff --git a/tests/test-regen-sslcert.bats b/tests/test-regen-sslcert.bats index c243995..4d3618a 100644 --- a/tests/test-regen-sslcert.bats +++ b/tests/test-regen-sslcert.bats @@ -9,8 +9,14 @@ # is a side effect of a hook whose job is the certificate, so a logger that # fails may not stop it. # -# turnkey-make-ssl-cert, openssl, systemctl, update-ca-certificates, sleep -# and logger are stubs; `which` is the real one, finding the stubs on PATH. +# The certificate is for the name the machine has, given to +# turnkey-make-ssl-cert: without names it took them from `hostname -A`, a +# reverse lookup of the machine's addresses, and a Web container named web +# served CN=core (2026-10-03). +# +# turnkey-make-ssl-cert, openssl, systemctl, update-ca-certificates, sleep, +# logger, hostname and bin/fqdn.py are stubs; `which` is the real one, +# finding the stubs on PATH. bats_require_minimum_version 1.5.0 @@ -31,19 +37,55 @@ if (( n <= ${OPENSSL_DIFFER:-0} )); then echo "md5 $n"; else echo "md5 same"; fi fi' stub update-ca-certificates stub sleep + stub hostname 'if [[ $# -eq 0 ]]; then echo blog; else exit 1; fi' + export INITHOOKS_PATH=$BATS_TEST_TMPDIR/inithooks + mkdir -p "$INITHOOKS_PATH/bin" + ln -s "$REPO/lib" "$INITHOOKS_PATH/lib" + # the name the machine has, as 31fqdn writes it into /etc/hosts + cat > "$INITHOOKS_PATH/bin/fqdn.py" <> '$STUBS/fqdn.py.calls' +printf '%s' "\${MACHINE-HOSTNAME=blog +FQDN=blog.example.org +}" +EOF + chmod +x "$INITHOOKS_PATH/bin/fqdn.py" export INITHOOKS_CONF=$BATS_TEST_TMPDIR/inithooks.conf - unset _TURNKEY_INIT RUNNING OPENSSL_DIFFER + export INITHOOKS_DEFAULT=$BATS_TEST_TMPDIR/default-inithooks + { + echo "INITHOOKS_PATH=$INITHOOKS_PATH" + echo "INITHOOKS_CONF=$INITHOOKS_CONF" + } > "$INITHOOKS_DEFAULT" + unset _TURNKEY_INIT RUNNING OPENSSL_DIFFER MACHINE } @test "the certificate is made and the trust store updated" { run "$REPO/firstboot.d/15regen-sslcert" [ "$status" -eq 0 ] - [ "$(calls turnkey-make-ssl-cert)" = "--default --force" ] + [ "$(calls turnkey-make-ssl-cert)" = "--default --force --ip blog.example.org blog" ] [ -e "$STUBS/update-ca-certificates.calls" ] [[ "$output" == *"Generating SSL/TLS cert & key"* ]] } +@test "the certificate is for the name the machine has, not a reverse lookup" { + run "$REPO/firstboot.d/15regen-sslcert" + + [ "$status" -eq 0 ] + [ "$(calls fqdn.py)" = "--machine --current=blog" ] + # hostname answered its name only: no -A, no -f + [ -z "$(calls hostname)" ] +} + +@test "a machine without a domain gets a certificate for its hostname" { + export MACHINE=$'HOSTNAME=web\nFQDN=\n' + + run "$REPO/firstboot.d/15regen-sslcert" + + [ "$status" -eq 0 ] + [ "$(calls turnkey-make-ssl-cert)" = "--default --force --ip web" ] +} + @test "a logger that fails does not stop the hook" { # journald down: logger exits 1 with "socket /dev/log: Connection # refused", under bash -e @@ -52,7 +94,7 @@ fi' run --separate-stderr "$REPO/firstboot.d/15regen-sslcert" [ "$status" -eq 0 ] - [ "$(calls turnkey-make-ssl-cert)" = "--default --force" ] + [ "$(calls turnkey-make-ssl-cert)" = "--default --force --ip blog.example.org blog" ] [ -e "$STUBS/update-ca-certificates.calls" ] [[ "$output" == *"Restarting relevant services"* ]] [[ "$stderr" != *"Connection refused"* ]] diff --git a/tests/test-run.bats b/tests/test-run.bats index 02a261f..b25f98f 100644 --- a/tests/test-run.bats +++ b/tests/test-run.bats @@ -331,8 +331,10 @@ run_on_unread_terminal() { import fcntl, os, pty, struct, subprocess, sys, termios master, slave = pty.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) +# close_fds=False: the descriptor kcov traces on (tests/coverage.sh) must +# reach the runner proc = subprocess.run([sys.argv[1]], stdin=subprocess.DEVNULL, stdout=slave, - stderr=sys.stderr) + stderr=sys.stderr, close_fds=False) sys.exit(proc.returncode) PY } @@ -393,10 +395,10 @@ PY grep -q "notices not drawn: the console has no size" "$STUBS/logger.calls" } -@test "a console that does not take a notice does not hold the boot" { - # dialog here writes more than the pty holds, so its write blocks the - # way the real one did; the runner gives it NOTICE_TIMEOUT and goes on - # without notices +@test "a sized console nobody reads is found before any notice" { + # dialog here writes more than the pty holds, so its write would block + # the way the real one did; the console's probe (lib/console.sh) finds + # it first, and nothing is drawn stub dialog 'printf "%0131072d" 0' probe 15first probe 30second @@ -406,9 +408,66 @@ PY [ "$status" -eq 0 ] [ "$(wc -l < "$SEEN")" -eq 3 ] + [ -z "$(calls dialog)" ] + grep -q "notices not drawn: the console did not take a write in 1 s" \ + "$ROOT/inithooks.log" +} + +@test "a console that stops taking notices does not hold the boot" { + # the probe passes, the notice after it does not reach the console; + # the runner gives it NOTICE_TIMEOUT and goes on without notices, and + # the hooks after it are told nobody can answer + stub dialog 'printf "%0131072d" 0' + probe 15first + probe 30second 'echo "$INITHOOKS_UNATTENDED" > '"'$ROOT/told'" + export CONSOLE_PROBE_BYTES=1 + + run_on_unread_terminal + + [ "$status" -eq 0 ] + [ "$(wc -l < "$SEEN")" -eq 2 ] [ "$(calls dialog | wc -l)" -eq 1 ] grep -q "notices not drawn: the console did not take a notice in 1 s" \ "$ROOT/inithooks.log" + [ "$(cat "$ROOT/told")" = "the console did not take a notice in 1 s, nobody is reading it" ] +} + +@test "nobody to answer: the hooks are told, and what they print goes to the log" { + # tty1 of a container nobody is attached to holds what is written to + # it until its buffer is full, and then blocks the writer for good: + # 95secupdates prints the whole upgrade + stub dialog + probe 15first 'echo "$INITHOOKS_UNATTENDED" > '"'$ROOT/told'"'; echo HOOK-PRINTED' + + run_on_unattended_terminal + + [ "$status" -eq 0 ] + [ "$(cat "$ROOT/told")" = "the console has no size, nobody is attached to it" ] + grep -qx HOOK-PRINTED "$ROOT/inithooks.log" + [[ "$output" != *HOOK-PRINTED* ]] +} + +@test "nobody to answer: confconsole still gets the console" { + # for whoever attaches to it later + stub confconsole '[[ -t 1 ]] && echo terminal > '"'$ROOT/confconsole'" + probe 15first + + run_on_unattended_terminal + + [ "$status" -eq 0 ] + [ "$(cat "$ROOT/confconsole")" = terminal ] +} + +@test "somebody to answer: the hooks are told, and print on the console" { + stub dialog + probe 15first 'echo "$INITHOOKS_UNATTENDED" > '"'$ROOT/told'"'; echo HOOK-PRINTED' + + run_on_terminal + + [ "$status" -eq 0 ] + [ "$(cat "$ROOT/told")" = no ] + [[ "$output" == *HOOK-PRINTED* ]] + run ! grep -q HOOK-PRINTED "$ROOT/inithooks.log" } @test "a run whose output goes to the log draws nothing either" { diff --git a/tests/test-secupdates.bats b/tests/test-secupdates.bats index d846a60..c6a014c 100644 --- a/tests/test-secupdates.bats +++ b/tests/test-secupdates.bats @@ -36,6 +36,7 @@ exit "${LS_STATUS:-0}"' export INITHOOKS_PATH=$BATS_TEST_TMPDIR/inithooks mkdir -p "$INITHOOKS_PATH/bin" "$INITHOOKS_PATH/firstboot.d" + ln -s "$REPO/lib" "$INITHOOKS_PATH/lib" touch "$INITHOOKS_PATH/firstboot.d/99reboot" printf '#!/bin/bash\nexit "${ASK_STATUS:-0}"\n' \ > "$INITHOOKS_PATH/bin/secupdates-ask.py" @@ -52,6 +53,10 @@ exit "${LS_STATUS:-0}"' 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" + # somebody can answer the console (lib/console.sh), unless a test + # says otherwise + export INITHOOKS_UNATTENDED=no + export INITHOOKS_LOGFILE=$BATS_TEST_TMPDIR/inithooks.log unset SEC_UPDATES DPKG_AUDIT LS_CHANGES LS_STATUS ASK_STATUS \ UPDATE_STATUS UPGRADE_STATUS CURL_STATUS } @@ -82,6 +87,30 @@ apt_option() { [ ! -x "$INITHOOKS_PATH/firstboot.d/99reboot" ] } +@test "nobody to answer: the updates are installed as FORCE installs them" { + # what the preseed of a headless build says (README.rst) + export INITHOOKS_UNATTENDED="the console has no size" + export ASK_STATUS=99 + + run --separate-stderr "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 0 ] + [ "$(cat "$SEC_UPDATES_RECORD")" = "force" ] + [[ "$(calls apt-get)" == *"dist-upgrade -y"* ]] + [ "$(cat "$INITHOOKS_LOGFILE")" = "INFO: [95secupdates] not asked, nobody can answer (the console has no size): security updates installed, as SEC_UPDATES=FORCE does" ] +} + +@test "nobody to answer a preseeded SKIP: nothing is installed" { + export INITHOOKS_UNATTENDED="the console has no size" + echo "export SEC_UPDATES=SKIP" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/95secupdates" + + [ "$status" -eq 0 ] + [ "$(cat "$SEC_UPDATES_RECORD")" = "skip" ] + [ -z "$(calls apt-get)" ] +} + @test "Skip on the screen records skip" { export ASK_STATUS=99 diff --git a/tests/test_fqdn.py b/tests/test_fqdn.py index 2c4d877..9aabcc4 100644 --- a/tests/test_fqdn.py +++ b/tests/test_fqdn.py @@ -263,6 +263,67 @@ def test_without_a_domain_the_entry_names_the_host_alone(self): self.assertIn("127.0.1.1 blog\n", text) self.assertNotIn("blog.example.org", text) + def test_a_hostname_that_is_the_fqdn_is_named_once(self): + text = fqdn.hosts_with_name("127.0.0.1 localhost\n", + "blog.example.org", "blog.example.org") + + self.assertEqual(text, "127.0.0.1 localhost\n" + "127.0.1.1 blog.example.org\n") + + +class TestInHosts(unittest.TestCase): + """The dotted name /etc/hosts gives the host, as keel inspect reads it + (keel.inspect.hostname.fqdn_in_hosts): from the first line naming it""" + + def test_the_dotted_name_on_the_host_s_line(self): + # what pct writes for a container whose host has a search domain + text = "127.0.0.1 localhost\n127.0.1.1 web1.pop.coop web1\n" + + self.assertEqual(fqdn.in_hosts(text, "web1"), "web1.pop.coop") + + def test_nothing_when_no_line_names_the_host(self): + self.assertEqual(fqdn.in_hosts("127.0.0.1 localhost\n", "web"), "") + + def test_nothing_when_the_first_line_naming_it_has_no_domain(self): + # a resolver answers from the first line, so the later one does + # not give the host its name + text = "127.0.1.1 web\n192.0.2.10 web.example.org web\n" + + self.assertEqual(fqdn.in_hosts(text, "web"), "") + + def test_comments_and_short_lines_are_not_entries(self): + text = "# 127.0.1.1 web.example.org web\nweb\n" + + self.assertEqual(fqdn.in_hosts(text, "web"), "") + + def test_the_host_is_matched_without_case_and_as_a_first_label(self): + text = "127.0.1.1 Web.Example.org\n" + + self.assertEqual(fqdn.in_hosts(text, "web"), "web.example.org") + + +class TestMachine(unittest.TestCase): + """(hostname, fqdn) the machine has, for a first boot nobody answers""" + + def test_a_hostname_alone_has_no_domain(self): + self.assertEqual(fqdn.machine("web", DEBIAN_HOSTS.replace( + "blog", "web")), ("web", "")) + + def test_the_domain_comes_from_the_host_s_line(self): + text = "127.0.1.1 keel-web1.pop.coop keel-web1\n" + + self.assertEqual(fqdn.machine("keel-web1", text), + ("keel-web1", "keel-web1.pop.coop")) + + def test_a_dotted_hostname_is_the_fqdn_and_its_first_label(self): + self.assertEqual(fqdn.machine("Web.Example.org.", ""), + ("web", "web.example.org")) + + def test_a_name_that_is_no_domain_name_gives_no_fqdn(self): + text = "127.0.1.1 web_1.example.org web_1\n" + + self.assertEqual(fqdn.machine("web_1", text), ("web_1", "")) + class TestUpdated(unittest.TestCase): def test_a_new_description_gets_version_instance_and_domains(self): diff --git a/tests/test_fqdn_cli.py b/tests/test_fqdn_cli.py index 09b45f4..94af474 100644 --- a/tests/test_fqdn_cli.py +++ b/tests/test_fqdn_cli.py @@ -344,6 +344,49 @@ def test_the_default_paths_are_the_machine_s(self): "/etc/keel/instance.yaml") +class TestMachine(FqdnCase): + """--machine: the name the machine has, for a first boot nobody + answers and for an operator who skipped, printed as the answer is""" + + def test_the_hostname_and_the_domain_of_its_hosts_line(self): + with open(self.hosts, "w") as fob: + fob.write("127.0.1.1 keel-web1.pop.coop keel-web1\n") + + status, out, err, console = self.run_fqdn( + argv=("--machine", "--current=keel-web1")) + + self.assertEqual((status, err), (0, "")) + self.assertEqual(out, "HOSTNAME=keel-web1\nFQDN=keel-web1.pop.coop\n") + self.assertEqual(console.calls, []) + + def test_a_hostname_without_a_domain(self): + status, out, _, _ = self.run_fqdn(argv=("--machine", "--current=web")) + + self.assertEqual((status, out), (0, "HOSTNAME=web\nFQDN=\n")) + + def test_a_hosts_file_that_does_not_exist_gives_no_domain(self): + os.remove(self.hosts) + + status, out, _, _ = self.run_fqdn(argv=("--machine", "--current=web")) + + self.assertEqual((status, out), (0, "HOSTNAME=web\nFQDN=\n")) + + def test_a_hosts_file_that_cannot_be_read_is_fatal(self): + os.remove(self.hosts) + os.mkdir(self.hosts) + + status, _, err, _ = self.run_fqdn(argv=("--machine", "--current=web")) + + self.assertEqual(status, 1) + self.assertIn(self.hosts, err) + + def test_machine_needs_the_current_name(self): + status, _, err, _ = self.run_fqdn(argv=("--machine",)) + + self.assertEqual(status, 1) + self.assertIn("--machine needs --current", err) + + class TestUsage(FqdnCase): def test_an_unknown_option_is_a_usage_error(self): status, _, err, _ = self.run_fqdn(argv=("--nonsense",))