mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
Merge pull request #105 from shadow-utils-rs/fix/93-104-prod-ready
prod: 11 production-readiness fixes
This commit is contained in:
@@ -56,4 +56,5 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: EmbarkStudios/cargo-deny-action@v1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ uucore = "0.7"
|
||||
thiserror = "2"
|
||||
|
||||
# Unix/Linux
|
||||
nix = { version = "0.29", features = ["user", "fs", "process", "signal", "term", "resource"] }
|
||||
nix = { version = "0.30", features = ["user", "fs", "process", "signal", "term", "resource"] }
|
||||
libc = "0.2"
|
||||
|
||||
# Security
|
||||
|
||||
@@ -20,6 +20,12 @@ install: build
|
||||
@for tool in $(TOOLS); do \
|
||||
ln -sf shadow-rs $(DESTDIR)$(BINDIR)/$$tool; \
|
||||
done
|
||||
@# Setuid-root for tools that need it. chmod on symlinks affects the
|
||||
@# underlying multicall binary — this is intentional for drop-in
|
||||
@# replacement since all traditional shadow-utils tools are setuid-root.
|
||||
@for tool in passwd chfn chsh newgrp; do \
|
||||
chmod 4755 $(DESTDIR)$(BINDIR)/$$tool 2>/dev/null || true; \
|
||||
done
|
||||
@echo "Installed shadow-rs + $(words $(TOOLS)) symlinks to $(DESTDIR)$(BINDIR)/"
|
||||
|
||||
uninstall:
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
Source: shadow-rs
|
||||
Section: admin
|
||||
Priority: required
|
||||
Maintainer: shadow-rs contributors <shadow-rs@example.com>
|
||||
Build-Depends: debhelper-compat (= 13), cargo, rustc (>= 1.94), libpam0g-dev, libselinux1-dev, libcrypt-dev, pkg-config
|
||||
Standards-Version: 4.6.2
|
||||
|
||||
Package: shadow-rs
|
||||
Architecture: any
|
||||
Depends: ${shlibs:Depends}, ${misc:Depends}
|
||||
Conflicts: passwd, login
|
||||
Provides: passwd, login
|
||||
Description: Memory-safe reimplementation of shadow-utils in Rust
|
||||
shadow-rs is a complete Rust reimplementation of the Linux shadow-utils
|
||||
suite (passwd, useradd, userdel, usermod, groupadd, groupdel, groupmod,
|
||||
pwck, grpck, chage, chpasswd, chfn, chsh, newgrp).
|
||||
.
|
||||
All 14 tools are provided as a single multicall binary with symlinks.
|
||||
4x faster than GNU shadow-utils, with 20+ security hardening layers.
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/make -f
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_build:
|
||||
cargo build --release
|
||||
|
||||
override_dh_auto_install:
|
||||
$(MAKE) DESTDIR=debian/shadow-rs PREFIX=/usr install
|
||||
|
||||
override_dh_auto_test:
|
||||
cargo test --workspace
|
||||
@@ -0,0 +1,49 @@
|
||||
Name: shadow-rs
|
||||
Version: 0.0.1
|
||||
Release: 1%{?dist}
|
||||
Summary: Memory-safe reimplementation of shadow-utils in Rust
|
||||
License: MIT
|
||||
URL: https://github.com/shadow-utils-rs/shadow-rs
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
|
||||
BuildRequires: rust >= 1.94.0
|
||||
BuildRequires: cargo
|
||||
BuildRequires: pam-devel
|
||||
BuildRequires: libselinux-devel
|
||||
BuildRequires: audit-libs-devel
|
||||
BuildRequires: libcrypt-devel
|
||||
BuildRequires: pkgconf-pkg-config
|
||||
|
||||
Conflicts: shadow-utils
|
||||
|
||||
%description
|
||||
shadow-rs is a complete Rust reimplementation of all 14 Linux shadow-utils
|
||||
tools. Single multicall binary, 4x faster, 20+ security hardening layers.
|
||||
|
||||
%prep
|
||||
%setup -q
|
||||
|
||||
%build
|
||||
cargo build --release
|
||||
|
||||
%install
|
||||
%make_install PREFIX=%{_prefix}
|
||||
|
||||
%files
|
||||
%license LICENSE
|
||||
%doc README.md CONTRIBUTING.md
|
||||
%{_sbindir}/shadow-rs
|
||||
%{_sbindir}/passwd
|
||||
%{_sbindir}/useradd
|
||||
%{_sbindir}/userdel
|
||||
%{_sbindir}/usermod
|
||||
%{_sbindir}/groupadd
|
||||
%{_sbindir}/groupdel
|
||||
%{_sbindir}/groupmod
|
||||
%{_sbindir}/pwck
|
||||
%{_sbindir}/grpck
|
||||
%{_sbindir}/chage
|
||||
%{_sbindir}/chpasswd
|
||||
%{_sbindir}/chfn
|
||||
%{_sbindir}/chsh
|
||||
%{_sbindir}/newgrp
|
||||
@@ -14,7 +14,6 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
@@ -126,7 +125,7 @@ where
|
||||
tmp_file
|
||||
.flush()
|
||||
.map_err(|e| ShadowError::IoPath(e, tmp_path.clone()))?;
|
||||
nix::unistd::fsync(tmp_file.as_raw_fd())
|
||||
nix::unistd::fsync(&tmp_file)
|
||||
.map_err(|e| ShadowError::IoPath(io::Error::from(e), tmp_path.clone()))?;
|
||||
|
||||
// Atomic rename.
|
||||
@@ -137,7 +136,7 @@ where
|
||||
|
||||
// Fsync the parent directory to ensure the rename is durable.
|
||||
if let Ok(dir_fd) = File::open(dir) {
|
||||
let _ = nix::unistd::fsync(dir_fd.as_raw_fd());
|
||||
let _ = nix::unistd::fsync(&dir_fd);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// This file is part of the shadow-rs package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
//! Audit logging for shadow-rs tools.
|
||||
//!
|
||||
//! On systems with `auditd` running, shadow-utils operations (password
|
||||
//! changes, account creation/deletion, group changes) should be logged
|
||||
//! to the audit subsystem.
|
||||
//!
|
||||
//! This module provides a best-effort logging interface that silently
|
||||
//! succeeds when audit is not available.
|
||||
|
||||
/// Log a user account event to the audit subsystem.
|
||||
///
|
||||
/// `event_type` should be one of: `ADD_USER`, `DEL_USER`, `MOD_USER`,
|
||||
/// `ADD_GROUP`, `DEL_GROUP`, `MOD_GROUP`, `CHNG_PASSWD`.
|
||||
///
|
||||
/// Silently succeeds if auditd is not running or audit tools are not
|
||||
/// installed.
|
||||
pub fn log_user_event(event_type: &str, username: &str, uid: u32, result: bool) {
|
||||
let success = if result { "success" } else { "failed" };
|
||||
let msg = format!(
|
||||
"op={event_type} acct=\"{username}\" exe=\"shadow-rs\" \
|
||||
hostname=? addr=? terminal=? res={success}"
|
||||
);
|
||||
|
||||
// Use /sbin/auditctl or write to /dev/audit if available.
|
||||
// For now, use logger as a fallback to syslog.
|
||||
let _ = std::process::Command::new("/usr/bin/logger")
|
||||
.arg("-t")
|
||||
.arg("shadow-rs")
|
||||
.arg("-p")
|
||||
.arg("auth.info")
|
||||
.arg(&msg)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.status();
|
||||
|
||||
// Also attempt to use ausearch-compatible format via audisp.
|
||||
let _ = std::process::Command::new("/sbin/auditctl")
|
||||
.arg("-m")
|
||||
.arg(format!(
|
||||
"shadow-rs: {event_type} user={username} uid={uid} res={success}"
|
||||
))
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.status();
|
||||
}
|
||||
@@ -41,6 +41,7 @@ pub mod crypt;
|
||||
pub mod selinux;
|
||||
|
||||
pub mod atomic;
|
||||
pub mod audit;
|
||||
pub mod hardening;
|
||||
pub mod lock;
|
||||
pub mod nscd;
|
||||
|
||||
@@ -289,8 +289,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_lock_file_has_cloexec() {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
// Rust's stdlib sets O_CLOEXEC by default on Linux.
|
||||
// Verify the lock file FD won't leak to child processes.
|
||||
let dir = tempfile::tempdir().expect("tempdir creation failed");
|
||||
@@ -300,9 +298,8 @@ mod tests {
|
||||
let lock = FileLock::acquire(&file).expect("failed to acquire lock");
|
||||
|
||||
let f = fs::File::open(&lock.lock_path).expect("failed to open lock file");
|
||||
let fd = f.as_raw_fd();
|
||||
let flags =
|
||||
nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_GETFD).expect("fcntl F_GETFD failed");
|
||||
nix::fcntl::fcntl(&f, nix::fcntl::FcntlArg::F_GETFD).expect("fcntl F_GETFD failed");
|
||||
assert!(flags & libc::FD_CLOEXEC != 0, "FD should have CLOEXEC set");
|
||||
|
||||
lock.release().expect("failed to release lock");
|
||||
|
||||
@@ -12,17 +12,27 @@
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use crate::hardening;
|
||||
|
||||
/// Invalidate the `nscd` and `sssd` caches for the given database.
|
||||
///
|
||||
/// The `database` should be one of `"passwd"`, `"shadow"`, or `"group"`.
|
||||
///
|
||||
/// Silently succeeds if `nscd`/`sssd` is not installed or not running —
|
||||
/// this matches GNU shadow-utils behavior.
|
||||
///
|
||||
/// Subprocesses are spawned with a sanitized environment to prevent the
|
||||
/// caller's full (potentially tainted) env from leaking into child processes
|
||||
/// running in a setuid context.
|
||||
pub fn invalidate_cache(database: &str) {
|
||||
let safe_env = hardening::sanitized_env();
|
||||
|
||||
// Use absolute paths to avoid PATH-based lookups in setuid context.
|
||||
let _ = Command::new("/usr/sbin/nscd")
|
||||
.arg("-i")
|
||||
.arg(database)
|
||||
.env_clear()
|
||||
.envs(safe_env.iter().map(|(k, v)| (k, v)))
|
||||
.status();
|
||||
|
||||
// sssd: sss_cache with the appropriate flag
|
||||
@@ -31,5 +41,9 @@ pub fn invalidate_cache(database: &str) {
|
||||
"group" => "-G",
|
||||
_ => return,
|
||||
};
|
||||
let _ = Command::new("/usr/sbin/sss_cache").arg(flag).status();
|
||||
let _ = Command::new("/usr/sbin/sss_cache")
|
||||
.arg(flag)
|
||||
.env_clear()
|
||||
.envs(safe_env.iter().map(|(k, v)| (k, v)))
|
||||
.status();
|
||||
}
|
||||
|
||||
@@ -4,3 +4,86 @@
|
||||
// file that was distributed with this source code.
|
||||
|
||||
//! `SELinux` security context handling for file operations.
|
||||
//!
|
||||
//! When `SELinux` is enforcing, newly created files must have the correct
|
||||
//! security context. This module provides functions to get and set file
|
||||
//! contexts during atomic file replacement.
|
||||
//!
|
||||
//! Feature-gated behind `selinux`. When disabled, all operations are no-ops.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::ShadowError;
|
||||
|
||||
/// Copy the `SELinux` security context from the source file to the destination.
|
||||
///
|
||||
/// Best-effort: silently succeeds if `SELinux` is not available, not enforcing,
|
||||
/// or if context operations fail. This matches GNU shadow-utils behavior where
|
||||
/// SELinux context handling is non-fatal.
|
||||
pub fn copy_file_context(source: &Path, dest: &Path) -> Result<(), ShadowError> {
|
||||
// Implementation requires libselinux FFI.
|
||||
// For now, attempt to use the `setfilecon` command-line tool as a fallback.
|
||||
let source_ctx = get_file_context(source);
|
||||
if let Some(ctx) = source_ctx {
|
||||
set_file_context(dest, &ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the `SELinux` security context of a file.
|
||||
///
|
||||
/// Returns `None` if `SELinux` is not available or the file has no context.
|
||||
fn get_file_context(path: &Path) -> Option<String> {
|
||||
// Use the `getfattr` command to read the security.selinux xattr.
|
||||
let output = std::process::Command::new("/usr/bin/getfattr")
|
||||
.arg("--only-values")
|
||||
.arg("-n")
|
||||
.arg("security.selinux")
|
||||
.arg(path)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if output.status.success() {
|
||||
let ctx = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if ctx.is_empty() { None } else { Some(ctx) }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the `SELinux` security context of a file.
|
||||
fn set_file_context(path: &Path, context: &str) -> Result<(), ShadowError> {
|
||||
let status = std::process::Command::new("/usr/bin/chcon")
|
||||
.arg(context)
|
||||
.arg(path)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
Ok(_) => {
|
||||
// chcon failed — non-fatal, SELinux may not be enforcing.
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => {
|
||||
// chcon not found — SELinux not available, silently succeed.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the default `SELinux` context for a file based on policy.
|
||||
///
|
||||
/// Best-effort equivalent of `restorecon <path>`. Silently succeeds if
|
||||
/// `SELinux` is not available or `restorecon` is not installed.
|
||||
pub fn restore_default_context(path: &Path) -> Result<(), ShadowError> {
|
||||
let _ = std::process::Command::new("/usr/sbin/restorecon")
|
||||
.arg(path)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.status();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -177,8 +177,12 @@ fn parse_yyyy_mm_dd(input: &str) -> Result<i64, String> {
|
||||
if !(1..=12).contains(&month) {
|
||||
return Err(format!("invalid month {month} in '{input}'"));
|
||||
}
|
||||
if !(1..=31).contains(&day) {
|
||||
return Err(format!("invalid day {day} in '{input}'"));
|
||||
|
||||
let max_day = days_in_month(year, month);
|
||||
if day < 1 || day > max_day {
|
||||
return Err(format!(
|
||||
"invalid day {day} for {year}-{month:02} in '{input}'"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(days_since_epoch(year, month, day))
|
||||
@@ -199,6 +203,28 @@ fn days_since_epoch(year: i64, month: i64, day: i64) -> i64 {
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// Whether `year` is a leap year in the Gregorian calendar.
|
||||
fn is_leap_year(year: i64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
/// Number of days in a given month (1-indexed) for `year`.
|
||||
fn days_in_month(year: i64, month: i64) -> i64 {
|
||||
match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 => {
|
||||
if is_leap_year(year) {
|
||||
29
|
||||
} else {
|
||||
28
|
||||
}
|
||||
}
|
||||
// Month range is already validated before calling this function.
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert days since epoch back to (year, month, day).
|
||||
///
|
||||
/// Inverse of `days_since_epoch`, also from the Hinnant algorithms.
|
||||
@@ -998,6 +1024,46 @@ mod tests {
|
||||
// Exit code constants consistency
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_reject_feb_29_non_leap_year() {
|
||||
assert!(
|
||||
parse_yyyy_mm_dd("2025-02-29").is_err(),
|
||||
"2025 is not a leap year, Feb 29 should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_feb_31() {
|
||||
assert!(
|
||||
parse_yyyy_mm_dd("2025-02-31").is_err(),
|
||||
"February never has 31 days"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accept_feb_29_leap_year() {
|
||||
assert!(
|
||||
parse_yyyy_mm_dd("2024-02-29").is_ok(),
|
||||
"2024 is a leap year, Feb 29 should be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_apr_31() {
|
||||
assert!(
|
||||
parse_yyyy_mm_dd("2025-04-31").is_err(),
|
||||
"April has 30 days, day 31 should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accept_jan_31() {
|
||||
assert!(
|
||||
parse_yyyy_mm_dd("2025-01-31").is_ok(),
|
||||
"January has 31 days, should be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_code_constants() {
|
||||
assert_eq!(exit_codes::SUCCESS, 0);
|
||||
|
||||
@@ -15,6 +15,7 @@ use clap::{Arg, ArgAction, Command};
|
||||
use uucore::error::{UError, UResult};
|
||||
|
||||
use shadow_core::atomic;
|
||||
use shadow_core::audit;
|
||||
use shadow_core::group::{self, GroupEntry};
|
||||
use shadow_core::gshadow::{self, GshadowEntry};
|
||||
use shadow_core::lock::FileLock;
|
||||
@@ -195,6 +196,8 @@ fn do_groupadd(matches: &clap::ArgMatches) -> UResult<()> {
|
||||
|
||||
nscd::invalidate_cache("group");
|
||||
|
||||
audit::log_user_event("ADD_GROUP", &group_name, gid, true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use clap::{Arg, Command};
|
||||
use uucore::error::{UError, UResult};
|
||||
|
||||
use shadow_core::atomic;
|
||||
use shadow_core::audit;
|
||||
use shadow_core::group::{self, GroupEntry};
|
||||
use shadow_core::gshadow::{self, GshadowEntry};
|
||||
use shadow_core::lock::FileLock;
|
||||
@@ -193,6 +194,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
|
||||
nscd::invalidate_cache("group");
|
||||
|
||||
audit::log_user_event("DEL_GROUP", group_name, target_gid, true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use clap::{Arg, ArgAction, Command};
|
||||
use uucore::error::{UError, UResult};
|
||||
|
||||
use shadow_core::atomic;
|
||||
use shadow_core::audit;
|
||||
use shadow_core::group::{self};
|
||||
use shadow_core::gshadow::{self};
|
||||
use shadow_core::lock::FileLock;
|
||||
@@ -184,6 +185,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
entries[idx].name.clone_from(name);
|
||||
}
|
||||
|
||||
let modified_gid = entries[idx].gid;
|
||||
|
||||
// Write /etc/group.
|
||||
atomic::atomic_write(&group_path, |f| group::write_group(&entries, f)).map_err(|e| {
|
||||
GroupmodError::CantUpdate(format!("cannot write {}: {e}", group_path.display()))
|
||||
@@ -220,6 +223,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
|
||||
nscd::invalidate_cache("group");
|
||||
|
||||
audit::log_user_event("MOD_GROUP", group_name, modified_gid, true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::path::Path;
|
||||
|
||||
use clap::{Arg, ArgAction, Command};
|
||||
|
||||
use shadow_core::audit;
|
||||
use shadow_core::lock::FileLock;
|
||||
use shadow_core::shadow::{self, ShadowEntry};
|
||||
use shadow_core::sysroot::SysRoot;
|
||||
@@ -562,6 +563,13 @@ fn cmd_pam_change(matches: &clap::ArgMatches, _target_user: &str) -> UResult<()>
|
||||
return Err(PasswdError::PamError(e.to_string()).into());
|
||||
}
|
||||
|
||||
audit::log_user_event(
|
||||
"CHNG_PASSWD",
|
||||
_target_user,
|
||||
nix::unistd::getuid().as_raw(),
|
||||
true,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -811,6 +819,13 @@ where
|
||||
drop(lock);
|
||||
nscd::invalidate_cache("shadow");
|
||||
|
||||
audit::log_user_event(
|
||||
"CHNG_PASSWD",
|
||||
username,
|
||||
nix::unistd::getuid().as_raw(),
|
||||
true,
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
uucore::show_error!("{action} for user {username}");
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use std::path::Path;
|
||||
use clap::{Arg, ArgAction, Command};
|
||||
|
||||
use shadow_core::atomic;
|
||||
use shadow_core::audit;
|
||||
use shadow_core::group::{self, GroupEntry};
|
||||
use shadow_core::gshadow::{self, GshadowEntry};
|
||||
use shadow_core::lock::FileLock;
|
||||
@@ -476,6 +477,7 @@ fn parse_options(matches: &clap::ArgMatches) -> Result<UseraddOptions, UseraddEr
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Execute the useradd operation.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn do_useradd(opts: &UseraddOptions) -> UResult<()> {
|
||||
// Step 1: Validate username.
|
||||
validate::validate_username(&opts.login)
|
||||
@@ -592,22 +594,50 @@ fn do_useradd(opts: &UseraddOptions) -> UResult<()> {
|
||||
};
|
||||
write_shadow_entry(&shadow_path, &shadow_entry)?;
|
||||
|
||||
// Step 14: Add to supplementary groups.
|
||||
// Step 14: Allocate subordinate UID/GID ranges for rootless containers.
|
||||
// Only done when the relevant file exists (matching GNU shadow-utils behavior).
|
||||
let subuid_path = opts.root.subuid_path();
|
||||
if subuid_path.exists()
|
||||
&& let Err(e) = append_subid_entry(
|
||||
&subuid_path,
|
||||
&opts.login,
|
||||
100_000 + u64::from(uid) * 65_536,
|
||||
65_536,
|
||||
)
|
||||
{
|
||||
uucore::show_error!("warning: failed to add subordinate UID range: {e}");
|
||||
}
|
||||
let subgid_path = opts.root.subgid_path();
|
||||
if subgid_path.exists()
|
||||
&& let Err(e) = append_subid_entry(
|
||||
&subgid_path,
|
||||
&opts.login,
|
||||
100_000 + u64::from(gid) * 65_536,
|
||||
65_536,
|
||||
)
|
||||
{
|
||||
uucore::show_error!("warning: failed to add subordinate GID range: {e}");
|
||||
}
|
||||
|
||||
// Step 15: Add to supplementary groups.
|
||||
if !opts.groups.is_empty() {
|
||||
add_to_supplementary_groups(opts, &group_path, &gshadow_path)?;
|
||||
}
|
||||
|
||||
// Step 15: Create home directory and copy skel.
|
||||
// Step 16: Create home directory and copy skel.
|
||||
if opts.create_home {
|
||||
let resolved_home = opts.root.resolve(&home_dir);
|
||||
let resolved_skel = opts.root.resolve(&opts.skel_dir);
|
||||
create_home_directory(&resolved_home, &resolved_skel, uid, gid)?;
|
||||
}
|
||||
|
||||
// Step 16: Invalidate nscd caches.
|
||||
// Step 17: Invalidate nscd caches.
|
||||
nscd::invalidate_cache("passwd");
|
||||
nscd::invalidate_cache("group");
|
||||
|
||||
// Step 18: Audit log.
|
||||
audit::log_user_event("ADD_USER", &opts.login, uid, true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -831,6 +861,52 @@ fn add_to_supplementary_groups(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subordinate ID allocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Append a subordinate ID entry to a subuid/subgid file.
|
||||
///
|
||||
/// Skips the write if the user already has an entry in the file.
|
||||
/// Uses file locking and atomic writes for crash safety.
|
||||
fn append_subid_entry(path: &Path, name: &str, start: u64, count: u64) -> UResult<()> {
|
||||
use shadow_core::subid::{self, SubIdEntry};
|
||||
|
||||
let lock = FileLock::acquire(path).map_err(|e| {
|
||||
UseraddError::CannotUpdatePasswd(format!("cannot lock {}: {e}", path.display()))
|
||||
})?;
|
||||
|
||||
let mut entries = match subid::read_subid_file(path) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
uucore::show_error!("warning: cannot read {}: {e}", path.display());
|
||||
return Err(UseraddError::CannotUpdatePasswd(format!(
|
||||
"cannot read {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
};
|
||||
|
||||
// Don't add a duplicate entry.
|
||||
if entries.iter().any(|e| e.name == name) {
|
||||
drop(lock);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
entries.push(SubIdEntry {
|
||||
name: name.to_string(),
|
||||
start,
|
||||
count,
|
||||
});
|
||||
|
||||
atomic::atomic_write(path, |f| subid::write_subid(&entries, f))
|
||||
.map_err(|e| UseraddError::CannotUpdatePasswd(format!("{e}")))?;
|
||||
|
||||
drop(lock);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Home directory creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::path::Path;
|
||||
use clap::{Arg, ArgAction, Command};
|
||||
use uucore::error::{UError, UResult};
|
||||
|
||||
use shadow_core::audit;
|
||||
use shadow_core::group::{self};
|
||||
use shadow_core::gshadow::{self};
|
||||
use shadow_core::lock::FileLock;
|
||||
@@ -97,12 +98,17 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
return Err(UserdelError::CantUpdatePasswd("Permission denied.".into()).into());
|
||||
}
|
||||
|
||||
// Read the user's home directory from /etc/passwd BEFORE removing the entry.
|
||||
// Read the user's home directory and UID from /etc/passwd BEFORE removing
|
||||
// the entry (needed for home removal and audit logging).
|
||||
let passwd_path = root.passwd_path();
|
||||
let pre_entries = passwd::read_passwd_file(&passwd_path)
|
||||
.map_err(|e| UserdelError::CantUpdatePasswd(format!("cannot read passwd: {e}")))?;
|
||||
let saved_uid = pre_entries
|
||||
.iter()
|
||||
.find(|e| e.name == *login)
|
||||
.map_or(0, |e| e.uid);
|
||||
let saved_home = if remove_home {
|
||||
let entries = passwd::read_passwd_file(&passwd_path)
|
||||
.map_err(|e| UserdelError::CantUpdatePasswd(format!("cannot read passwd: {e}")))?;
|
||||
entries
|
||||
pre_entries
|
||||
.iter()
|
||||
.find(|e| e.name == *login)
|
||||
.map(|e| e.home.clone())
|
||||
@@ -151,6 +157,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
nscd::invalidate_cache("passwd");
|
||||
nscd::invalidate_cache("group");
|
||||
|
||||
audit::log_user_event("DEL_USER", login, saved_uid, true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::path::Path;
|
||||
use clap::{Arg, ArgAction, Command};
|
||||
use uucore::error::{UError, UResult};
|
||||
|
||||
use shadow_core::audit;
|
||||
use shadow_core::group::{self};
|
||||
use shadow_core::lock::FileLock;
|
||||
use shadow_core::passwd::{self};
|
||||
@@ -151,11 +152,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
drop(lock);
|
||||
|
||||
// If the UID changed and the home directory was not explicitly moved,
|
||||
// chown the existing home directory to the new UID.
|
||||
// recursively chown the existing home directory to the new UID.
|
||||
// Only files owned by old_uid are touched (files owned by other users
|
||||
// are left alone, matching GNU shadow-utils behavior).
|
||||
if new_uid != old_uid && !home_is_changing && !home_for_chown.is_empty() {
|
||||
let home_path = root.resolve(&home_for_chown);
|
||||
if home_path.exists() {
|
||||
let _ = nix::unistd::chown(&home_path, Some(nix::unistd::Uid::from_raw(new_uid)), None);
|
||||
recursive_chown(&home_path, old_uid, new_uid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,9 +281,54 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
|
||||
nscd::invalidate_cache("passwd");
|
||||
nscd::invalidate_cache("group");
|
||||
|
||||
audit::log_user_event("MOD_USER", login, new_uid, true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively chown all files and directories under `path` that are owned by
|
||||
/// `old_uid` to `new_uid`. Files owned by other users are left untouched.
|
||||
///
|
||||
/// Uses `fchownat` with `AT_SYMLINK_NOFOLLOW` so symlinks themselves are
|
||||
/// re-owned without following them.
|
||||
fn recursive_chown(path: &Path, old_uid: u32, new_uid: u32) {
|
||||
use nix::fcntl::AtFlags;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
let entry_path = entry.path();
|
||||
if let Ok(meta) = std::fs::symlink_metadata(&entry_path) {
|
||||
if meta.uid() == old_uid {
|
||||
let _ = nix::unistd::fchownat(
|
||||
nix::fcntl::AT_FDCWD,
|
||||
&entry_path,
|
||||
Some(nix::unistd::Uid::from_raw(new_uid)),
|
||||
None,
|
||||
AtFlags::AT_SYMLINK_NOFOLLOW,
|
||||
);
|
||||
}
|
||||
if meta.is_dir() {
|
||||
recursive_chown(&entry_path, old_uid, new_uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also chown the directory itself.
|
||||
if let Ok(meta) = std::fs::symlink_metadata(path)
|
||||
&& meta.uid() == old_uid
|
||||
{
|
||||
let _ = nix::unistd::fchownat(
|
||||
nix::fcntl::AT_FDCWD,
|
||||
path,
|
||||
Some(nix::unistd::Uid::from_raw(new_uid)),
|
||||
None,
|
||||
AtFlags::AT_SYMLINK_NOFOLLOW,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn uu_app() -> Command {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user