diff --git a/COVERAGE.md b/COVERAGE.md index 48dbaf36..ed87d049 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -4,6 +4,50 @@ Measured on 2026-09-24 against upstream 19.x (b60dd23), following the project decision 0003 (90 percent floor per repository, 95 percent for every file our changes touch). +## Branch fix/webmin-auth-hardening: 100 percent, five files (2026-09-29) + +Every file this branch touches has a target in `tests/coverage.sh`, and the +two suites that measure no file of their own run after them. Measured with +kcov 43 on Debian 13, 85 bats after the merge of 19.x (#8 and #10), the +five files of this branch: + +| File | Lines | Covered | Percent | +|------|-------|---------|---------| +| conf/samba-rootpass | 8 | 8 | 100 | +| conf/turnkey.d/postfix-local | 17 | 17 | 100 | +| conf/turnkey.d/rootpass | 6 | 6 | 100 | +| conf/turnkey.d/webmin-enable | 10 | 10 | 100 | +| conf/turnkey.d/webmin-pam | 7 | 7 | 100 | +| Total | 48 | 48 | 100 | + +`conf/turnkey.d/rootpass`, `conf/turnkey.d/webmin-enable` and +`conf/samba-rootpass` changed shebang from `/bin/sh` to `/bin/bash`, because +kcov measures bash and not dash and decision 0003 gives no exemption for a +file a change touches. + +The gate in `.github/workflows/tests.yml` stays at 100, the measured number. + +The suite asserts behaviour and not configuration. The question "can this +password get in" is put to the real Linux-PAM: `tests/pam-authenticate` +calls `pam_authenticate` through libpam over the scratch image's own stack, +in a private mount namespace with the scratch shadow and passwd files bind +mounted over the real ones, so pam_unix and its unix_chkpwd helper are the +installed ones. An earlier version of the suite modelled pam_unix by hand +and reported an acceptance the module does not make; nothing is modelled +now. What pam_unix makes of the crypt() of the empty string differs between +libpam 1.5 and 1.7, so the five tests that depend on it run on 1.7, the +appliance's, and skip by name elsewhere: the CI runner has 1.5.3 and skips +them. The verdict on whether the web interface would start is +`systemd-analyze condition` over the packaged `webmin.service` and the +drop-in together, both handed to `systemd-analyze verify`. The shadow tools +and `smbpasswd` are the things stubbed, because they chroot into the root +they are given or talk to a daemon; each stub records the behaviour it +reproduces. + +`tests/before-firstboot.bats` runs the three conf scripts over one scratch +image in the order a build runs them and asks the whole question of the +result, because no single script owns the answer. + ## Measured baseline on the default branch: 100 percent (2026-09-28) Pull request #2 merged on 2026-09-26 (merge commit 5a0a381) and brought diff --git a/changes/turnkey.changelog b/changes/turnkey.changelog index 1cdb7da9..1e29962f 100644 --- a/changes/turnkey.changelog +++ b/changes/turnkey.changelog @@ -1,5 +1,41 @@ turnkey-core-19.0 (1) turnkey; urgency=low + * An appliance no longer carries an account that can be authenticated + before its first boot has set a password. A build given no 'ROOT_PASS' + wrote 'U6aMy0wojraho', the crypt() of the empty string, into the root + account. On libpam 1.7 (Debian 13), through any PAM stack carrying + 'nullok', Debian's common-auth among them, pam_unix authenticates that + field with any password without asking for one. It now writes '*', + which pam_unix refuses whatever is submitted and whatever the stack + says, and the first boot writes over it as before. This is the change + that holds the account shut; the two below narrow what reaches it. + The field is deliberately not '!', which shadow reads as a locked + account: 'passwd --unlock root', which turnkey-sudoadmin runs on every + first boot, refuses to unlock a field of exactly '!', exits 3 and + leaves the rest of that hook undone. + + * A build time 'ROOT_PASS' holding a glob character is now set as written. + It was expanded against the build directory before reaching chpasswd. + + * 'conf/samba-rootpass', which runs after rootpass on a Samba appliance, + wrote the old field back, expanded 'ROOT_PASS' the same way and traced + it into the build log. It now writes '*', quotes the password and does + not trace. + + * 'nullok' is removed from the PAM stack the webmin package installs, in + the new 'webmin-pam' conf script, so that an account whose password is + not set cannot be authenticated through Webmin without one. Nothing in + an appliance asked for it. 'common-auth' keeps it for now (#13). + + * Webmin waits for the first boot scripts before it starts. Its unit gains + a condition on the marker inithooks writes when its boot scripts have + run, and a path unit starts it when that marker appears and records + that it has, so every later boot starts Webmin with the rest of the + boot. The marker means the scripts ran, not that they succeeded. A + first boot that never finishes keeps Webmin shut; 'systemctl start + webmin-after-firstboot.service' opens it by hand, for good. An image + carrying no inithooks at all is unaffected. + * apt no longer announces the appliance to every archive it contacts. The per-appliance '/etc/apt/apt.conf.d/01turnkey', which carried a User-Agent of 'TurnKey APT-HTTP/1.3 (turnkey----)', is diff --git a/conf/samba-rootpass b/conf/samba-rootpass index 470c379e..c4c166ff 100755 --- a/conf/samba-rootpass +++ b/conf/samba-rootpass @@ -1,18 +1,30 @@ -#!/bin/sh -ex +#!/bin/bash -e # set samba root password +# +# The same account field as conf/turnkey.d/rootpass writes, for the same +# reason: '*' is a field no password matches, through any PAM stack, until +# the first boot sets a password over it. The old value, 'U6aMy0wojraho', +# is what crypt() returns for the empty string, and a stack carrying nullok +# (Debian's common-auth does) let any password in against it. +# +# No xtrace: it printed ROOT_PASS into the build log. +# +# 'smbpasswd -n' gives the Samba account a null password, which Samba +# refuses to authenticate unless 'null passwords = yes' is set in smb.conf; +# nothing in this tree sets it. That call is unchanged. +NO_PASSWORD='*' + passwd --unlock root # useful when testing in chroot if [ -n "$ROOT_PASS" ]; then - echo root:$ROOT_PASS | chpasswd + echo "root:$ROOT_PASS" | chpasswd else - # this is a blank password - usermod -p U6aMy0wojraho root + usermod -p "$NO_PASSWORD" root fi -if [ "$ROOT_PASS" ]; then +if [ -n "$ROOT_PASS" ]; then (echo "$ROOT_PASS" ; echo "$ROOT_PASS" ) | smbpasswd -a -s root else smbpasswd -a -n root fi - diff --git a/conf/turnkey.d/rootpass b/conf/turnkey.d/rootpass index d9a0f2ff..306a6422 100755 --- a/conf/turnkey.d/rootpass +++ b/conf/turnkey.d/rootpass @@ -1,12 +1,22 @@ -#!/bin/sh -e +#!/bin/bash -e + +# The password field written into an image the build gives no root password +# to. crypt() returns no such string for any input, so nothing a caller can +# submit matches it and the account cannot be authenticated until the first +# boot sets a password over it with chpasswd. +# +# Deliberately not '!': shadow reads a field of exactly '!' as a locked +# account, and 'passwd --unlock root', which turnkey-sudoadmin runs on every +# first boot, then refuses it ("unlocking the password would result in a +# passwordless account"), exits 3 and leaves the rest of that hook undone. +NO_PASSWORD='*' # chroot only environments don't need a root password if [ ! "$CHROOT_ONLY" ]; then if [ -n "$ROOT_PASS" ]; then - echo root:$ROOT_PASS | chpasswd + echo "root:$ROOT_PASS" | chpasswd else - # this is a blank password - usermod -p U6aMy0wojraho root + usermod -p "$NO_PASSWORD" root fi else passwd --lock root diff --git a/conf/turnkey.d/webmin-enable b/conf/turnkey.d/webmin-enable index d8563ecb..5f0ca4c0 100755 --- a/conf/turnkey.d/webmin-enable +++ b/conf/turnkey.d/webmin-enable @@ -1,3 +1,78 @@ -#!/bin/sh -e +#!/bin/bash -e + +# An appliance is configured by its first boot scripts, and a web interface +# is not something it needs before then. So the unit does not start with the +# rest of the boot until the first boot scripts have run once. +# +# The three conditions are triggering conditions, which systemd reads as an +# OR: +# +# - WEBMIN_OPENED exists: a first boot got to the end of its scripts on +# this machine once, and this boot is not waiting for it. The one shot +# below writes it; the build does not, so no image carries it. +# - the marker inithooks writes when its boot scripts are done, on the +# boot that has not got that far yet. +# - the image carries no inithooks at all, and there is nothing to wait +# for. +# +# The path unit starts Webmin when the marker appears; it triggers a one +# shot that stays active afterwards, so it fires once per boot and does not +# restart Webmin behind an administrator who stopped it. +# +# The marker is written when the scripts have run, not when they succeeded: +# inithooks logs a failing script and carries on. What keeps an account +# shut on a first boot that failed to set a password is rootpass (a field +# no password matches), not this. +# +# A first boot that never finishes keeps the interface shut on every boot +# until one does. To open it by hand, from a console: +# +# systemctl start webmin-after-firstboot.service +# +# which starts Webmin and records that it has been opened, for every boot +# after. 'systemctl start webmin' alone exits 0 and starts nothing while the +# conditions are unmet. +# +# SYSTEMD_DIR, INITHOOKS_MARKER, INITHOOKS_RUN and WEBMIN_OPENED are test +# hooks; a build leaves them unset. + +SYSTEMD_DIR=${SYSTEMD_DIR:-/etc/systemd/system} +INITHOOKS_MARKER=${INITHOOKS_MARKER:-/run/inithooks-complete} +INITHOOKS_RUN=${INITHOOKS_RUN:-/usr/lib/inithooks/run} +WEBMIN_OPENED=${WEBMIN_OPENED:-/var/lib/webmin-after-firstboot/opened} + +mkdir -p "$SYSTEMD_DIR/webmin.service.d" + +cat > "$SYSTEMD_DIR/webmin.service.d/after-firstboot.conf" < "$SYSTEMD_DIR/webmin-after-firstboot.path" < "$SYSTEMD_DIR/webmin-after-firstboot.service" <&2 + exit 1 +} + +[[ -f "$PAM_WEBMIN" ]] || fatal "$PAM_WEBMIN does not exist" + +sed -i -E 's/[[:space:]]+nullok(_secure)?\b//g' "$PAM_WEBMIN" + +if grep -qE '\bnullok' "$PAM_WEBMIN"; then + fatal "nullok survived in $PAM_WEBMIN" +fi diff --git a/tests/before-firstboot.bats b/tests/before-firstboot.bats new file mode 100644 index 00000000..4cffe790 --- /dev/null +++ b/tests/before-firstboot.bats @@ -0,0 +1,96 @@ +#!/usr/bin/env bats +# What a freshly built image lets through between the end of the build and +# the end of the first boot, when no password has been set on it yet. +# +# The conf scripts that decide it are run over one scratch image in the +# order the build runs them, and the image is then asked the only question +# worth asking of it: can anybody get in. +# +# No single script owns the answer. rootpass decides what the account +# carries, webmin-pam decides what the stack makes of it and webmin-enable +# decides whether the interface is listening at all, so they are tested +# together as well as apart. + +bats_require_minimum_version 1.5.0 + +load helpers + +setup() { + TESTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + CONF_DIR="$TESTS_DIR/../conf/turnkey.d" + scratch_image + + export INITHOOKS_MARKER=$IMAGE/run/inithooks-complete + export INITHOOKS_RUN=$IMAGE/usr/lib/inithooks/run + mkdir -p "$IMAGE/run" "$IMAGE/usr/lib/inithooks" + touch "$INITHOOKS_RUN" + + DROPIN=$SYSTEMD_DIR/webmin.service.d/after-firstboot.conf + unset CHROOT_ONLY ROOT_PASS +} + +# build_image +# Runs the conf scripts of this change in the order fab runs a conf +# directory, which is the order their names sort in. +build_image() { + local script + for script in $(printf '%s\n' rootpass webmin-enable webmin-pam | sort); do + "$CONF_DIR/$script" + done +} + +@test "no password gets in before the first boot has set one" { + build_image + run nothing_authenticates + [ "$status" -eq 0 ] +} + +# Webmin is one way in. The console login, su and everything else that +# includes common-auth are the others, and common-auth keeps nullok +# (Keel-Linux/common#13), so this is the question only the account field +# answers. +@test "and none gets in through the stack everything else uses either" { + build_image + run nothing_authenticates root "$PAM_COMMON_AUTH" + [ "$status" -eq 0 ] +} + +@test "and the interface is not up to be asked" { + build_image + run ! unit_would_start "$DROPIN" +} + +@test "and it stays that way for as long as the first boot takes" { + build_image + run ! unit_would_start "$DROPIN" + run nothing_authenticates + [ "$status" -eq 0 ] + + passwd --unlock root + run nothing_authenticates + [ "$status" -eq 0 ] + run ! unit_would_start "$DROPIN" +} + +@test "and opens when the first boot has set a password" { + build_image + echo 'root:hunter2' | chpasswd + touch "$INITHOOKS_MARKER" + + run unit_would_start "$DROPIN" + [ "$status" -eq 0 ] + run authenticates "hunter2" + [ "$status" -eq 0 ] + run refuses "" + [ "$status" -eq 0 ] +} + +@test "a build given a root password is held shut the same way" { + export ROOT_PASS=s3cret + build_image + run ! unit_would_start "$DROPIN" + run authenticates "s3cret" + [ "$status" -eq 0 ] + run refuses "" + [ "$status" -eq 0 ] +} diff --git a/tests/coverage.sh b/tests/coverage.sh index 67ced292..10f5bbdb 100755 --- a/tests/coverage.sh +++ b/tests/coverage.sh @@ -20,6 +20,10 @@ targets=( "conf/turnkey.d/postfix-local:tests/postfix-local.bats" "conf/turnkey.d/dpkg-vendor:tests/dpkg-vendor.bats" "conf/turnkey.d/apt-identity:tests/apt-identity.bats" + "conf/samba-rootpass:tests/samba-rootpass.bats" + "conf/turnkey.d/rootpass:tests/rootpass.bats" + "conf/turnkey.d/webmin-enable:tests/webmin-enable.bats" + "conf/turnkey.d/webmin-pam:tests/webmin-pam.bats" ) for tool in kcov bats; do @@ -53,6 +57,11 @@ for target in "${targets[@]}"; do fi done +# Suites that measure no file of their own still have to pass: +# before-firstboot.bats runs rootpass, webmin-enable and webmin-pam together, +# and pam-unix.bats pins the pam_unix behaviour the others rest on. +bats "$root/tests/before-firstboot.bats" "$root/tests/pam-unix.bats" + if [ "$failed" -ne 0 ]; then exit 1 fi diff --git a/tests/fixtures/pam.d-common-auth b/tests/fixtures/pam.d-common-auth new file mode 100644 index 00000000..25f30c24 --- /dev/null +++ b/tests/fixtures/pam.d-common-auth @@ -0,0 +1,3 @@ +auth [success=1 default=ignore] pam_unix.so nullok +auth requisite pam_deny.so +auth required pam_permit.so diff --git a/tests/fixtures/pam.d-webmin b/tests/fixtures/pam.d-webmin new file mode 100644 index 00000000..4bb7cb8a --- /dev/null +++ b/tests/fixtures/pam.d-webmin @@ -0,0 +1,4 @@ +#%PAM-1.0 +auth required pam_unix.so nullok +account required pam_unix.so +session required pam_unix.so diff --git a/tests/fixtures/webmin.service b/tests/fixtures/webmin.service new file mode 100644 index 00000000..655cf915 --- /dev/null +++ b/tests/fixtures/webmin.service @@ -0,0 +1,16 @@ +[Unit] +Description=Webmin server daemon +Wants=network-online.target +After=network.target network-online.target + +[Service] +Type=simple +Environment="PERLLIB=/usr/share/webmin" +ExecStart=/usr/share/webmin/miniserv.pl --nofork /etc/webmin/miniserv.conf +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=2s +TimeoutStopSec=300s + +[Install] +WantedBy=multi-user.target diff --git a/tests/helpers.bash b/tests/helpers.bash new file mode 100644 index 00000000..780392f7 --- /dev/null +++ b/tests/helpers.bash @@ -0,0 +1,211 @@ +# Shared helpers for the conf scripts that decide whether an account of a +# freshly built image can be authenticated. +# +# The verdict these tests take is the one pam_unix takes, because it is +# pam_unix that takes it: tests/pam-authenticate loads the real libpam, +# starts the scratch image's own stack and calls pam_authenticate, in a +# private mount namespace where the scratch shadow and passwd files are bind +# mounted over /etc/shadow and /etc/passwd (sandbox_mount_ns). Everything +# pam_unix does is the real thing, including running unix_chkpwd, which it +# does for every shadowed account even as root. +# +# It runs it twice, and that is what a hand model of it missed. Before it +# asks for a password at all, _unix_blankpasswd() hands the helper an empty +# one, with nullok when the stack carries it; if the helper accepts, the +# account "has a blank password" and is authenticated without being asked. +# On libpam 1.7 the helper accepts the empty password against any field +# crypt() returns for it, so with nullok a field of 'U6aMy0wojraho' lets any +# password in. Libpam 1.5 refuses that same empty password under nullok, +# and accepts it without. Which one a verdict holds for is therefore a +# property of the libpam that gave it: see require_measured_libpam. +# +# The shadow tools are stubs, because usermod, chpasswd and passwd chroot +# into the root they are given and so cannot touch a scratch tree as an +# ordinary user. Each stub carries the behaviour it reproduces, and the +# command that measured it, at the top of the file. + +HELPERS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIXTURES="$HELPERS_DIR/fixtures" + +# scratch_image +# The part of an image under construction that these scripts touch: the +# account databases, the PAM stack the webmin package installs, Debian's +# common-auth as a built core carries it, and an empty systemd tree. Puts the stubs first on PATH and points every test hook at +# the scratch tree. +# +# root starts out carrying the field an earlier build wrote, so a script +# that writes nothing at all fails these tests instead of passing on the +# state it was handed. +scratch_image() { + IMAGE=$BATS_TEST_TMPDIR/image + mkdir -p "$IMAGE/etc/pam.d" "$IMAGE/etc/systemd/system" + printf 'root:x:0:0:root:/root:/bin/bash\n' > "$IMAGE/etc/passwd" + printf 'root:U6aMy0wojraho:20718:0:99999:7:::\n' > "$IMAGE/etc/shadow" + cp "$FIXTURES/pam.d-webmin" "$IMAGE/etc/pam.d/webmin" + cp "$FIXTURES/pam.d-common-auth" "$IMAGE/etc/pam.d/common-auth" + + export PATH="$HELPERS_DIR/stubs:$PATH" + export STUB_LOG=$BATS_TEST_TMPDIR/calls.log + : > "$STUB_LOG" + + export SHADOW_FILE=$IMAGE/etc/shadow + export PAM_WEBMIN=$IMAGE/etc/pam.d/webmin + export PAM_COMMON_AUTH=$IMAGE/etc/pam.d/common-auth + export SYSTEMD_DIR=$IMAGE/etc/systemd/system +} + +# MEASURED_LIBPAM +# The libpam the version dependent facts in these suites were measured on: +# Debian 13's, which is the one an appliance runs. +MEASURED_LIBPAM=1.7 + +# require_measured_libpam +# Skips the calling test, naming both versions, when the libpam-modules +# installed here is not MEASURED_LIBPAM. For a test whose verdict differs +# between libpam versions; a test that holds on every version does not call +# it. +require_measured_libpam() { + local here + here=$(dpkg-query -W -f '${Version}' libpam-modules 2>/dev/null || true) + if [[ "$here" != "$MEASURED_LIBPAM".* ]]; then + skip "libpam-modules ${here:-unknown} here; measured on $MEASURED_LIBPAM" + fi +} + +# field_of USER +# The password field USER carries in the scratch image. +field_of() { + awk -F: -v user="${1:-root}" '$1 == user { print $2 }' "${SHADOW_FILE:?}" +} + +# stack_allows_blank [PAM_FILE] +# Whether nullok is written on an auth line of the stack. Configuration, +# not behaviour: what it does is for pam_verdict to say. +stack_allows_blank() { + grep -E '^[[:space:]]*auth[[:space:]]' "${1:-$PAM_WEBMIN}" \ + | grep -qE '\bnullok(_secure)?\b' +} + +# sandbox_mount_ns COMMAND... +# Runs COMMAND as root of a private mount namespace: an unprivileged user +# namespace where the kernel allows one to mount (Debian), otherwise +# 'sudo -n unshare --mount' where sudo needs no password (the Ubuntu CI +# runner, whose AppArmor policy leaves an unprivileged user namespace +# without the right to mount). Either way nothing mounted is visible +# outside the namespace. Exits 77 when neither is available. +sandbox_mount_ns() { + if unshare --user --map-root-user --mount -- \ + /bin/sh -c 'mount -t tmpfs none /mnt' 2>/dev/null; then + unshare --user --map-root-user --mount -- "$@" + elif sudo -n unshare --mount -- \ + /bin/sh -c 'mount -t tmpfs none /mnt' 2>/dev/null; then + sudo -n unshare --mount -- "$@" + else + echo "sandbox_mount_ns: no mount namespace available here" >&2 + return 77 + fi +} + +# pam_verdict PASSWORD [USER] [PAM_FILE] +# The real pam_authenticate over PAM_FILE (the scratch image's webmin stack +# by default) for USER with PASSWORD, against the scratch image's accounts. +# Returns 0 authenticated, 1 refused, and 3 or more when the question could +# not be asked. +# +# The verdict is read from the return code pam-authenticate prints, never +# from an exit status: unshare and mount exit 1 when they fail, and a +# sandbox that does not work must not be read as a refusal. +pam_verdict() { + local password=$1 + local user=${2:-root} + local pam_file=${3:-$PAM_WEBMIN} + local output + output=$(sandbox_mount_ns /bin/bash -c ' + mount --bind "$1" /etc/shadow || exit 4 + mount --bind "$2" /etc/passwd || exit 4 + exec "$3" "$4" "$5" "$6" "$7" + ' pam_verdict "${SHADOW_FILE:?}" "$IMAGE/etc/passwd" \ + "$HELPERS_DIR/pam-authenticate" \ + "$(dirname "$pam_file")" "$(basename "$pam_file")" \ + "$user" "$password") + echo "$output" + case $output in + "pam=0 "*) return 0 ;; + "pam=7 "*) return 1 ;; + "pam="*) return 3 ;; + *) echo "pam_verdict: the sandbox did not answer" >&2 + return 4 ;; + esac +} + +# authenticates PASSWORD [USER] [PAM_FILE] +# Succeeds when pam_unix lets PASSWORD in, fails otherwise, including when +# the question could not be asked. +authenticates() { + local rc=0 + pam_verdict "$@" || rc=$? + [[ $rc -eq 0 ]] +} + +# refuses PASSWORD [USER] [PAM_FILE] +# Succeeds only when pam_unix refuses PASSWORD. A refutation is written with +# this and never as '! authenticates', which a sandbox that does not work +# would satisfy. +refuses() { + local rc=0 + pam_verdict "$@" || rc=$? + if [[ $rc -gt 1 ]]; then + echo "pam_verdict could not ask (status $rc)" >&2 + fi + [[ $rc -eq 1 ]] +} + +# nothing_authenticates [USER] [PAM_FILE] +# Succeeds when pam_unix refuses every password tried: the empty one, the +# ones worth trying first, and a right looking one. +nothing_authenticates() { + local user=${1:-root} + local pam_file=${2:-$PAM_WEBMIN} + local password + for password in "" " " "root" "toor" "password" "turnkey" "*" "!" \ + "U6aMy0wojraho" "hunter2"; do + if ! refuses "$password" "$user" "$pam_file"; then + echo "not refused: '$password' against field '$(field_of "$user")'" >&2 + return 1 + fi + done +} + +# conditions_of UNIT_FILE... +# The Condition lines of a unit and its drop-ins, as arguments for +# systemd-analyze. systemd merges them into one unit, so they are read +# together: a plain condition in any of the files is ANDed with the +# triggering ones of all of them. +conditions_of() { + cat "$@" | grep -E '^Condition' || true +} + +# unit_would_start UNIT_FILE... +# systemd's own verdict on whether a unit with those conditions would run, +# taken from systemd-analyze, not from reading the file. +unit_would_start() { + local -a conditions + readarray -t conditions < <(conditions_of "$@") + [[ ${#conditions[@]} -gt 0 ]] || return 0 + systemd-analyze condition "${conditions[@]}" >/dev/null +} + +# run_exec_start UNIT_FILE +# Runs the ExecStart lines of a oneshot unit in order, stopping at the +# first that fails, as systemd does. Each command is looked up by its name +# on PATH, so a stub stands in for systemctl and touch and mkdir are the +# real ones. +run_exec_start() { + local line + local -a argv + while IFS= read -r line; do + read -r -a argv <<< "${line#ExecStart=}" + argv[0]=$(basename "${argv[0]}") + "${argv[@]}" || return + done < <(grep -E '^ExecStart=' "$1") +} diff --git a/tests/pam-authenticate b/tests/pam-authenticate new file mode 100755 index 00000000..6647160a --- /dev/null +++ b/tests/pam-authenticate @@ -0,0 +1,125 @@ +#!/usr/bin/python3 +"""Ask the real Linux-PAM whether a password gets a user in. + + pam-authenticate CONFDIR SERVICE USER PASSWORD + +Loads libpam.so.0, starts SERVICE with its stack read from CONFDIR +(pam_start_confdir, Linux-PAM 1.4 and later) and calls pam_authenticate, +answering every prompt with PASSWORD. The modules in the stack are the ones +installed on this machine, and they do what they do in any application, +Webmin's miniserv included: pam_unix runs unix_chkpwd for a shadowed +account, as root or not. + +Run it inside a private mount namespace with a scratch shadow file bind +mounted over /etc/shadow (helpers.bash does), never against the real one. + +Exit status: 0 authenticated, 1 refused (PAM_AUTH_ERR), 3 anything else, +so that a sandbox or stack that does not work is never read as a refusal. +Prints one line: the PAM return code and whether the stack asked for a +password at all. +""" + +import ctypes +import ctypes.util +import sys + +PAM_SUCCESS = 0 +PAM_AUTH_ERR = 7 +PAM_PROMPT_ECHO_OFF = 1 +PAM_PROMPT_ECHO_ON = 2 +PAM_FAIL_DELAY = 10 + + +class PamMessage(ctypes.Structure): + _fields_ = [("msg_style", ctypes.c_int), ("msg", ctypes.c_char_p)] + + +class PamResponse(ctypes.Structure): + _fields_ = [("resp", ctypes.c_void_p), ("resp_retcode", ctypes.c_int)] + + +CONV_FUNC = ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.c_int, + ctypes.POINTER(ctypes.POINTER(PamMessage)), + ctypes.POINTER(ctypes.POINTER(PamResponse)), + ctypes.c_void_p, +) + + +# void (*)(int retval, unsigned usec_delay, void *appdata_ptr) +DELAY_FUNC = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_uint, + ctypes.c_void_p) + + +class PamConv(ctypes.Structure): + _fields_ = [("conv", CONV_FUNC), ("appdata_ptr", ctypes.c_void_p)] + + +def main(argv): + if len(argv) != 5: + print(__doc__.strip().splitlines()[2].strip(), file=sys.stderr) + return 3 + confdir, service, user, password = argv[1:] + + libpam = ctypes.CDLL("libpam.so.0") + libc = ctypes.CDLL(ctypes.util.find_library("c")) + libc.calloc.restype = ctypes.c_void_p + libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t] + libc.strdup.restype = ctypes.c_void_p + libc.strdup.argtypes = [ctypes.c_char_p] + libpam.pam_start_confdir.argtypes = [ + ctypes.c_char_p, ctypes.c_char_p, ctypes.POINTER(PamConv), + ctypes.c_char_p, ctypes.POINTER(ctypes.c_void_p)] + libpam.pam_authenticate.argtypes = [ctypes.c_void_p, ctypes.c_int] + libpam.pam_end.argtypes = [ctypes.c_void_p, ctypes.c_int] + libpam.pam_set_item.argtypes = [ctypes.c_void_p, ctypes.c_int, + ctypes.c_void_p] + + prompted = [] + + # libpam frees the responses, so they are allocated with its allocator. + def converse(count, messages, responses, _appdata): + array = libc.calloc(count, ctypes.sizeof(PamResponse)) + if not array: + return 5 + reply = ctypes.cast(array, ctypes.POINTER(PamResponse)) + for i in range(count): + style = messages[i].contents.msg_style + if style in (PAM_PROMPT_ECHO_OFF, PAM_PROMPT_ECHO_ON): + prompted.append(style) + reply[i].resp = libc.strdup(password.encode()) + responses[0] = reply + return PAM_SUCCESS + + conv = PamConv(CONV_FUNC(converse), None) + handle = ctypes.c_void_p() + rc = libpam.pam_start_confdir(service.encode(), user.encode(), + ctypes.byref(conv), confdir.encode(), + ctypes.byref(handle)) + if rc != PAM_SUCCESS: + print("pam_start_confdir failed: %d" % rc, file=sys.stderr) + return 3 + # pam_unix asks for a delay of about two seconds after a refusal. An + # application that sets PAM_FAIL_DELAY is handed the delay instead of + # libpam sleeping it; this one ignores it. The verdict is unchanged. + no_delay = DELAY_FUNC(lambda _rc, _usec, _appdata: None) + rc = libpam.pam_set_item(handle, PAM_FAIL_DELAY, + ctypes.cast(no_delay, ctypes.c_void_p)) + if rc != PAM_SUCCESS: + print("pam_set_item(PAM_FAIL_DELAY) failed: %d" % rc, file=sys.stderr) + libpam.pam_end(handle, rc) + return 3 + rc = libpam.pam_authenticate(handle, 0) + libpam.pam_end(handle, rc) + + print("pam=%d prompted=%s" % (rc, "yes" if prompted else "no")) + if rc == PAM_SUCCESS: + return 0 + if rc == PAM_AUTH_ERR: + return 1 + return 3 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/pam-unix.bats b/tests/pam-unix.bats new file mode 100644 index 00000000..e3150282 --- /dev/null +++ b/tests/pam-unix.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +# What pam_unix itself does with the password fields and the option these +# conf scripts decide, asked of the real module (tests/pam-authenticate). +# +# These are not tests of this repository's scripts. They pin the facts the +# other suites rest on, so that a libpam that changes them, or a sandbox +# that stops asking the module at all, fails here by name instead of +# turning another suite quietly green. +# +# What pam_unix makes of 'U6aMy0wojraho', the crypt() of the empty string, +# changed between libpam 1.5 and 1.7 (helpers.bash has how), so those two +# facts are pinned for the libpam an appliance runs and skipped, by name, +# on any other. + +bats_require_minimum_version 1.5.0 + +load helpers + +setup() { + scratch_image + NO_NULLOK=$IMAGE/etc/pam.d/no-nullok + printf 'auth\trequired\tpam_unix.so\n' > "$NO_NULLOK" + HASH=$(perl -e 'print crypt($ARGV[0], $ARGV[1])' s3cret '$6$keelpins$') +} + +set_field() { + printf 'root:%s:20718:0:99999:7:::\n' "$1" > "$SHADOW_FILE" +} + +@test "the sandbox asks the module: a set password gets in and a wrong one does not" { + set_field "$HASH" + run authenticates s3cret + [ "$status" -eq 0 ] + [[ "$output" == *"prompted=yes"* ]] + run refuses wrong + [ "$status" -eq 0 ] +} + +@test "a user the module cannot find is not read as a refusal" { + run pam_verdict anything nobody-here + [ "$status" -ge 3 ] + run refuses anything nobody-here + [ "$status" -ne 0 ] +} + +@test "with nullok, an empty field lets any password in without asking" { + set_field '' + run pam_verdict "anything at all" + [ "$status" -eq 0 ] + [[ "$output" == *"prompted=no"* ]] +} + +@test "without nullok, an empty field refuses every password" { + set_field '' + run nothing_authenticates root "$NO_NULLOK" + [ "$status" -eq 0 ] +} + +@test "libpam 1.7, with nullok: the crypt of the empty string lets any password in without asking" { + require_measured_libpam + set_field 'U6aMy0wojraho' + run pam_verdict "anything at all" + [ "$status" -eq 0 ] + [[ "$output" == *"prompted=no"* ]] +} + +@test "libpam 1.7, without nullok: the crypt of the empty string refuses every password" { + require_measured_libpam + set_field 'U6aMy0wojraho' + run nothing_authenticates root "$NO_NULLOK" + [ "$status" -eq 0 ] +} + +@test "a field starting with '*' or '!' refuses everything, whatever the option" { + local field stack + for field in '*' '!' "!$HASH" "*$HASH"; do + set_field "$field" + for stack in "$PAM_WEBMIN" "$NO_NULLOK"; do + run nothing_authenticates root "$stack" + [ "$status" -eq 0 ] + run refuses s3cret root "$stack" + [ "$status" -eq 0 ] + done + done +} diff --git a/tests/rootpass.bats b/tests/rootpass.bats new file mode 100644 index 00000000..c2c131f2 --- /dev/null +++ b/tests/rootpass.bats @@ -0,0 +1,102 @@ +#!/usr/bin/env bats +# Tests for conf/turnkey.d/rootpass: what a caller can get in with, in the +# image the script leaves behind. +# +# The verdict is pam_unix's, over the real crypt(); the shadow tools are +# stubs. See helpers.bash. + +bats_require_minimum_version 1.5.0 + +load helpers + +setup() { + TESTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + SCRIPT="$TESTS_DIR/../conf/turnkey.d/rootpass" + scratch_image + unset CHROOT_ONLY ROOT_PASS +} + +@test "no password gets root in when the build sets none" { + run "$SCRIPT" + [ "$status" -eq 0 ] + run nothing_authenticates + [ "$status" -eq 0 ] +} + +@test "the empty password is refused although the stack still allows a blank one" { + stack_allows_blank + run "$SCRIPT" + [ "$status" -eq 0 ] + run refuses "" + [ "$status" -eq 0 ] +} + +@test "the field left behind is one passwd --unlock accepts" { + run "$SCRIPT" + [ "$status" -eq 0 ] + before="$(field_of root)" + run passwd --unlock root + [ "$status" -eq 0 ] + [ "$(field_of root)" = "$before" ] + run nothing_authenticates + [ "$status" -eq 0 ] +} + +@test "a field of '!' is the one passwd --unlock refuses, which is why it is not used" { + printf 'root:!:20718:0:99999:7:::\n' > "$SHADOW_FILE" + run passwd --unlock root + [ "$status" -eq 3 ] + [[ "$output" == *"passwordless account"* ]] + [ "$(field_of root)" = '!' ] +} + +@test "the password the first boot sets over it works" { + run "$SCRIPT" + [ "$status" -eq 0 ] + echo 'root:hunter2' | chpasswd + run authenticates "hunter2" + [ "$status" -eq 0 ] + run refuses "" + [ "$status" -eq 0 ] + run refuses "hunter3" + [ "$status" -eq 0 ] +} + +@test "a build time ROOT_PASS is the password, and nothing else is" { + export ROOT_PASS=s3cret + run "$SCRIPT" + [ "$status" -eq 0 ] + run authenticates "s3cret" + [ "$status" -eq 0 ] + run refuses "" + [ "$status" -eq 0 ] +} + +@test "a ROOT_PASS holding a glob character is set as written" { + mkdir -p "$IMAGE/build" + touch "$IMAGE/build/root:pass" + cd "$IMAGE/build" + export ROOT_PASS='p*ss' + run "$SCRIPT" + [ "$status" -eq 0 ] + run authenticates 'p*ss' + [ "$status" -eq 0 ] + run refuses 'pass' + [ "$status" -eq 0 ] +} + +@test "a chroot only build locks the account instead" { + export CHROOT_ONLY=y + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(cat "$STUB_LOG")" = "passwd --lock root" ] + [[ "$(field_of root)" == '!'* ]] + run nothing_authenticates + [ "$status" -eq 0 ] +} + +@test "the account database is touched once, with usermod" { + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(cat "$STUB_LOG")" = "usermod -p * root" ] +} diff --git a/tests/samba-rootpass.bats b/tests/samba-rootpass.bats new file mode 100644 index 00000000..9473db38 --- /dev/null +++ b/tests/samba-rootpass.bats @@ -0,0 +1,73 @@ +#!/usr/bin/env bats +# Tests for conf/samba-rootpass: the same question as rootpass.bats, for the +# copy of it the Samba appliances run, and what it hands smbpasswd. +# +# The verdict is the real pam_unix's (helpers.bash); the shadow tools and +# smbpasswd are stubs. + +bats_require_minimum_version 1.5.0 + +load helpers + +setup() { + TESTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + SCRIPT="$TESTS_DIR/../conf/samba-rootpass" + scratch_image + unset ROOT_PASS +} + +@test "no password gets root in when the build sets none, through any stack" { + run "$SCRIPT" + [ "$status" -eq 0 ] + run nothing_authenticates root "$PAM_WEBMIN" + [ "$status" -eq 0 ] + run nothing_authenticates root "$PAM_COMMON_AUTH" + [ "$status" -eq 0 ] +} + +@test "the account is unlocked first, and the field left is one unlock accepts" { + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(head -1 "$STUB_LOG")" = "passwd --unlock root" ] + [ "$(field_of root)" = '*' ] + run passwd --unlock root + [ "$status" -eq 0 ] +} + +@test "the Samba account is added with no password when the build sets none" { + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(tail -1 "$STUB_LOG")" = "smbpasswd -a -n root" ] +} + +@test "a build time ROOT_PASS is the password for both, and nothing else is" { + export ROOT_PASS=s3cret + run "$SCRIPT" + [ "$status" -eq 0 ] + run authenticates s3cret + [ "$status" -eq 0 ] + run refuses "" + [ "$status" -eq 0 ] + [ "$(tail -1 "$STUB_LOG")" = "smbpasswd -a -s root" ] + [ "$(cat "$STUB_LOG.stdin")" = "$(printf 's3cret\ns3cret')" ] +} + +@test "a ROOT_PASS holding a glob character is set as written" { + mkdir -p "$IMAGE/build" + touch "$IMAGE/build/root:pass" + cd "$IMAGE/build" + export ROOT_PASS='p*ss' + run "$SCRIPT" + [ "$status" -eq 0 ] + run authenticates 'p*ss' + [ "$status" -eq 0 ] + run refuses 'pass' + [ "$status" -eq 0 ] +} + +@test "the build password is not written to the build log" { + export ROOT_PASS='n0t-in-the-log' + run --separate-stderr "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output$stderr" != *"n0t-in-the-log"* ]] +} diff --git a/tests/stubs/chpasswd b/tests/stubs/chpasswd new file mode 100755 index 00000000..1902faa7 --- /dev/null +++ b/tests/stubs/chpasswd @@ -0,0 +1,16 @@ +#!/bin/bash +# Stand-in for chpasswd: records the call in STUB_LOG, reads 'user:password' +# lines on standard input and replaces the whole password field of +# SHADOW_FILE with a hash of the password, which is what chpasswd does to a +# field whatever that field held before. +echo "chpasswd $*" >> "${STUB_LOG:?}" + +while IFS= read -r line; do + user=${line%%:*} + password=${line#*:} + hash=$(perl -e 'print crypt($ARGV[0], $ARGV[1])' "$password" '$6$keelstub$') + awk -F: -v user="$user" -v hash="$hash" 'BEGIN { OFS=":" } + $1 == user { $2 = hash } + { print }' "${SHADOW_FILE:?}" > "$SHADOW_FILE.new" + mv "$SHADOW_FILE.new" "$SHADOW_FILE" +done diff --git a/tests/stubs/passwd b/tests/stubs/passwd new file mode 100755 index 00000000..dec3975f --- /dev/null +++ b/tests/stubs/passwd @@ -0,0 +1,46 @@ +#!/bin/bash +# Stand-in for passwd --lock and passwd --unlock, with the behaviour of +# shadow 4.17 measured in a scratch root: +# +# for f in '!' '*' 'U6aMy0wojraho' '!U6aMy0wojraho' ''; do ... \ +# unshare -r passwd --root "$dir" -u root; done +# +# field '!' -u exit 3, field unchanged, "unlocking the +# password would result in a passwordless +# account" +# field '*' -u exit 0, field unchanged +# field '!' -u exit 0, field '' +# any field -l exit 0, '!' prefixed unless already there +echo "passwd $*" >> "${STUB_LOG:?}" + +action= +user= +while [[ $# -gt 0 ]]; do + case $1 in + -l|--lock) action=lock; shift ;; + -u|--unlock) action=unlock; shift ;; + -*) shift ;; + *) user=$1; shift ;; + esac +done + +field=$(awk -F: -v user="${user:?}" '$1 == user { print $2 }' "${SHADOW_FILE:?}") + +case $action in + lock) + [[ "$field" == '!'* ]] || field="!$field" + ;; + unlock) + if [[ "$field" == '!' ]]; then + echo "passwd: unlocking the password would result in a passwordless account." >&2 + exit 3 + fi + field=${field#!} + ;; + *) exit 1 ;; +esac + +awk -F: -v user="$user" -v field="$field" 'BEGIN { OFS=":" } + $1 == user { $2 = field } + { print }' "$SHADOW_FILE" > "$SHADOW_FILE.new" +mv "$SHADOW_FILE.new" "$SHADOW_FILE" diff --git a/tests/stubs/smbpasswd b/tests/stubs/smbpasswd new file mode 100755 index 00000000..081ab365 --- /dev/null +++ b/tests/stubs/smbpasswd @@ -0,0 +1,7 @@ +#!/bin/bash +# Stand-in for smbpasswd: records the call in STUB_LOG, and what it was given +# on standard input in STUB_LOG.stdin, one line per line read. +echo "smbpasswd $*" >> "${STUB_LOG:?}" +if [[ " $* " == *" -s "* ]]; then + cat >> "$STUB_LOG.stdin" +fi diff --git a/tests/stubs/usermod b/tests/stubs/usermod new file mode 100755 index 00000000..baa66044 --- /dev/null +++ b/tests/stubs/usermod @@ -0,0 +1,23 @@ +#!/bin/bash +# Stand-in for usermod: records the call in STUB_LOG and, for '-p FIELD +# USER', writes FIELD into the password field of SHADOW_FILE exactly as +# usermod does, without interpreting it. +echo "usermod $*" >> "${STUB_LOG:?}" + +field= +user= +while [[ $# -gt 0 ]]; do + case $1 in + -p) field=$2; shift 2 ;; + -*) shift ;; + *) user=$1; shift ;; + esac +done + +[[ -n "$user" ]] || exit 1 +[[ -n "$field" ]] || exit 0 + +awk -F: -v user="$user" -v field="$field" 'BEGIN { OFS=":" } + $1 == user { $2 = field } + { print }' "${SHADOW_FILE:?}" > "$SHADOW_FILE.new" +mv "$SHADOW_FILE.new" "$SHADOW_FILE" diff --git a/tests/webmin-enable.bats b/tests/webmin-enable.bats new file mode 100644 index 00000000..fc429a0f --- /dev/null +++ b/tests/webmin-enable.bats @@ -0,0 +1,176 @@ +#!/usr/bin/env bats +# Tests for conf/turnkey.d/webmin-enable: when the web interface of a +# freshly built image is allowed to start. +# +# The verdict on the conditions is systemd's own, taken from +# 'systemd-analyze condition'; the unit files are handed to +# 'systemd-analyze verify' so that systemd, and not a reader, says they +# parse. + +bats_require_minimum_version 1.5.0 + +load helpers + +setup() { + TESTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + SCRIPT="$TESTS_DIR/../conf/turnkey.d/webmin-enable" + scratch_image + + export INITHOOKS_MARKER=$IMAGE/run/inithooks-complete + export INITHOOKS_RUN=$IMAGE/usr/lib/inithooks/run + mkdir -p "$IMAGE/run" "$IMAGE/usr/lib/inithooks" + touch "$INITHOOKS_RUN" + + export WEBMIN_OPENED=$IMAGE/var/lib/webmin-after-firstboot/opened + DROPIN=$SYSTEMD_DIR/webmin.service.d/after-firstboot.conf + ONESHOT=$SYSTEMD_DIR/webmin-after-firstboot.service + + # webmin.service as the webmin package installs it, beside the drop-in + # the way it is in an image, so that systemd reads the two together. + # Only the program is swapped for one that exists here, which + # 'systemd-analyze verify' checks and nothing else depends on. + sed 's|^ExecStart=/usr/share/webmin/miniserv.pl|ExecStart=/bin/true|' \ + "$FIXTURES/webmin.service" > "$SYSTEMD_DIR/webmin.service" + UNIT=$SYSTEMD_DIR/webmin.service +} + +# next_boot +# What a reboot does to the state these units read: /run is emptied. +next_boot() { + rm -f "$INITHOOKS_MARKER" +} + +@test "webmin does not start while the first boot scripts have not run" { + run "$SCRIPT" + [ "$status" -eq 0 ] + run ! unit_would_start "$DROPIN" +} + +@test "webmin starts once they report they have" { + run "$SCRIPT" + [ "$status" -eq 0 ] + touch "$INITHOOKS_MARKER" + run unit_would_start "$DROPIN" + [ "$status" -eq 0 ] +} + +@test "an image with no inithooks is not left without a web interface" { + rm "$INITHOOKS_RUN" + run "$SCRIPT" + [ "$status" -eq 0 ] + run unit_would_start "$DROPIN" + [ "$status" -eq 0 ] +} + +@test "systemd accepts the units the script writes" { + run "$SCRIPT" + [ "$status" -eq 0 ] + run systemd-analyze verify \ + "$SYSTEMD_DIR/webmin-after-firstboot.path" \ + "$SYSTEMD_DIR/webmin-after-firstboot.service" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "systemd accepts webmin.service with the drop-in in place" { + run "$SCRIPT" + [ "$status" -eq 0 ] + run systemd-analyze verify --man=no "$UNIT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "the verdict is the merged unit's, the packaged conditions included" { + run "$SCRIPT" + [ "$status" -eq 0 ] + run ! unit_would_start "$UNIT" "$DROPIN" + touch "$INITHOOKS_MARKER" + run unit_would_start "$UNIT" "$DROPIN" + [ "$status" -eq 0 ] + + # a plain condition in the packaged unit would AND with the drop-in's + printf '[Unit]\nConditionPathExists=%s\n' "$IMAGE/nowhere" >> "$UNIT" + run ! unit_would_start "$UNIT" "$DROPIN" +} + +@test "the first boot opening the interface leaves nothing for later boots to wait on" { + run "$SCRIPT" + [ "$status" -eq 0 ] + touch "$INITHOOKS_MARKER" + run run_exec_start "$ONESHOT" + [ "$status" -eq 0 ] + grep -qx "systemctl --no-block start webmin.service" "$STUB_LOG" + + next_boot + run unit_would_start "$UNIT" "$DROPIN" + [ "$status" -eq 0 ] +} + +@test "a boot whose inithooks never finish still has the interface, after one that did" { + run "$SCRIPT" + [ "$status" -eq 0 ] + touch "$INITHOOKS_MARKER" + run_exec_start "$ONESHOT" + next_boot + next_boot + [ ! -e "$INITHOOKS_MARKER" ] + run unit_would_start "$UNIT" "$DROPIN" + [ "$status" -eq 0 ] +} + +@test "the build itself opens nothing: no record of a first boot is left in the image" { + run "$SCRIPT" + [ "$status" -eq 0 ] + [ ! -e "$WEBMIN_OPENED" ] + next_boot + run ! unit_would_start "$UNIT" "$DROPIN" +} + +@test "a first boot that never finishes keeps it shut on the boots after it" { + run "$SCRIPT" + [ "$status" -eq 0 ] + next_boot + run ! unit_would_start "$UNIT" "$DROPIN" + next_boot + run ! unit_would_start "$UNIT" "$DROPIN" +} + +@test "the recovery the script's comment names opens it" { + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "systemctl start webmin-after-firstboot.service" "$SCRIPT" + run run_exec_start "$ONESHOT" + [ "$status" -eq 0 ] + next_boot + run unit_would_start "$UNIT" "$DROPIN" + [ "$status" -eq 0 ] +} + +@test "the marker is what the path unit waits for, and it fires once" { + run "$SCRIPT" + [ "$status" -eq 0 ] + unit=$SYSTEMD_DIR/webmin-after-firstboot.path + grep -qx "PathExists=$INITHOOKS_MARKER" "$unit" + grep -qx "Unit=webmin-after-firstboot.service" "$unit" + grep -qx "RemainAfterExit=yes" \ + "$SYSTEMD_DIR/webmin-after-firstboot.service" +} + +@test "webmin and the path unit are the two units enabled" { + run "$SCRIPT" + [ "$status" -eq 0 ] + diff - "$STUB_LOG" <<'EXPECTED' +systemctl enable webmin +systemctl enable webmin-after-firstboot.path +EXPECTED +} + +@test "running it again leaves the same image" { + run "$SCRIPT" + [ "$status" -eq 0 ] + first="$(cat "$DROPIN" "$SYSTEMD_DIR"/webmin-after-firstboot.*)" + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(cat "$DROPIN" "$SYSTEMD_DIR"/webmin-after-firstboot.*)" = "$first" ] + [ ! -e "$WEBMIN_OPENED" ] +} diff --git a/tests/webmin-pam.bats b/tests/webmin-pam.bats new file mode 100644 index 00000000..2174250e --- /dev/null +++ b/tests/webmin-pam.bats @@ -0,0 +1,105 @@ +#!/usr/bin/env bats +# Tests for conf/turnkey.d/webmin-pam: what the PAM stack webmin uses lets +# through, before and after the script has run over it. +# +# tests/fixtures/pam.d-webmin is the file the webmin package installs, as +# it stands in a built core layer. The scratch image account has no password +# set on it, which is the case the option under test decides. + +bats_require_minimum_version 1.5.0 + +load helpers + +setup() { + TESTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + SCRIPT="$TESTS_DIR/../conf/turnkey.d/webmin-pam" + scratch_image +} + +@test "a wrong password stops getting in" { + require_measured_libpam + run authenticates "anything at all" + [ "$status" -eq 0 ] + + run "$SCRIPT" + [ "$status" -eq 0 ] + + run refuses "anything at all" + [ "$status" -eq 0 ] +} + +@test "the empty password stops getting in as well, through this stack" { + require_measured_libpam + run authenticates "" + [ "$status" -eq 0 ] + + run "$SCRIPT" + [ "$status" -eq 0 ] + + run nothing_authenticates + [ "$status" -eq 0 ] +} + +@test "a stack that keeps the option still lets anything in, which is what the field is for" { + require_measured_libpam + run "$SCRIPT" + [ "$status" -eq 0 ] + + run authenticates "anything at all" root "$PAM_COMMON_AUTH" + [ "$status" -eq 0 ] + + printf 'root:*:20718:0:99999:7:::\n' > "$SHADOW_FILE" + run nothing_authenticates root "$PAM_COMMON_AUTH" + [ "$status" -eq 0 ] +} + +@test "the right password still gets in" { + run "$SCRIPT" + [ "$status" -eq 0 ] + echo 'root:hunter2' | chpasswd + run authenticates "hunter2" + [ "$status" -eq 0 ] +} + +@test "the option goes whatever separates it from the module" { + printf '#%%PAM-1.0\nauth required pam_unix.so nullok try_first_pass\n' \ + > "$PAM_WEBMIN" + run "$SCRIPT" + [ "$status" -eq 0 ] + run ! stack_allows_blank + [ "$(sed -n '2p' "$PAM_WEBMIN")" = "auth required pam_unix.so try_first_pass" ] +} + +@test "nothing else in the stack is touched" { + run "$SCRIPT" + [ "$status" -eq 0 ] + diff - "$PAM_WEBMIN" <<'EXPECTED' +#%PAM-1.0 +auth required pam_unix.so +account required pam_unix.so +session required pam_unix.so +EXPECTED +} + +@test "running it again changes nothing" { + run "$SCRIPT" + [ "$status" -eq 0 ] + first="$(cat "$PAM_WEBMIN")" + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(cat "$PAM_WEBMIN")" = "$first" ] +} + +@test "fails when the stack is not there" { + rm "$PAM_WEBMIN" + run "$SCRIPT" + [ "$status" -eq 1 ] + [ "$output" = "FATAL [webmin-pam]: $PAM_WEBMIN does not exist" ] +} + +@test "fails rather than report success when the option survives" { + printf '#%%PAM-1.0\nnullok\n' > "$PAM_WEBMIN" + run "$SCRIPT" + [ "$status" -eq 1 ] + [ "$output" = "FATAL [webmin-pam]: nullok survived in $PAM_WEBMIN" ] +}