Files
shadow/Makefile
T
Pierre Warnier 7e28940874 expiry, and usermod's subordinate-id options (#299)
* usermod: add and remove subordinate id ranges

GNU usermod has -v/--add-subuids, -V/--del-subuids, -w/--add-subgids and
-W/--del-subgids; ours had none of them, which came to light while writing
the tests for newuidmap. The range helpers live in shadow_core::subid so
whatever next touches /etc/subuid reads the same file the same way.

The behaviour was established by running the GNU tool: a range the user
already holds is not added twice; a removal trims an entry it overlaps,
splits one it cuts through the middle of, deletes one it covers, and ignores
a range never held; nothing checks other users' entries, since an
administrator may share a range on purpose; and an invalid range --
reversed, not two numbers, above the 32-bit ids the kernel maps -- is exit 3
with the GNU wording, before any file is written. Entries are keyed by the
login name given on the command line and are not renamed by -l, as GNU's
are not: a range is a grant to a name.

-V is GNU's letter for the deletion, so clap's own -V for --version had to
go; --version stays.

* expiry: check and enforce password expiration

expiry is the twenty-eighth tool and the last the Scope table lists as in
scope. It judges the caller's own /etc/shadow line as login would: silent
when all is well, a warning inside warn_days, a forced change through PAM
when the password has expired, a refusal when the account has. Shell
profiles run it so a session that did not come through login -- a display
manager, an ssh key -- still meets the aging policy.

The rules are in shadow_core::shadow::Aging, one function over the aging
fields with checked arithmetic, since every input is read from a file anyone
who can write /etc/shadow chooses. Its order is login's: account expiry
first, then a last change of 0 or an aged-out password, then the warning
window, with a maximum age of 10000 or more meaning never as chage -l prints
it. A locked password is a policy of its own and is not reported.

The GNU tool is setgid shadow -- enough to read the file, no more -- and the
change goes through PAM as the caller. The per-tool install ships it the
same way, a new install class. The single setuid binary keeps euid 0 for it,
having nothing narrower to offer. Under --prefix it reports and never
changes anything, since a PAM change would act on this system's account of
the same name; that is also what lets the required-change path be asserted
in the integration tests without a prompt, while the e2e suite drives the
prompt itself on a real user under a pty and checks the hash changed.

groupmems is not coming. Debian 13 and Fedora do not ship it, and on Ubuntu
24.04 every action fails against PAM's default for want of a
/etc/pam.d/groupmems: a tool one distribution ships and none can use. The
Scope table says so.

Verified: make check clean with the unprivileged run; 935 tests on alpine
and 950 on fedora with PAM; 410 e2e assertions against a real install; 59
GNU comparisons; 35 arm64 assertions under emulation.

* shadow-core: stop asking nscd to invalidate a shadow cache it never had

Every passwd, chage and chpasswd ended with `nscd -i shadow`, and on a host
running nscd that printed "nscd: 'shadow' is not a known database" on the
user's terminal each time. nscd caches passwd, group, hosts, services and
netgroup; the passwd entries it holds carry the `x` placeholder, so a
password change leaves nothing of nscd's stale. Only the databases nscd
knows are passed to it now. sss_cache still gets -U for a shadow change,
which is right: sssd does cache what a password change affects.
2026-09-07 15:47:43 +02:00

190 lines
8.6 KiB
Makefile

PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
SBINDIR ?= $(PREFIX)/sbin
# Tools that need setuid-root to allow non-root callers (change own password,
# GECOS, shell, effective group, or administer a group as a group admin).
SETUID_TOOLS = passwd chfn chsh newgrp gpasswd sg newuidmap newgidmap
# Root-only tools (no setuid; fail at getuid() check for non-root callers).
ROOT_TOOLS = useradd userdel usermod chpasswd chgpasswd newusers \
groupadd groupdel groupmod pwck grpck vipw vigr \
pwconv pwunconv grpconv grpunconv
# Tools an ordinary user runs, and which therefore go in bin rather than sbin:
# sbin is not on a normal user's PATH, so `passwd` there is `command not
# found`. This is where GNU shadow puts them -- verified against the Debian
# package, which ships passwd, chage, chfn, chsh and newgrp in /usr/bin and
# everything else in /usr/sbin. chage is here for `chage -l`, the one mode a
# user may run on their own account.
# login lives in bin too, and is root-only without being setuid: getty runs
# it as root, and it refuses to run as anyone else.
USER_BIN_TOOLS = chage login
# expiry reads the caller's own /etc/shadow line and nothing else, and the GNU
# suite ships it setgid shadow -- enough to read the file, no more.
SETGID_SHADOW_TOOLS = expiry
USER_TOOLS = $(SETUID_TOOLS) $(USER_BIN_TOOLS) $(SETGID_SHADOW_TOOLS)
ALL_TOOLS = $(SETUID_TOOLS) $(ROOT_TOOLS) $(USER_BIN_TOOLS) $(SETGID_SHADOW_TOOLS)
.PHONY: all build build-multicall build-arm64 dist-musl check test test-gnu-compat test-unprivileged test-arm64 install install-multicall uninstall clean
all: build
# `pam` is not a default cargo feature, so a plain build produces a `passwd`
# that refuses the interactive change path ("PAM support is not compiled in").
# Installed binaries must have it; the PAM headers are already listed as a
# build requirement in the README.
build:
cargo build --release --workspace --bins --exclude uu_shadow \
--features uu_passwd/pam,uu_chfn/pam,uu_chsh/pam,uu_login/pam,uu_expiry/pam
build-multicall:
cargo build --release --bin shadow-rs --features pam
# Static musl archive, published as a release asset next to the glibc one
# (dist-workspace.toml runs this target; see issue #224). Built without `pam`:
# Linux-PAM dlopen()s its modules, which a static binary cannot do, and
# shadow-core refuses the combination at compile time. The archive name carries
# the "-static" label and docs/PLATFORM-SUPPORT.md, shipped inside, spells out
# what the build does and does not do.
MUSL_TARGET = x86_64-unknown-linux-musl
MUSL_ARCHIVE = uu_shadow-$(MUSL_TARGET)-static
MUSL_DIST_DIR = target/dist-musl
dist-musl:
rustup target add $(MUSL_TARGET)
cargo build --release --locked --bin shadow-rs --target $(MUSL_TARGET)
@# A DT_NEEDED entry would mean a shared object slipped in and the archive
@# is not the self-contained binary its name promises.
@if readelf -d target/$(MUSL_TARGET)/release/shadow-rs | grep -q NEEDED; then \
echo "error: shadow-rs is not statically linked" >&2; exit 1; \
fi
rm -rf $(MUSL_DIST_DIR)
mkdir -p $(MUSL_DIST_DIR)/$(MUSL_ARCHIVE)
cp target/$(MUSL_TARGET)/release/shadow-rs LICENSE README.md CHANGELOG.md \
docs/PLATFORM-SUPPORT.md $(MUSL_DIST_DIR)/$(MUSL_ARCHIVE)/
tar -C $(MUSL_DIST_DIR) --owner=0 --group=0 --numeric-owner \
-czf $(MUSL_DIST_DIR)/$(MUSL_ARCHIVE).tar.gz $(MUSL_ARCHIVE)
cd $(MUSL_DIST_DIR) && sha256sum $(MUSL_ARCHIVE).tar.gz > $(MUSL_ARCHIVE).tar.gz.sha256
@echo "Built $(MUSL_DIST_DIR)/$(MUSL_ARCHIVE).tar.gz"
# Everything CI gates on, in one place, so the README, CONTRIBUTING, the git
# hooks and ci.yml stop each carrying their own copy of the command list.
# Run it inside a container: docker compose run --rm debian make check
check:
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --workspace --all-targets --features pam -- -D warnings
cargo clippy --workspace --all-targets --all-features -- -D warnings
$(MAKE) test
$(MAKE) test-unprivileged
# `install` ships binaries built with pam, so the tests must cover that build
# as well as the default one: the feature changes which code paths exist.
test:
cargo test --workspace
cargo test --workspace --features pam
ARM64_TARGET = aarch64-unknown-linux-gnu
ARM64_BIN = target/$(ARM64_TARGET)/release/shadow-rs
# Cross-build for arm64 and check the result actually runs.
#
# The release publishes an arm64 archive; nothing used to execute one, so a
# broken arm64 binary would reach a user before anyone ran it. The toolchain is
# installed here rather than baked into the image: it is 32 packages, and every
# other job would carry them for a check that is run occasionally.
build-arm64:
dpkg --add-architecture arm64
apt-get update
apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu libpam0g-dev:arm64 libcrypt-dev:arm64 qemu-user-static
rustup target add $(ARM64_TARGET)
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
cargo build --release --locked --target $(ARM64_TARGET) --bin shadow-rs --features pam
# Runs under qemu user-mode emulation, which is enough to catch what actually
# differs across architectures: integer widths, struct layouts, and the crypt
# and PAM FFI boundaries.
test-arm64: build-arm64
ARM64_BIN=$(ARM64_BIN) bash tests/arm64-smoke.sh
# Run the suite as an unprivileged user.
#
# Every container in docker-compose.yml runs as root, so a test that silently
# assumes root passes here and fails in CI, where one job runs as an ordinary
# user -- and a test asserting a failure exit code passes for the wrong reason,
# because "permission denied" is also a failure. This target is that job,
# locally. The binaries are already built, so it only re-runs them.
#
# SHADOW_TEST_REQUIRE_ROOT is cleared deliberately: it exists to turn a skip
# into a failure when the suite *is* running as root, which is the opposite of
# what this target does.
UNPRIV_USER = shadowtest
test-unprivileged:
cargo test --workspace --no-run 2>&1 \
| sed -n 's/.*(\(target\/debug\/deps\/[^)]*\))$$/\1/p' > /tmp/test-binaries
@id -u $(UNPRIV_USER) >/dev/null 2>&1 || useradd -m $(UNPRIV_USER)
@chmod -R a+rX target
@fail=0; while read -r bin; do \
printf '== %s\n' "$$bin"; \
su $(UNPRIV_USER) -s /bin/sh -c "SHADOW_TEST_REQUIRE_ROOT= $(CURDIR)/$$bin" \
|| fail=1; \
done < /tmp/test-binaries; \
exit $$fail
# Compare our output and exit codes against the GNU tools installed alongside.
# Needs root and the GNU shadow package, so it belongs in a container.
test-gnu-compat:
bash tests/gnu-compat.sh
# Default install: 28 standalone per-tool binaries, with the setuid layout and
# the bin/sbin split GNU shadow-utils uses. Only $(SETUID_TOOLS) are setuid.
install: build
@for tool in $(SETUID_TOOLS); do \
install -Dm4755 target/release/$$tool $(DESTDIR)$(BINDIR)/$$tool || exit 1; \
done
@for tool in $(USER_BIN_TOOLS); do \
install -Dm0755 target/release/$$tool $(DESTDIR)$(BINDIR)/$$tool || exit 1; \
done
@for tool in $(SETGID_SHADOW_TOOLS); do \
install -Dm2755 -g shadow target/release/$$tool $(DESTDIR)$(BINDIR)/$$tool || exit 1; \
done
@for tool in $(ROOT_TOOLS); do \
install -Dm0755 target/release/$$tool $(DESTDIR)$(SBINDIR)/$$tool || exit 1; \
done
@echo "Installed $(words $(ALL_TOOLS)) standalone binaries"
@echo " $(DESTDIR)$(BINDIR)/ setuid (4755): $(SETUID_TOOLS)"
@echo " $(DESTDIR)$(BINDIR)/ user (0755): $(USER_BIN_TOOLS)"
@echo " $(DESTDIR)$(BINDIR)/ setgid shadow (2755): $(SETGID_SHADOW_TOOLS)"
@echo " $(DESTDIR)$(SBINDIR)/ root (0755): $(ROOT_TOOLS)"
# Opt-in install: single multicall binary with symlinks. Smaller footprint.
# The binary is installed setuid-root for $(SETUID_TOOLS); the other applets
# drop back to the caller's uid before running, so the privilege model matches
# the per-tool layout. Intended for container/embedded use.
install-multicall: build-multicall
install -Dm4755 target/release/shadow-rs $(DESTDIR)$(SBINDIR)/shadow-rs
@install -d $(DESTDIR)$(BINDIR)
@for tool in $(USER_TOOLS); do \
ln -sf $(SBINDIR)/shadow-rs $(DESTDIR)$(BINDIR)/$$tool || exit 1; \
done
@for tool in $(ROOT_TOOLS); do \
ln -sf shadow-rs $(DESTDIR)$(SBINDIR)/$$tool || exit 1; \
done
@echo "Installed multicall shadow-rs to $(DESTDIR)$(SBINDIR)/ with"
@echo " $(words $(USER_TOOLS)) symlinks in $(DESTDIR)$(BINDIR)/: $(USER_TOOLS)"
@echo " $(words $(ROOT_TOOLS)) symlinks in $(DESTDIR)$(SBINDIR)/: $(ROOT_TOOLS)"
uninstall:
@for tool in $(ALL_TOOLS); do \
rm -f $(DESTDIR)$(BINDIR)/$$tool $(DESTDIR)$(SBINDIR)/$$tool; \
done
rm -f $(DESTDIR)$(BINDIR)/shadow-rs $(DESTDIR)$(SBINDIR)/shadow-rs
@echo "Uninstalled shadow-rs from $(DESTDIR)$(BINDIR)/ and $(DESTDIR)$(SBINDIR)/"
clean:
cargo clean