diff --git a/Makefile.in b/Makefile.in index 716171db1..612889254 100644 --- a/Makefile.in +++ b/Makefile.in @@ -60,7 +60,7 @@ TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/perms # Programs we must have to run the test cases CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ - t_rename_secure$(EXEEXT) t_symlink_secure$(EXEEXT) t_secure_relpath$(EXEEXT) t_acl$(EXEEXT) t_hashtable_overflow$(EXEEXT) t_iwildmatch$(EXEEXT) t_clean_fname$(EXEEXT) t_safe_arg$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT) + t_rename_secure$(EXEEXT) t_symlink_secure$(EXEEXT) t_secure_relpath$(EXEEXT) t_acl$(EXEEXT) t_hashtable_overflow$(EXEEXT) t_iwildmatch$(EXEEXT) t_clean_fname$(EXEEXT) t_safe_arg$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT) @PAM_MOCK_SO@ CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \ testsuite/xattrs-hlink_test.py testsuite/exclude-lsh_test.py @@ -550,6 +550,9 @@ simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS) touch $@; \ fi +pam_mock.so: $(srcdir)/testsuite/pam/pam_mock.c + $(CC) $(CFLAGS) $(CPPFLAGS) -I. -I$(srcdir) -shared -fPIC -o $@ $(srcdir)/testsuite/pam/pam_mock.c -lpam + testsuite/chown-fake_test.py: ln -s chown_test.py $(srcdir)/testsuite/chown-fake_test.py diff --git a/authenticate.c b/authenticate.c index 3376bb1ed..cf0678604 100644 --- a/authenticate.c +++ b/authenticate.c @@ -84,6 +84,97 @@ static int get_random_bytes(char *buf, int len) return got == len; } +#ifdef SUPPORT_PAM +/* Cross-platform PAM header */ +#if defined(HAVE_SECURITY_PAM_APPL_H) +# include /* Linux, recent macOS */ +#elif defined(HAVE_PAM_PAM_APPL_H) +# include /* UNIX-like */ +#else +# error "PAM is enabled, but no pam_appl.h header was found." +#endif + +/* Handle Solaris dropping the const qualifier in pam_message */ +#if defined(__sun) +#define PAM_MSG_CONST +#else +#define PAM_MSG_CONST const +#endif + +/* + * A cross-platform conversation function for PAM. + * Completely eliminates the need for the Linux-only pam_misc.h and misc_conv. + * Logs informational and error messages directly to the rsync daemon log. + * If PAM attempts to interactively prompt for a password, this instantly + * rejects it to prevent the background daemon from hanging. + */ +static int rsync_pam_conv(int num_msg, PAM_MSG_CONST struct pam_message **msg, + struct pam_response **resp, void *appdata_ptr) +{ + int i; + (void)appdata_ptr; + if (num_msg <= 0 || msg == NULL || resp == NULL) + return PAM_CONV_ERR; + *resp = NULL; + + for (i = 0; i < num_msg; i++) { + if (msg[i] == NULL || msg[i]->msg == NULL) + return PAM_CONV_ERR; + switch (msg[i]->msg_style) { + case PAM_TEXT_INFO: + rprintf(FLOG, "PAM info: %s\n", msg[i]->msg); + break; + case PAM_ERROR_MSG: + rprintf(FLOG, "PAM error: %s\n", msg[i]->msg); + break; + case PAM_PROMPT_ECHO_ON: + case PAM_PROMPT_ECHO_OFF: + /* + * We don't support interactive prompts. + */ + return PAM_CONV_ERR; + default: + return PAM_CONV_ERR; + } + } + return PAM_SUCCESS; +} + +static struct pam_conv conv = { + rsync_pam_conv, + NULL +}; + +const char *rsync_pam_validate_account(const char *username) +{ + pam_handle_t *pamh = NULL; + int retval; + static char pam_err_buf[256]; + const char *final_err = NULL; + /* 1. Initialize PAM */ + retval = pam_start("rsync", username, &conv, &pamh); + if (retval != PAM_SUCCESS) { + snprintf(pam_err_buf, sizeof(pam_err_buf), + "PAM initialization failed for user %s", username); + return pam_err_buf; + } + /* 2. Validate account */ + retval = pam_acct_mgmt(pamh, PAM_SILENT); + /* 3. Handle result */ + if (retval == PAM_SUCCESS) { + rprintf(FLOG, "PAM: Account validation successful for user %s\n", username); + } else { + snprintf(pam_err_buf, sizeof(pam_err_buf), + "PAM account validation failed, %s", + pam_strerror(pamh, retval)); + final_err = pam_err_buf; + } + /* 4. Cleanup */ + pam_end(pamh, retval); + return final_err; +} +#endif + /* Generate a challenge buffer and return it base64-encoded. */ static void gen_challenge(const char *addr, char *challenge) { @@ -289,6 +380,7 @@ char *auth_server(int f_in, int f_out, int module, const char *host, const char *addr, const char *leader) { char *users = lp_auth_users(module); + int use_pam = lp_use_pam(module); char challenge[MAX_DIGEST_LEN*2]; char line[BIGPATHBUFLEN]; const char **auth_uid_groups = NULL; @@ -414,7 +506,20 @@ char *auth_server(int f_in, int f_out, int module, const char *host, err = "denied by rule"; else { const char *group = group_match >= 0 ? auth_uid_groups[group_match] : NULL; + /* 1. Verify standard rsync credentials first */ err = check_secret(module, line, group, challenge, pass); + /* 2. Validate PAM requirements and account status */ + if (!err && use_pam) { +#ifndef SUPPORT_PAM + err = "PAM enabled but rsync compiled without PAM support"; +#else + /* If PAM fails, this points to our detailed static buffer. + If it succeeds, it returns NULL and err remains NULL. */ + const char *pam_err = rsync_pam_validate_account(line); + if (pam_err) + err = pam_err; +#endif + } } force_memzero(challenge, sizeof challenge); diff --git a/configure.ac b/configure.ac index 17f53f9ea..24d654299 100644 --- a/configure.ac +++ b/configure.ac @@ -13,7 +13,7 @@ AC_CHECK_HEADERS(poll.h sys/fcntl.h sys/select.h fcntl.h sys/time.h sys/unistd.h sys/acl.h acl/libacl.h attr/xattr.h sys/xattr.h sys/extattr.h dl.h \ popt.h popt/popt.h linux/falloc.h netinet/in_systm.h netgroup.h \ zlib.h xxhash.h openssl/md4.h openssl/md5.h zstd.h lz4.h sys/file.h \ - sys/resource.h bsd/string.h idn2.h) + sys/resource.h bsd/string.h idn2.h security/pam_modules.h security/pam_appl.h pam/pam_appl.h) AC_CHECK_HEADERS([netinet/ip.h], [], [], [[#include ]]) AC_HEADER_MAJOR_FIXED @@ -655,6 +655,32 @@ else AC_MSG_RESULT(no) fi +PAM_MOCK_SO="" +AC_MSG_CHECKING([whether to enable PAM support]) +AC_ARG_ENABLE([pam], + AS_HELP_STRING([--enable-pam], [enable PAM support (default is NO)])) +AH_TEMPLATE([SUPPORT_PAM], +[Define to 1 if you want PAM support. By default this is undefined.]) + +if test x"$enable_pam" = x"yes"; then + if test x"$ac_cv_header_security_pam_appl_h" = x"yes" || test x"$ac_cv_header_pam_pam_appl_h" = x"yes"; then + AC_MSG_RESULT(yes) + AC_SEARCH_LIBS(pam_start, pam, + [AC_DEFINE(SUPPORT_PAM) + PAM_MOCK_SO="pam_mock.so"], + [err_msg="$err_msg$nl- Failed to find pam_start function in pam lib."; + no_lib="$no_lib pam"]) + else + AC_MSG_RESULT(no) + err_msg="$err_msg$nl- Failed to find pam_appl.h for PAM support." + no_lib="$no_lib pam" + fi +else + AC_MSG_RESULT(no) +fi + +AC_SUBST(PAM_MOCK_SO) + if test x"$no_lib" != x; then echo "" echo "Configure found the following issues:" diff --git a/daemon-parm.txt b/daemon-parm.txt index 573ffa870..f42a8042f 100644 --- a/daemon-parm.txt +++ b/daemon-parm.txt @@ -64,6 +64,7 @@ BOOL reverse_lookup True BOOL strict_modes True BOOL transfer_logging False BOOL write_only False +BOOL use_pam False BOOL3 munge_symlinks Unset BOOL3 numeric_ids Unset diff --git a/rsyncd.conf.5.md b/rsyncd.conf.5.md index d16d6f3b4..2250313c8 100644 --- a/rsyncd.conf.5.md +++ b/rsyncd.conf.5.md @@ -774,6 +774,20 @@ in the values of parameters. See that section for details. the exact check. If the file is not found or is rejected, no logins for an "[auth users](#)" module will be possible. + 0. `use pam` + + This parameter determines whether the rsync daemon will utilize Pluggable + Authentication Modules (PAM) for account validation. If "use pam" is true, + rsync will invoke the PAM account management subsystem (`pam_acct_mgmt`) + after a user successfully authenticates. This allows administrators to + enforce system-level access policies (such as locked, expired, or disabled + accounts) without duplicating those controls inside rsync. + + Note that this is strictly for account management, not primary password + authentication. Password verification is always handled by rsync's internal + challenge-response mechanism using the "[secrets file](#)" parameter. The + default is false. + 0. `auth digest` This parameter sets the *minimum* message digest that the daemon will accept diff --git a/testsuite/daemon-auth_test.py b/testsuite/daemon-auth_test.py index 5075f1cbf..befff4f41 100644 --- a/testsuite/daemon-auth_test.py +++ b/testsuite/daemon-auth_test.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Daemon coverage: auth users, secrets file, strict modes. +"""Daemon coverage: auth users, secrets file, strict modes, and PAM. A module with auth users + a secrets file must accept the right password, reject a wrong one, and (with the default strict modes) refuse a @@ -9,6 +9,7 @@ import os import subprocess +import getpass from rsyncfns import ( FROMDIR, SCRATCHDIR, @@ -32,7 +33,10 @@ authdir = SCRATCHDIR / 'authdest' secrets = SCRATCHDIR / 'rsyncd.secrets' -secrets.write_text('tuser:secretpass\n') +real_user = getpass.getuser() + +# Add both the fake user and the real user to the secrets file +secrets.write_text(f'tuser:secretpass\n{real_user}:realpass\n') secrets.chmod(0o600) conf = write_daemon_conf([ @@ -40,7 +44,7 @@ 'auth users': 'tuser', 'secrets file': secrets}), ]) url = start_test_daemon(conf, DAEMON_PORT) -userurl = url.replace('rsync://', 'rsync://tuser@', 1) +host_port_path = url.replace('rsync://', '') def pwfile(name, text): @@ -50,23 +54,25 @@ def pwfile(name, text): return p -def push(pw, **kw): +def push(pw, target_module='auth', user='tuser', **kw): rmtree(authdir) makepath(authdir) return subprocess.run( - rsync_argv('-a', f'--password-file={pw}', f'{src}/', f'{userurl}auth/'), + rsync_argv('-a', f'--password-file={pw}', f'{src}/', f'rsync://{user}@{host_port_path}{target_module}/'), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, **kw) # --- correct password succeeds ---------------------------------------------- ok = pwfile('pw.ok', 'secretpass\n') +real_ok = pwfile('pw.real_ok', 'realpass\n') +bad = pwfile('pw.bad', 'wrongpass\n') + proc = push(ok) if proc.returncode not in (0, 23): test_fail(f"auth with the correct password failed: {proc.stderr}") verify_dirs(src, authdir, label="auth success") # --- wrong password is rejected --------------------------------------------- -bad = pwfile('pw.bad', 'wrongpass\n') proc = push(bad) if proc.returncode == 0: test_fail("auth with the wrong password unexpectedly succeeded") @@ -89,4 +95,124 @@ def push(pw, **kw): test_fail("strict modes did not reject a world-readable secrets file") secrets.chmod(0o600) -print("daemon-auth: auth users / secrets file / strict modes verified") +# ============================================================================ +# --- PAM Implementation Tests ----------------------------------------------- +# ============================================================================ +import sys +import ctypes.util +import platform +import shutil + +# Skip Darwin: macOS SIP strips dynamic library injection across fork/exec +if platform.system() == 'Darwin': + print("daemon-auth: auth users / secrets file / strict modes verified (PAM tests skipped on Darwin)") + sys.exit(0) + +# Verify mock PAM plugin is compiled +build_dir = os.environ.get('tooldir', os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +mock_so = os.path.join(build_dir, 'pam_mock.so') +if not os.path.exists(mock_so): + print("daemon-auth: auth users / secrets file / strict modes verified (PAM tests skipped: missing pam_mock.so)") + sys.exit(0) + +# Locate pam_wrapper via pkg-config +pam_wrapper_so = None +pkg_config = shutil.which("pkg-config") +if pkg_config: + try: + # First try: --libs + res = subprocess.run([pkg_config, "--libs", "pam_wrapper"], capture_output=True, text=True, check=True) + discovered_path = res.stdout.strip() + + if os.path.isabs(discovered_path) and os.path.exists(discovered_path): + pam_wrapper_so = discovered_path + else: + # Second try: --variable=libdir (incase --libs returned -L flags) + res_libdir = subprocess.run([pkg_config, "--variable=libdir", "pam_wrapper"], capture_output=True, text=True, check=True) + libdir = res_libdir.stdout.strip() + if libdir: + candidate = os.path.join(libdir, "libpam_wrapper.so") + if os.path.exists(candidate): + pam_wrapper_so = candidate + except Exception: + pass + +if not pam_wrapper_so: + print("daemon-auth: auth users / secrets file / strict modes verified (PAM tests skipped: pam_wrapper not found)") + sys.exit(0) + +# Setup isolated PAM configuration +fake_pam_dir = SCRATCHDIR / 'pam.d' +if not fake_pam_dir.exists(): + fake_pam_dir.mkdir() + +pam_conf = fake_pam_dir / 'rsync' +pam_conf.write_text(f"account required {mock_so}\n") + +# Inject pam_wrapper into daemon environment +os.environ['LD_PRELOAD'] = pam_wrapper_so +os.environ['PAM_WRAPPER'] = '1' +os.environ['PAM_WRAPPER_SERVICE_DIR'] = str(fake_pam_dir) + +daemon_log = SCRATCHDIR / 'rsyncd.log' +if daemon_log.exists(): + daemon_log.unlink() +conf = SCRATCHDIR / 'rsyncd.conf' + +conf.write_text( + f"pid file = {SCRATCHDIR}/rsyncd.pid\n" + "use chroot = no\n" + f"log file = {daemon_log}\n" + f"uid = {os.getuid()}\n" + f"gid = {os.getgid()}\n" + f"\n[pam_auth]\n" + f"\tpath = {authdir}\n" + "\tread only = no\n" + f"\tauth users = tuser, {real_user}\n" + f"\tsecrets file = {secrets}\n" + "\tuse pam = yes\n" +) + +url = start_test_daemon(conf, DAEMON_PORT) +host_port_path = url.replace('rsync://', '') + +def reload_log_file(): + return daemon_log.read_text() if daemon_log.exists() else "" + +# 1. Fake user with valid secrets password: fails PAM account management (expected returncode 5) +proc = push(ok, target_module='pam_auth', user='tuser') +log_content = reload_log_file() + +if "PAM enabled but rsync compiled without PAM support" in log_content: + print("daemon-auth: auth users / secrets file / strict modes verified (PAM tests skipped: rsync built without PAM)") + sys.exit(0) + +if "PAM: Account validation successful for user" in log_content: + test_fail("PAM module unexpectedly authenticated non-existent system user 'tuser'!") + +if proc.returncode != 5: + test_fail(f"Fake user failed with unexpected exit code (expected 5, got {proc.returncode}): {proc.stderr}") + +# 2. Fake user with wrong password: fails MD5 challenge prior to PAM evaluation +proc = push(bad, target_module='pam_auth', user='tuser') +log_content = reload_log_file() +if proc.returncode == 0 or "PAM: Account validation successful for user" in log_content: + test_fail("PAM module unexpectedly succeeded with the wrong password (fake user)") + +# 3. Real system user with valid secrets password: passes both MD5 and PAM +proc = push(real_ok, target_module='pam_auth', user=real_user) +if proc.returncode not in (0, 23): + test_fail(f"PAM module rejected valid system user '{real_user}': {proc.stderr} (rc={proc.returncode})") + +log_content = reload_log_file() +if "PAM: Account validation successful for user" not in log_content: + test_fail("The test is running on an older rsync release which is not supporting PAM for account validation.") + +verify_dirs(src, authdir, label="PAM real user auth success") + +# Remove injection wrapper from parent test runner environment immediately after spawn +del os.environ['LD_PRELOAD'] +del os.environ['PAM_WRAPPER'] +del os.environ['PAM_WRAPPER_SERVICE_DIR'] + +print("daemon-auth: auth users / secrets file / strict modes / PAM verified") diff --git a/testsuite/pam/pam_mock.c b/testsuite/pam/pam_mock.c new file mode 100644 index 000000000..887581adf --- /dev/null +++ b/testsuite/pam/pam_mock.c @@ -0,0 +1,48 @@ +#include "config.h" + +#ifdef SUPPORT_PAM + +#define PAM_SM_ACCOUNT +#if defined(HAVE_SECURITY_PAM_APPL_H) +# include +#elif defined(HAVE_PAM_PAM_APPL_H) +# include +#endif + +#if defined(HAVE_SECURITY_PAM_MODULES_H) +# include +#elif defined(HAVE_PAM_PAM_MODULES_H) +# include +#endif +#include +#include + +/* Handle Solaris vs Linux/macOS pam_get_item signature differences */ +#if defined(__sun) +#define PAM_ITEM_OUT_CAST(x) (void **)(x) +#else +#define PAM_ITEM_OUT_CAST(x) (const void **)(x) +#endif + +int pam_sm_acct_mgmt(pam_handle_t *pamh, int flags, int argc, const char **argv) { + const void *user = NULL; + (void)flags; + (void)argc; + (void)argv; + + if (pam_get_item(pamh, PAM_USER, PAM_ITEM_OUT_CAST(&user)) != PAM_SUCCESS || user == NULL) + return PAM_PERM_DENIED; + + /* Standard logic: success if user exists, unknown if they don't */ + if (getpwnam((const char *)user) != NULL) + return PAM_SUCCESS; + + return PAM_USER_UNKNOWN; +} + +#else +/* + * If PAM is disabled or headers are missing, we compile an empty file + * to prevent compiler errors. */ +typedef int make_iso_compilers_happy; +#endif