Files
finit/test/pam-session.sh
Joachim Wiberg 61b0e0f3e6 Fix #420: run services inside a PAM session
Apply a PAM session to run/task/sysv/services Finit starts, pam_limits
above all, so a service running as a given user picks up that user's
limits the way a login does.

Add a new `pam` setting for the new block format (only), like the
per-service directories, naming a file in /etc/pam.d:

    service weston {
        user    = "weston"
        pam     = "weston-autologin"
        command = "/usr/bin/weston --continue-without-input"
    }

pam_close_session() has to be called by a process still holding the
handle, and the handle does not survive exec().  Hence the keeper: it
holds the handle, drops to the service's credentials, and waits for a
parent-death signal before closing the session.  Same shape as
systemd's (sd-pam), for the same reason, and one per fork, so the
script hooks open and close their own.

The keeper closes the descriptors it inherited from Finit and only
those.  Closing everything would also take out what pam_open_session()
opened for itself, a keyring fd or a lock file, and leave the modules
to close a session with those pulled out from under them.  Closing
nothing, as (sd-pam) does, would leave it holding the write end of the
notify pipe for the service's whole lifetime and starve notify = "s6"
services of their ready signal.  So the fds open before pam_start()
are snapshotted and exactly those are closed, while the ones PAM opens
after are marked close-on-exec so the daemon does not inherit them
either.

A refused value, a denied account stack, an uninstalled pam.d file,
and a build without PAM support all keep the service from starting
rather than running it with the stacks skipped: one that quietly loses
pam_limits and its private /tmp, with nothing said.  Capabilities a
module like pam_cap.so granted are merged into the IAB Finit applies
instead of being replaced by it, which only helps a service that also
sets capabilities, the other arm being a plain setuid() with nothing
left to restore once permitted is empty.

The test sysroot gains pam_permit.so, pam_deny.so and pam_limits.so,
which ldd cannot see, libpam dlopen()s them, and the test skips when
the host has none to stage.  The negative cases pin the exit status
rather than only asserting crashed, which serv reports for any early
exit, so a bad command or an unwritable pidfile cannot pass for a
rejected session.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-09-23 16:30:14 +02:00

123 lines
5.5 KiB
Bash
Executable File

#!/bin/sh
# A service can run inside a PAM session, so the session stack in
# /etc/pam.d applies to the process that becomes the daemon.
#
# The session outlives the setup: a keeper process, (finit-pam), holds
# the PAM handle and closes the session when the service dies. Here we
# check that it appears and disappears with the service, that a denied
# account stack keeps the service from running at all, and that a
# missing pam.d config, or a value that is a path rather than a name,
# does the same.
#
# Skipped unless Finit was built --enable-pam and the host had PAM
# modules for lib/sysroot.mk to stage.
set -eu
TEST_DIR=$(dirname "$0")
test_teardown()
{
say "Running test teardown."
run "rm -f $FINIT_CONF"
run "rm -f /tmp/pre"
run "rm -rf /etc/pam.d"
run "rm -rf /etc/security"
}
# shellcheck source=/dev/null
. "$TEST_DIR/lib/setup.sh"
# shellcheck disable=SC2154 # top_builddir comes from AM_TESTS_ENVIRONMENT
grep -q "define HAVE_LIBPAM" "$top_builddir/config.h" 2>/dev/null \
|| skip "Finit built without --enable-pam"
# Staged by lib/sysroot.mk from the host, when it has them. All three,
# not just pam_permit.so: without pam_deny.so the denied case would fail
# for the wrong reason and still look like a pass.
# shellcheck disable=SC2016 # the globs are for the shell inside texec
PAMDIR=$(texec sh -c 'for d in /lib/*/security /usr/lib/*/security /lib/security; do
test -f "$d/pam_permit.so" || continue
test -f "$d/pam_deny.so" || continue
test -f "$d/pam_limits.so" || continue
echo "$d" && break
done')
[ -n "$PAMDIR" ] || skip "no PAM modules in the test root, need them on the host"
assert_keeper()
{
assert "$1 PAM session keeper(s)" \
"$(texec sh -c 'cat /proc/[0-9]*/comm 2>/dev/null | grep -c "(finit-pam)"' || true)" -eq "$1"
}
# pamsess_open() leaves the child with EX_OSERR, so a service that fails
# for any other reason -- a bad command, a pidfile it cannot write -- is
# a test failure rather than a pass.
denied="crashed (code=exited, status=71/OSERR)"
say 'A permissive PAM config, and one that denies the account stack'
run "mkdir -p /etc/pam.d"
run "printf 'auth required pam_permit.so\naccount required pam_permit.so\nsession required pam_permit.so\n' > /etc/pam.d/finit-ok"
run "printf 'auth required pam_permit.so\naccount required pam_deny.so\nsession required pam_permit.so\n' > /etc/pam.d/finit-no"
say 'A service in a session runs, and a keeper appears alongside it'
run "printf 'service pamok {\n runlevel = \"S12345\"\n user = \"daemon\"\n pam = \"finit-ok\"\n command = \"/sbin/serv -n -i pamok\"\n}\n' > $FINIT_CONF"
run "initctl reload"
retry 'assert_status pamok running'
retry 'assert_keeper 1'
say 'Stopping the service takes the keeper with it'
run "initctl stop pamok"
retry 'assert_status pamok stopped'
retry 'assert_keeper 0'
say 'Starting again opens a fresh session'
run "initctl start pamok"
retry 'assert_status pamok running'
retry 'assert_keeper 1'
run "initctl stop pamok"
retry 'assert_keeper 0'
say 'A denied account stack keeps the service from running'
run "printf 'service pamno {\n runlevel = \"S12345\"\n user = \"daemon\"\n pam = \"finit-no\"\n command = \"/sbin/serv -n -i pamno\"\n}\n' > $FINIT_CONF"
run "initctl reload"
retry "assert_status_full pamno '$denied'" 500
assert_keeper 0
say 'So does a pam.d config that is not there'
run "printf 'service pamgone {\n runlevel = \"S12345\"\n user = \"daemon\"\n pam = \"finit-no-such-config\"\n command = \"/sbin/serv -n -i pamgone\"\n}\n' > $FINIT_CONF"
run "initctl reload"
retry "assert_status_full pamgone '$denied'" 500
assert_keeper 0
say 'A pam value that is a path is refused, the service does not start'
run "printf 'service pampath {\n runlevel = \"S12345\"\n user = \"daemon\"\n pam = \"/etc/pam.d/finit-ok\"\n command = \"/sbin/serv -n -i pampath\"\n}\n' > $FINIT_CONF"
run "initctl reload"
retry 'assert_status pampath missing'
assert_keeper 0
# The pre: script forks from SVC_SETUP_STATE, before service_start()
# runs, so it is pamsess_open() that has to refuse the value here. The
# script exits 71 without ever running, hence no /tmp/pre.
say 'And a pre: script does not get the refused value either'
run "printf 'service pampre {\n runlevel = \"S12345\"\n user = \"daemon\"\n pam = \"/etc/pam.d/finit-ok\"\n exec-start-pre = \"/bin/pre.sh\"\n command = \"/sbin/serv -n -i pampre\"\n}\n' > $FINIT_CONF"
run "initctl reload"
retry "assert_status_full pampre '$denied'" 500
assert "pre: script did not run" "$(texec sh -c 'test -e /tmp/pre && echo yes || echo no')" = "no"
assert_keeper 0
say 'pam_limits wins over the per-service rlimit'
run "mkdir -p /etc/security"
run "printf 'daemon hard nofile 512\ndaemon soft nofile 512\n' > /etc/security/limits.conf"
run "printf 'auth required pam_permit.so\naccount required pam_permit.so\nsession required pam_limits.so\n' > /etc/pam.d/finit-lim"
run "printf 'service pamlim {\n runlevel = \"S12345\"\n user = \"daemon\"\n pam = \"finit-lim\"\n rlimit {\n nofile = 4096\n }\n command = \"/sbin/serv -n -i pamlim\"\n}\n' > $FINIT_CONF"
run "initctl reload"
retry 'assert_status pamlim running'
pamlim_pid=$(texec initctl -j status pamlim | jq -M .pid)
# shellcheck disable=SC2016 # $4 is awk's field reference, not the shell's
assert "nofile is 512, from limits.conf, not 4096 from the block" \
"$(texec awk '/Max open files/ { print $4 }' "/proc/$pamlim_pid/limits")" = "512"
run "initctl stop pamlim"
retry 'assert_keeper 0'