Skip to main content

rusmes_server/
privileges.rs

1//! Privilege-drop helpers: chroot + setuid/setgid.
2//!
3//! Callers must invoke [`PrivilegeDrop::apply`] **after** all sockets are
4//! bound and TLS files are loaded into memory, and **before** any
5//! `tokio::spawn` calls.  Violating this ordering may leave ports unbound
6//! or TLS keys inaccessible post-drop.
7//!
8//! ## Platform support
9//!
10//! Only Linux performs the actual privilege drop.  All other targets (macOS,
11//! etc.) emit a `tracing::warn!` if drop was requested and return `Ok(())`.
12//!
13//! ## DNS after chroot
14//!
15//! Once the server enters a chroot, `/etc/resolv.conf` is no longer
16//! accessible.  Callers must pre-resolve any needed addresses before calling
17//! `apply`, or ensure `/etc/resolv.conf` (and related glibc NSS files) are
18//! staged under `runtime_dir` before startup.
19//!
20//! ## Bind-ordering limitation (current architecture)
21//!
22//! The current `rusmes-server/src/main.rs` architecture binds all listener
23//! sockets **inside** `tokio::spawn` closures (i.e., post-drop).  This means
24//! that when `run_as_user` / `run_as_group` / `chroot` are set, any privileged
25//! ports (<1024) will **fail to bind** after the privilege drop has been
26//! applied.  Operators using these fields should therefore either:
27//!
28//! - Use non-privileged ports (≥1024) and rely on a port-forwarding rule
29//!   (e.g. `nftables`, `iptables REDIRECT`, or `CAP_NET_BIND_SERVICE`), or
30//! - Wait for the planned listener-pre-bind refactor that hoists all
31//!   `TcpListener::bind` calls above the first `tokio::spawn`.
32//!
33//! This limitation is tracked in `crates/rusmes-server/TODO.md`.
34
35use std::path::PathBuf;
36
37/// Requested privilege drop.
38///
39/// All fields are optional — `None` / `false` means "no change" (back-compat).
40#[derive(Debug, Default)]
41pub struct PrivilegeDrop {
42    /// If `Some`, call `chroot(dir)` followed by `chdir("/")`.
43    /// The directory becomes the filesystem root for all subsequent I/O.
44    pub chroot_dir: Option<PathBuf>,
45    /// Target UID.  `None` = don't call `setuid`.
46    pub uid: Option<nix::unistd::Uid>,
47    /// Target GID.  `None` = don't call `setgid`.
48    pub gid: Option<nix::unistd::Gid>,
49}
50
51impl PrivilegeDrop {
52    /// Apply chroot + setgid + setuid in the correct order.
53    ///
54    /// Ordering: chroot first (requires root), then setgroups/setgid, then
55    /// setuid last (once root is dropped we cannot chroot or change group).
56    ///
57    /// # Bind-ordering caveat
58    ///
59    /// Because the current server architecture binds sockets inside spawned
60    /// tasks, calling `apply()` before the first `tokio::spawn` means that
61    /// sockets for privileged ports will be bound after root has been dropped.
62    /// See the module-level documentation for details and operator guidance.
63    #[cfg(target_os = "linux")]
64    pub fn apply(&self) -> anyhow::Result<()> {
65        use nix::unistd;
66
67        if let Some(dir) = &self.chroot_dir {
68            tracing::info!("chroot: entering {:?}", dir);
69            unistd::chroot(dir).map_err(|e| anyhow::anyhow!("chroot({:?}) failed: {e}", dir))?;
70            unistd::chdir("/")
71                .map_err(|e| anyhow::anyhow!("chdir('/') after chroot failed: {e}"))?;
72            tracing::info!("chroot: now rooted at {:?}", dir);
73        }
74
75        if let Some(gid) = self.gid {
76            // Clear supplementary groups, then set primary GID.
77            unistd::setgroups(&[gid])
78                .map_err(|e| anyhow::anyhow!("setgroups([{gid}]) failed: {e}"))?;
79            unistd::setgid(gid).map_err(|e| anyhow::anyhow!("setgid({gid}) failed: {e}"))?;
80            tracing::info!("privilege-drop: gid set to {gid}");
81        }
82
83        if let Some(uid) = self.uid {
84            unistd::setuid(uid).map_err(|e| anyhow::anyhow!("setuid({uid}) failed: {e}"))?;
85            tracing::info!("privilege-drop: uid set to {uid}");
86        }
87
88        Ok(())
89    }
90
91    /// No-op implementation for non-Linux platforms.
92    ///
93    /// Emits a `tracing::warn!` if any non-default drop was requested,
94    /// then returns `Ok(())` so callers behave identically across platforms.
95    #[cfg(not(target_os = "linux"))]
96    pub fn apply(&self) -> anyhow::Result<()> {
97        if self.chroot_dir.is_some() || self.uid.is_some() || self.gid.is_some() {
98            tracing::warn!(
99                "privilege-drop requested (chroot={:?}, uid={:?}, gid={:?}) \
100                 but skipped: only supported on Linux",
101                self.chroot_dir,
102                self.uid,
103                self.gid
104            );
105        }
106        Ok(())
107    }
108}
109
110/// Resolve a username to a UID using the system user database.
111///
112/// Returns `None` if `name` is empty (no-op case).
113/// Returns `Err` if the name is non-empty but not found in the system database.
114///
115/// # Examples
116///
117/// ```rust,no_run
118/// use rusmes_server::privileges::resolve_uid;
119///
120/// // Empty string → no change (None)
121/// assert!(resolve_uid("").unwrap().is_none());
122///
123/// // Non-existent user → Err
124/// assert!(resolve_uid("__no_such_user__").is_err());
125/// ```
126pub fn resolve_uid(name: &str) -> anyhow::Result<Option<nix::unistd::Uid>> {
127    if name.is_empty() {
128        return Ok(None);
129    }
130    let user = nix::unistd::User::from_name(name)
131        .map_err(|e| anyhow::anyhow!("lookup user {:?} failed: {e}", name))?
132        .ok_or_else(|| anyhow::anyhow!("user {:?} not found in system database", name))?;
133    Ok(Some(user.uid))
134}
135
136/// Resolve a group name to a GID using the system group database.
137///
138/// Returns `None` if `name` is empty (no-op case).
139/// Returns `Err` if the name is non-empty but not found in the system database.
140///
141/// # Examples
142///
143/// ```rust,no_run
144/// use rusmes_server::privileges::resolve_gid;
145///
146/// // Empty string → no change (None)
147/// assert!(resolve_gid("").unwrap().is_none());
148///
149/// // Non-existent group → Err
150/// assert!(resolve_gid("__no_such_group__").is_err());
151/// ```
152pub fn resolve_gid(name: &str) -> anyhow::Result<Option<nix::unistd::Gid>> {
153    if name.is_empty() {
154        return Ok(None);
155    }
156    let group = nix::unistd::Group::from_name(name)
157        .map_err(|e| anyhow::anyhow!("lookup group {:?} failed: {e}", name))?
158        .ok_or_else(|| anyhow::anyhow!("group {:?} not found in system database", name))?;
159    Ok(Some(group.gid))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_privilege_drop_noop_when_no_user_set() {
168        let drop = PrivilegeDrop::default();
169        drop.apply().expect("no-op drop must not fail");
170    }
171
172    #[cfg(target_os = "macos")]
173    #[test]
174    fn test_privilege_drop_warns_on_macos() {
175        // On macOS, apply() must succeed (no-op) even when non-trivial values
176        // are requested — it can only emit a warning, not fail.
177        let drop = PrivilegeDrop {
178            chroot_dir: Some(std::path::PathBuf::from("/tmp")),
179            uid: Some(nix::unistd::Uid::from_raw(99)),
180            gid: None,
181        };
182        assert!(drop.apply().is_ok(), "macOS path must be Ok()");
183    }
184
185    #[test]
186    fn test_resolve_uid_empty_returns_none() {
187        assert!(resolve_uid("").unwrap().is_none());
188    }
189
190    #[test]
191    fn test_resolve_gid_empty_returns_none() {
192        assert!(resolve_gid("").unwrap().is_none());
193    }
194
195    #[test]
196    fn test_resolve_uid_nonexistent_returns_err() {
197        assert!(resolve_uid("__nonexistent_rusmes_user__").is_err());
198    }
199
200    #[test]
201    fn test_resolve_gid_nonexistent_returns_err() {
202        assert!(resolve_gid("__nonexistent_rusmes_group__").is_err());
203    }
204}