From 170176d5d3119d2745569d524b794f87dafbdb27 Mon Sep 17 00:00:00 2001 From: seks99x Date: Mon, 28 Sep 2026 19:51:04 +0300 Subject: [PATCH 1/5] Introduce PAM account management integration for rsync daemon Co-authored-by: steadytao --- Makefile.in | 5 +- auth/auth.h | 18 ++++++ authenticate.c => auth/authenticate.c | 21 ++++++ auth/pam.c | 66 +++++++++++++++++++ clientserver.c | 1 + configure.ac | 30 +++++++++ daemon-parm.txt | 1 + socket.c | 1 + testsuite/daemon-auth_test.py | 93 +++++++++++++++++++++++++-- 9 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 auth/auth.h rename authenticate.c => auth/authenticate.c (95%) create mode 100644 auth/pam.c diff --git a/Makefile.in b/Makefile.in index 716171db1..8065d526a 100644 --- a/Makefile.in +++ b/Makefile.in @@ -39,7 +39,7 @@ GENFILES=configure.sh aclocal.m4 config.h.in rsync.1 rsync.1.html \ rsync-ssl.1 rsync-ssl.1.html rsyncd.conf.5 rsyncd.conf.5.html \ @GEN_RRSYNC@ HEADERS=byteorder.h config.h errcode.h proto.h rsync.h ifuncs.h itypes.h inums.h \ - lib/pool_alloc.h lib/mdigest.h lib/md-defines.h + lib/pool_alloc.h lib/mdigest.h lib/md-defines.h auth/auth.h LIBOBJ=lib/wildmatch.o lib/compat.o lib/snprintf.o lib/mdfour.o lib/md5.o \ lib/permstring.o lib/pool_alloc.o lib/sysacls.o lib/sysxattrs.o lib/acl.o @LIBOBJS@ zlib_OBJS=zlib/deflate.o zlib/inffast.o zlib/inflate.o zlib/inftrees.o \ @@ -50,7 +50,8 @@ OBJS1=$(OBJS1_NO_MAIN) main.o OBJS2=options.o io.o compat.o hlink.o token.o uidlist.o socket.o hashtable.o \ usage.o fileio.o batch.o clientname.o chmod.o acls.o xattrs.o OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ -DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o +AUTH_OBJ = auth/authenticate.o auth/pam.o +DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o $(AUTH_OBJ) popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ diff --git a/auth/auth.h b/auth/auth.h new file mode 100644 index 000000000..13c2bbfa7 --- /dev/null +++ b/auth/auth.h @@ -0,0 +1,18 @@ +#ifndef RSYNC_PAM_H +#define RSYNC_PAM_H + +/* + * Verify the user's account status using PAM (pam_acct_mgmt). + * This ensures the account is not locked, expired, or otherwise restricted + * by the system administrator's PAM configuration. + * + * Returns NULL if access is permitted, or a static error string on failure. + */ +const char *rsync_pam_validate_account(const char *username); + +void base64_encode(const char *buf, int len, char *out, int pad); +char *auth_server(int f_in, int f_out, int module, const char *host, const char *addr, const char *leader); +void auth_client(int fd, const char *user, const char *challenge); + +#endif + diff --git a/authenticate.c b/auth/authenticate.c similarity index 95% rename from authenticate.c rename to auth/authenticate.c index 3376bb1ed..c4ac6c851 100644 --- a/authenticate.c +++ b/auth/authenticate.c @@ -21,6 +21,7 @@ #include "rsync.h" #include "itypes.h" #include "ifuncs.h" +#include "auth/auth.h" /* O_CLOEXEC is absent on some still-supported targets. The random-source fd * is read and closed synchronously, so the established zero-value fallback is @@ -32,6 +33,7 @@ extern int read_only; extern char *password_file; extern struct name_num_obj valid_auth_checksums; +extern int am_root; /*************************************************************************** encode a buffer using base64 - simple and slow algorithm. null terminates @@ -289,6 +291,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 +417,25 @@ 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) { + if (am_root != 1) + err = "PAM enabled but daemon not running as root"; +#ifndef SUPPORT_PAM + else + err = "PAM enabled but rsync compiled without PAM support"; +#else + 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/auth/pam.c b/auth/pam.c new file mode 100644 index 000000000..509f9c07c --- /dev/null +++ b/auth/pam.c @@ -0,0 +1,66 @@ +#include "rsync.h" +#include "auth/auth.h" +#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 + +/* + * A cross-platform dummy conversation function. + * Completely eliminates the need for the Linux-only pam_misc.h and misc_conv. + * If PAM attempts to interactively prompt for a password or display a message, + * this instantly rejects it to prevent the background daemon from hanging. + */ +static int rsync_pam_conv(int num_msg, const struct pam_message **msg, + struct pam_response **resp, void *appdata_ptr) +{ + /* Suppress unused variable warnings */ + (void)num_msg; + (void)msg; + (void)resp; + (void)appdata_ptr; + + return PAM_CONV_ERR; +} + +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 diff --git a/clientserver.c b/clientserver.c index e7f5dfd85..90973824f 100644 --- a/clientserver.c +++ b/clientserver.c @@ -22,6 +22,7 @@ #include "rsync.h" #include "itypes.h" #include "ifuncs.h" +#include "auth/auth.h" extern int quiet; extern int dry_run; diff --git a/configure.ac b/configure.ac index 17f53f9ea..6ca317d43 100644 --- a/configure.ac +++ b/configure.ac @@ -190,6 +190,9 @@ if test x"$with_secluded_args" = x"yes"; then AC_DEFINE_UNQUOTED(RSYNC_USE_SECLUDED_ARGS, 1, [Define to 1 if --secluded-args should be the default]) fi +AC_ARG_WITH([pam], + AS_HELP_STRING([--without-pam], [disable PAM support (default is auto-detect)])) + AC_ARG_WITH(rsync-path, AS_HELP_STRING([--with-rsync-path=PATH],[set default --rsync-path to PATH (default: rsync)]), [ RSYNC_PATH="$with_rsync_path" ], @@ -482,6 +485,33 @@ if test x"$with_included_zlib" != x"yes"; then AC_CHECK_LIB(z, deflateParams, , [with_included_zlib=yes]) fi +if test x"$with_pam" != x"no"; then + AC_CHECK_LIB([pam], [pam_start], [ + # Look for the cross-platform headers FIRST + AC_CHECK_HEADERS([security/pam_appl.h pam/pam_appl.h], [ + # This triggers if EITHER header is found + pam_header_found=yes + ]) + + # Only define SUPPORT_PAM if the headers actually exist + if test x"$pam_header_found" = x"yes"; then + LIBS="-lpam $LIBS" + AC_DEFINE(SUPPORT_PAM, 1, [Define to 1 if PAM is installed]) + with_pam=yes + else + if test x"$with_pam" = x"yes"; then + AC_MSG_ERROR([PAM support requested but pam_appl.h headers not found]) + fi + with_pam=no + fi + ], [ + if test x"$with_pam" = x"yes"; then + AC_MSG_ERROR([PAM support requested but libpam not found]) + fi + with_pam=no + ]) +fi + AC_MSG_CHECKING([whether to use included zlib]) if test x"$with_included_zlib" = x"yes"; then AC_MSG_RESULT($srcdir/zlib) 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/socket.c b/socket.c index 1449055f5..49383b625 100644 --- a/socket.c +++ b/socket.c @@ -28,6 +28,7 @@ #include #include "itypes.h" #include "ifuncs.h" +#include "auth/auth.h" #ifdef HAVE_NETINET_IN_SYSTM_H #include #endif diff --git a/testsuite/daemon-auth_test.py b/testsuite/daemon-auth_test.py index 5075f1cbf..b89d22fd4 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,8 @@ import os import subprocess +import getpass +import sys from rsyncfns import ( FROMDIR, SCRATCHDIR, @@ -32,7 +34,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 +45,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 +55,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 +96,76 @@ 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 ----------------------------------------------- +# ============================================================================ + +is_root = (os.geteuid() == 0) +if not is_root: + print("PAM test is skipped. Test not running as root") + sys.exit(0) + +daemon_log = SCRATCHDIR / 'rsyncd.log' +if daemon_log.exists(): + daemon_log.unlink() +conf = SCRATCHDIR / 'rsyncd.conf' + +# FIX 1: uid and gid added back to prevent 'Permission Denied' +conf.write_text( + f"pid file = {SCRATCHDIR}/rsyncd.pid\n" + "use chroot = no\n" + f"uid = {real_user}\n" + f"gid = {real_user}\n" + f"log file = {daemon_log}\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://', '') + +# FIX 2: Must use 'ok' password here so MD5 succeeds and triggers the PAM code +# 1. Fake User (Correct Password) - Acts as our PAM environment probe +proc = push(ok, target_module='pam_auth', user='tuser') +log_content = daemon_log.read_text() if daemon_log.exists() else "" + +# Check exactly why the daemon rejected the connection +if "PAM enabled but rsync compiled without PAM support" in log_content: + print("daemon-auth: PAM not compiled in. Skipping remaining PAM tests.") + sys.exit(0) + +if "PAM enabled but daemon not running as root" in log_content: + print("daemon-auth: Not running as root. Skipping remaining PAM tests.") + sys.exit(0) + +# If we get here, PAM is compiled and running as root. +# We MUST enforce the expected PAM account management failures. +if proc.returncode == 0: + 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 (Wrong Password) +# Fails at the initial MD5 hash check, never reaches PAM account management. +proc = push(bad, target_module='pam_auth', user='tuser') +if proc.returncode == 0: + test_fail("PAM module unexpectedly succeeded with the wrong password (fake user)") + +# 3. Real System User (Correct Password) +# Passes MD5 check and pam_acct_mgmt() confirms the account is valid. +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})") + +# If pam is not compiled, all this will pass normally so we need to check the log file +# to make sure that the user account is validated through PAM +log_content = daemon_log.read_text() if daemon_log.exists() else "" +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") +print("daemon-auth: auth users / secrets file / strict modes / PAM verified") From 695d2c25555471109f2794da8fd6648a3bca6faf Mon Sep 17 00:00:00 2001 From: seks99x Date: Tue, 29 Sep 2026 15:50:10 +0300 Subject: [PATCH 2/5] Add a pam_mock.c for reliable PAM testing. Enhance the testsuite and configuraiton --- Makefile.in | 5 +- configure.ac | 53 +++++++++------------ testsuite/daemon-auth_test.py | 87 +++++++++++++++++++++++++---------- testsuite/pam/pam_mock.c | 11 +++++ 4 files changed, 99 insertions(+), 57 deletions(-) create mode 100644 testsuite/pam/pam_mock.c diff --git a/Makefile.in b/Makefile.in index 8065d526a..33adf4d9d 100644 --- a/Makefile.in +++ b/Makefile.in @@ -61,7 +61,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 @@ -551,6 +551,9 @@ simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS) touch $@; \ fi +pam_mock.so: $(srcdir)/testsuite/pam/pam_mock.c + $(CC) $(CFLAGS) $(CPPFLAGS) -shared -fPIC -o $@ $(srcdir)/testsuite/pam/pam_mock.c + testsuite/chown-fake_test.py: ln -s chown_test.py $(srcdir)/testsuite/chown-fake_test.py diff --git a/configure.ac b/configure.ac index 6ca317d43..1230185e4 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 @@ -190,9 +190,6 @@ if test x"$with_secluded_args" = x"yes"; then AC_DEFINE_UNQUOTED(RSYNC_USE_SECLUDED_ARGS, 1, [Define to 1 if --secluded-args should be the default]) fi -AC_ARG_WITH([pam], - AS_HELP_STRING([--without-pam], [disable PAM support (default is auto-detect)])) - AC_ARG_WITH(rsync-path, AS_HELP_STRING([--with-rsync-path=PATH],[set default --rsync-path to PATH (default: rsync)]), [ RSYNC_PATH="$with_rsync_path" ], @@ -485,33 +482,6 @@ if test x"$with_included_zlib" != x"yes"; then AC_CHECK_LIB(z, deflateParams, , [with_included_zlib=yes]) fi -if test x"$with_pam" != x"no"; then - AC_CHECK_LIB([pam], [pam_start], [ - # Look for the cross-platform headers FIRST - AC_CHECK_HEADERS([security/pam_appl.h pam/pam_appl.h], [ - # This triggers if EITHER header is found - pam_header_found=yes - ]) - - # Only define SUPPORT_PAM if the headers actually exist - if test x"$pam_header_found" = x"yes"; then - LIBS="-lpam $LIBS" - AC_DEFINE(SUPPORT_PAM, 1, [Define to 1 if PAM is installed]) - with_pam=yes - else - if test x"$with_pam" = x"yes"; then - AC_MSG_ERROR([PAM support requested but pam_appl.h headers not found]) - fi - with_pam=no - fi - ], [ - if test x"$with_pam" = x"yes"; then - AC_MSG_ERROR([PAM support requested but libpam not found]) - fi - with_pam=no - ]) -fi - AC_MSG_CHECKING([whether to use included zlib]) if test x"$with_included_zlib" = x"yes"; then AC_MSG_RESULT($srcdir/zlib) @@ -685,6 +655,27 @@ else AC_MSG_RESULT(no) fi +AC_MSG_CHECKING([whether to enable PAM support]) +AC_ARG_ENABLE([pam], + AS_HELP_STRING([--disable-pam], [disable to omit PAM support])) +AH_TEMPLATE([SUPPORT_PAM], +[Undefine if you do not want PAM support. By default this is defined.]) +if test x"$enable_pam" != x"no"; 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)], + [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 + if test x"$no_lib" != x; then echo "" echo "Configure found the following issues:" diff --git a/testsuite/daemon-auth_test.py b/testsuite/daemon-auth_test.py index b89d22fd4..035962672 100644 --- a/testsuite/daemon-auth_test.py +++ b/testsuite/daemon-auth_test.py @@ -10,7 +10,6 @@ import os import subprocess import getpass -import sys from rsyncfns import ( FROMDIR, SCRATCHDIR, @@ -99,24 +98,68 @@ def push(pw, target_module='auth', user='tuser', **kw): # ============================================================================ # --- PAM Implementation Tests ----------------------------------------------- # ============================================================================ +import sys +import ctypes.util +import platform +import shutil -is_root = (os.geteuid() == 0) -if not is_root: - print("PAM test is skipped. Test not running as root") +# Ensure root privileges +if os.getuid() != 0: + print("daemon-auth: auth users / secrets file / strict modes verified (PAM tests skipped: requires root)") sys.exit(0) +# 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: + res = subprocess.run([pkg_config, "--libs", "pam_wrapper"], capture_output=True, text=True, check=True) + discovered_path = res.stdout.strip() + if os.path.exists(discovered_path): + pam_wrapper_so = discovered_path + 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' -# FIX 1: uid and gid added back to prevent 'Permission Denied' conf.write_text( f"pid file = {SCRATCHDIR}/rsyncd.pid\n" "use chroot = no\n" - f"uid = {real_user}\n" - f"gid = {real_user}\n" f"log file = {daemon_log}\n" + "uid = 0\n" + "gid = 0\n" f"\n[pam_auth]\n" f"\tpath = {authdir}\n" "\tread only = no\n" @@ -128,44 +171,38 @@ def push(pw, target_module='auth', user='tuser', **kw): url = start_test_daemon(conf, DAEMON_PORT) host_port_path = url.replace('rsync://', '') -# FIX 2: Must use 'ok' password here so MD5 succeeds and triggers the PAM code -# 1. Fake User (Correct Password) - Acts as our PAM environment probe +# 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 = daemon_log.read_text() if daemon_log.exists() else "" -# Check exactly why the daemon rejected the connection if "PAM enabled but rsync compiled without PAM support" in log_content: - print("daemon-auth: PAM not compiled in. Skipping remaining PAM tests.") - sys.exit(0) - -if "PAM enabled but daemon not running as root" in log_content: - print("daemon-auth: Not running as root. Skipping remaining PAM tests.") - sys.exit(0) + test_fail("daemon-auth: auth users / secrets file / strict modes verified (PAM tests skipped: rsync built without PAM)") -# If we get here, PAM is compiled and running as root. -# We MUST enforce the expected PAM account management failures. -if proc.returncode == 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 (Wrong Password) -# Fails at the initial MD5 hash check, never reaches PAM account management. +# 2. Fake user with wrong password: fails MD5 challenge prior to PAM evaluation proc = push(bad, target_module='pam_auth', user='tuser') if proc.returncode == 0: test_fail("PAM module unexpectedly succeeded with the wrong password (fake user)") -# 3. Real System User (Correct Password) -# Passes MD5 check and pam_acct_mgmt() confirms the account is valid. +# 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})") -# If pam is not compiled, all this will pass normally so we need to check the log file -# to make sure that the user account is validated through PAM log_content = daemon_log.read_text() if daemon_log.exists() else "" 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..a538dcfb0 --- /dev/null +++ b/testsuite/pam/pam_mock.c @@ -0,0 +1,11 @@ +#define PAM_SM_ACCOUNT +#include +#include +#include + +int pam_sm_acct_mgmt(pam_handle_t *pamh, int flags, int argc, const char **argv) { + const char *user; + if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS) return PAM_PERM_DENIED; + if (getpwnam(user) != NULL) return PAM_SUCCESS; + return PAM_USER_UNKNOWN; +} From 3763d0d1d9f1e0a54804fca614ecc8fd7c57f623 Mon Sep 17 00:00:00 2001 From: seks99x Date: Tue, 29 Sep 2026 16:47:58 +0300 Subject: [PATCH 3/5] Fix solaris and macOS errors --- Makefile.in | 2 +- auth/auth.h | 15 +++----- auth/pam.c | 9 ++++- ...icate-no-ocloexec-build-regression_test.py | 2 +- testsuite/pam/pam_mock.c | 36 +++++++++++++++++-- 5 files changed, 48 insertions(+), 16 deletions(-) diff --git a/Makefile.in b/Makefile.in index 33adf4d9d..111a488a6 100644 --- a/Makefile.in +++ b/Makefile.in @@ -552,7 +552,7 @@ simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS) fi pam_mock.so: $(srcdir)/testsuite/pam/pam_mock.c - $(CC) $(CFLAGS) $(CPPFLAGS) -shared -fPIC -o $@ $(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/auth/auth.h b/auth/auth.h index 13c2bbfa7..573a2efde 100644 --- a/auth/auth.h +++ b/auth/auth.h @@ -1,18 +1,13 @@ -#ifndef RSYNC_PAM_H -#define RSYNC_PAM_H +#ifndef RSYNC_AUTH_H +#define RSYNC_AUTH_H -/* - * Verify the user's account status using PAM (pam_acct_mgmt). - * This ensures the account is not locked, expired, or otherwise restricted - * by the system administrator's PAM configuration. - * - * Returns NULL if access is permitted, or a static error string on failure. - */ +#ifdef SUPPORT_PAM +/* Verify the user's account status using PAM... */ const char *rsync_pam_validate_account(const char *username); +#endif void base64_encode(const char *buf, int len, char *out, int pad); char *auth_server(int f_in, int f_out, int module, const char *host, const char *addr, const char *leader); void auth_client(int fd, const char *user, const char *challenge); #endif - diff --git a/auth/pam.c b/auth/pam.c index 509f9c07c..8af0803f5 100644 --- a/auth/pam.c +++ b/auth/pam.c @@ -11,13 +11,20 @@ # 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 dummy conversation function. * Completely eliminates the need for the Linux-only pam_misc.h and misc_conv. * If PAM attempts to interactively prompt for a password or display a message, * this instantly rejects it to prevent the background daemon from hanging. */ -static int rsync_pam_conv(int num_msg, const struct pam_message **msg, +static int rsync_pam_conv(int num_msg, PAM_MSG_CONST struct pam_message **msg, struct pam_response **resp, void *appdata_ptr) { /* Suppress unused variable warnings */ diff --git a/testsuite/authenticate-no-ocloexec-build-regression_test.py b/testsuite/authenticate-no-ocloexec-build-regression_test.py index 672fc9262..41b8f7196 100644 --- a/testsuite/authenticate-no-ocloexec-build-regression_test.py +++ b/testsuite/authenticate-no-ocloexec-build-regression_test.py @@ -31,7 +31,7 @@ def makefile_vars(repo, names): repo = Path(os.environ.get( "RSYNC_SOURCE_UNDER_TEST", Path(__file__).resolve().parent.parent)) -source_path = repo / "authenticate.c" +source_path = repo / "auth" / "authenticate.c" config_path = repo / "config.h" if not source_path.exists() or not config_path.exists(): test_skipped(f"configured rsync source tree unavailable at {repo}") diff --git a/testsuite/pam/pam_mock.c b/testsuite/pam/pam_mock.c index a538dcfb0..7ca8eafdb 100644 --- a/testsuite/pam/pam_mock.c +++ b/testsuite/pam/pam_mock.c @@ -1,11 +1,41 @@ +#include "config.h" + +#ifdef SUPPORT_PAM + #define PAM_SM_ACCOUNT +#include #include #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 char *user; - if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS) return PAM_PERM_DENIED; - if (getpwnam(user) != NULL) return PAM_SUCCESS; + 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. ISO C forbids an empty translation unit, + * so we provide a dummy typedef. + */ +typedef int make_iso_compilers_happy; +#endif From 6779d976e42a22387f58976c4b93ebbcd2a12e2a Mon Sep 17 00:00:00 2001 From: seks99x Date: Wed, 30 Sep 2026 16:20:36 +0300 Subject: [PATCH 4/5] Let the pam optional check on configure.ac --- Makefile.in | 2 +- configure.ac | 13 +++++++++---- testsuite/daemon-auth_test.py | 5 +++-- testsuite/pam/pam_mock.c | 4 +--- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Makefile.in b/Makefile.in index 111a488a6..b7b939bff 100644 --- a/Makefile.in +++ b/Makefile.in @@ -61,7 +61,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) pam_mock.so + 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 diff --git a/configure.ac b/configure.ac index 1230185e4..24d654299 100644 --- a/configure.ac +++ b/configure.ac @@ -655,16 +655,19 @@ else AC_MSG_RESULT(no) fi +PAM_MOCK_SO="" AC_MSG_CHECKING([whether to enable PAM support]) AC_ARG_ENABLE([pam], - AS_HELP_STRING([--disable-pam], [disable to omit PAM support])) + AS_HELP_STRING([--enable-pam], [enable PAM support (default is NO)])) AH_TEMPLATE([SUPPORT_PAM], -[Undefine if you do not want PAM support. By default this is defined.]) -if test x"$enable_pam" != x"no"; then +[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)], + [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 @@ -676,6 +679,8 @@ 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/testsuite/daemon-auth_test.py b/testsuite/daemon-auth_test.py index 035962672..60b646b0d 100644 --- a/testsuite/daemon-auth_test.py +++ b/testsuite/daemon-auth_test.py @@ -176,7 +176,8 @@ def push(pw, target_module='auth', user='tuser', **kw): log_content = daemon_log.read_text() if daemon_log.exists() else "" if "PAM enabled but rsync compiled without PAM support" in log_content: - test_fail("daemon-auth: auth users / secrets file / strict modes verified (PAM tests skipped: rsync built without PAM)") + 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'!") @@ -186,7 +187,7 @@ def push(pw, target_module='auth', user='tuser', **kw): # 2. Fake user with wrong password: fails MD5 challenge prior to PAM evaluation proc = push(bad, target_module='pam_auth', user='tuser') -if proc.returncode == 0: +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 diff --git a/testsuite/pam/pam_mock.c b/testsuite/pam/pam_mock.c index 7ca8eafdb..ec17790a2 100644 --- a/testsuite/pam/pam_mock.c +++ b/testsuite/pam/pam_mock.c @@ -34,8 +34,6 @@ int pam_sm_acct_mgmt(pam_handle_t *pamh, int flags, int argc, const char **argv) #else /* * If PAM is disabled or headers are missing, we compile an empty file - * to prevent compiler errors. ISO C forbids an empty translation unit, - * so we provide a dummy typedef. - */ + * to prevent compiler errors. */ typedef int make_iso_compilers_happy; #endif From e834eb613a03af2a6291c19c6fbd0d6877b4877c Mon Sep 17 00:00:00 2001 From: seks99x Date: Thu, 1 Oct 2026 14:36:11 +0300 Subject: [PATCH 5/5] Add PAM documentation to rsyncd.conf.5.md. Some tweaks. Remove auth/ subsystem --- Makefile.in | 5 +- auth/auth.h | 13 --- auth/pam.c | 73 ------------ auth/authenticate.c => authenticate.c | 110 +++++++++++++++--- clientserver.c | 1 - rsyncd.conf.5.md | 14 +++ socket.c | 1 - ...icate-no-ocloexec-build-regression_test.py | 2 +- testsuite/daemon-auth_test.py | 29 +++-- testsuite/pam/pam_mock.c | 13 ++- 10 files changed, 144 insertions(+), 117 deletions(-) delete mode 100644 auth/auth.h delete mode 100644 auth/pam.c rename auth/authenticate.c => authenticate.c (82%) diff --git a/Makefile.in b/Makefile.in index b7b939bff..612889254 100644 --- a/Makefile.in +++ b/Makefile.in @@ -39,7 +39,7 @@ GENFILES=configure.sh aclocal.m4 config.h.in rsync.1 rsync.1.html \ rsync-ssl.1 rsync-ssl.1.html rsyncd.conf.5 rsyncd.conf.5.html \ @GEN_RRSYNC@ HEADERS=byteorder.h config.h errcode.h proto.h rsync.h ifuncs.h itypes.h inums.h \ - lib/pool_alloc.h lib/mdigest.h lib/md-defines.h auth/auth.h + lib/pool_alloc.h lib/mdigest.h lib/md-defines.h LIBOBJ=lib/wildmatch.o lib/compat.o lib/snprintf.o lib/mdfour.o lib/md5.o \ lib/permstring.o lib/pool_alloc.o lib/sysacls.o lib/sysxattrs.o lib/acl.o @LIBOBJS@ zlib_OBJS=zlib/deflate.o zlib/inffast.o zlib/inflate.o zlib/inftrees.o \ @@ -50,8 +50,7 @@ OBJS1=$(OBJS1_NO_MAIN) main.o OBJS2=options.o io.o compat.o hlink.o token.o uidlist.o socket.o hashtable.o \ usage.o fileio.o batch.o clientname.o chmod.o acls.o xattrs.o OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ -AUTH_OBJ = auth/authenticate.o auth/pam.o -DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o $(AUTH_OBJ) +DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ diff --git a/auth/auth.h b/auth/auth.h deleted file mode 100644 index 573a2efde..000000000 --- a/auth/auth.h +++ /dev/null @@ -1,13 +0,0 @@ -#ifndef RSYNC_AUTH_H -#define RSYNC_AUTH_H - -#ifdef SUPPORT_PAM -/* Verify the user's account status using PAM... */ -const char *rsync_pam_validate_account(const char *username); -#endif - -void base64_encode(const char *buf, int len, char *out, int pad); -char *auth_server(int f_in, int f_out, int module, const char *host, const char *addr, const char *leader); -void auth_client(int fd, const char *user, const char *challenge); - -#endif diff --git a/auth/pam.c b/auth/pam.c deleted file mode 100644 index 8af0803f5..000000000 --- a/auth/pam.c +++ /dev/null @@ -1,73 +0,0 @@ -#include "rsync.h" -#include "auth/auth.h" -#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 dummy conversation function. - * Completely eliminates the need for the Linux-only pam_misc.h and misc_conv. - * If PAM attempts to interactively prompt for a password or display a message, - * 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) -{ - /* Suppress unused variable warnings */ - (void)num_msg; - (void)msg; - (void)resp; - (void)appdata_ptr; - - return PAM_CONV_ERR; -} - -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 diff --git a/auth/authenticate.c b/authenticate.c similarity index 82% rename from auth/authenticate.c rename to authenticate.c index c4ac6c851..cf0678604 100644 --- a/auth/authenticate.c +++ b/authenticate.c @@ -21,7 +21,6 @@ #include "rsync.h" #include "itypes.h" #include "ifuncs.h" -#include "auth/auth.h" /* O_CLOEXEC is absent on some still-supported targets. The random-source fd * is read and closed synchronously, so the established zero-value fallback is @@ -33,7 +32,6 @@ extern int read_only; extern char *password_file; extern struct name_num_obj valid_auth_checksums; -extern int am_root; /*************************************************************************** encode a buffer using base64 - simple and slow algorithm. null terminates @@ -86,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) { @@ -421,19 +510,14 @@ char *auth_server(int f_in, int f_out, int module, const char *host, err = check_secret(module, line, group, challenge, pass); /* 2. Validate PAM requirements and account status */ if (!err && use_pam) { - if (am_root != 1) - err = "PAM enabled but daemon not running as root"; #ifndef SUPPORT_PAM - else - err = "PAM enabled but rsync compiled without PAM support"; + err = "PAM enabled but rsync compiled without PAM support"; #else - 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; - } + /* 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 } } diff --git a/clientserver.c b/clientserver.c index 90973824f..e7f5dfd85 100644 --- a/clientserver.c +++ b/clientserver.c @@ -22,7 +22,6 @@ #include "rsync.h" #include "itypes.h" #include "ifuncs.h" -#include "auth/auth.h" extern int quiet; extern int dry_run; 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/socket.c b/socket.c index 49383b625..1449055f5 100644 --- a/socket.c +++ b/socket.c @@ -28,7 +28,6 @@ #include #include "itypes.h" #include "ifuncs.h" -#include "auth/auth.h" #ifdef HAVE_NETINET_IN_SYSTM_H #include #endif diff --git a/testsuite/authenticate-no-ocloexec-build-regression_test.py b/testsuite/authenticate-no-ocloexec-build-regression_test.py index 41b8f7196..672fc9262 100644 --- a/testsuite/authenticate-no-ocloexec-build-regression_test.py +++ b/testsuite/authenticate-no-ocloexec-build-regression_test.py @@ -31,7 +31,7 @@ def makefile_vars(repo, names): repo = Path(os.environ.get( "RSYNC_SOURCE_UNDER_TEST", Path(__file__).resolve().parent.parent)) -source_path = repo / "auth" / "authenticate.c" +source_path = repo / "authenticate.c" config_path = repo / "config.h" if not source_path.exists() or not config_path.exists(): test_skipped(f"configured rsync source tree unavailable at {repo}") diff --git a/testsuite/daemon-auth_test.py b/testsuite/daemon-auth_test.py index 60b646b0d..befff4f41 100644 --- a/testsuite/daemon-auth_test.py +++ b/testsuite/daemon-auth_test.py @@ -103,11 +103,6 @@ def push(pw, target_module='auth', user='tuser', **kw): import platform import shutil -# Ensure root privileges -if os.getuid() != 0: - print("daemon-auth: auth users / secrets file / strict modes verified (PAM tests skipped: requires root)") - sys.exit(0) - # 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)") @@ -125,10 +120,20 @@ def push(pw, target_module='auth', user='tuser', **kw): 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.exists(discovered_path): + + 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 @@ -158,8 +163,8 @@ def push(pw, target_module='auth', user='tuser', **kw): f"pid file = {SCRATCHDIR}/rsyncd.pid\n" "use chroot = no\n" f"log file = {daemon_log}\n" - "uid = 0\n" - "gid = 0\n" + f"uid = {os.getuid()}\n" + f"gid = {os.getgid()}\n" f"\n[pam_auth]\n" f"\tpath = {authdir}\n" "\tread only = no\n" @@ -171,9 +176,12 @@ def push(pw, target_module='auth', user='tuser', **kw): 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 = daemon_log.read_text() if daemon_log.exists() else "" +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)") @@ -187,6 +195,7 @@ def push(pw, target_module='auth', user='tuser', **kw): # 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)") @@ -195,7 +204,7 @@ def push(pw, target_module='auth', user='tuser', **kw): 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 = daemon_log.read_text() if daemon_log.exists() else "" +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.") diff --git a/testsuite/pam/pam_mock.c b/testsuite/pam/pam_mock.c index ec17790a2..887581adf 100644 --- a/testsuite/pam/pam_mock.c +++ b/testsuite/pam/pam_mock.c @@ -3,8 +3,17 @@ #ifdef SUPPORT_PAM #define PAM_SM_ACCOUNT -#include -#include +#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