Skip to main content

opencode_cloud_core/docker/
users.rs

1//! Container user management operations
2//!
3//! This module provides functions to manage Linux system users inside
4//! the running Docker container. opencode authenticates against PAM,
5//! so opencode-cloud must manage system users in the container.
6//!
7//! Security note: Passwords are never passed as command arguments.
8//! Instead, we use `chpasswd` which reads from stdin.
9
10use super::exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
11use super::{DockerClient, DockerError};
12
13/// Information about a container user
14#[derive(Debug, Clone, PartialEq)]
15pub struct UserInfo {
16    /// Username
17    pub username: String,
18    /// User ID (uid)
19    pub uid: u32,
20    /// Home directory path
21    pub home: String,
22    /// Login shell
23    pub shell: String,
24    /// Whether the account is locked
25    pub locked: bool,
26}
27
28/// Create a new user in the container
29///
30/// Creates a user with a home directory and /bin/bash shell.
31/// Returns an error if the user already exists.
32///
33/// # Arguments
34/// * `client` - Docker client
35/// * `container` - Container name or ID
36/// * `username` - Username to create
37///
38/// # Example
39/// ```ignore
40/// create_user(&client, "opencode-cloud", "admin").await?;
41/// ```
42pub async fn create_user(
43    client: &DockerClient,
44    container: &str,
45    username: &str,
46) -> Result<(), DockerError> {
47    let cmd = vec!["useradd", "-m", "-s", "/bin/bash", username];
48
49    let exit_code = exec_command_exit_code(client, container, cmd).await?;
50
51    if exit_code != 0 {
52        // Check if user already exists
53        if user_exists(client, container, username).await? {
54            return Err(DockerError::Container(format!(
55                "User '{username}' already exists"
56            )));
57        }
58        return Err(DockerError::Container(format!(
59            "Failed to create user '{username}': useradd returned exit code {exit_code}"
60        )));
61    }
62
63    Ok(())
64}
65
66/// Set or change a user's password
67///
68/// Uses chpasswd with stdin for secure password setting.
69/// The password never appears in command arguments or process list.
70///
71/// # Arguments
72/// * `client` - Docker client
73/// * `container` - Container name or ID
74/// * `username` - Username to set password for
75/// * `password` - New password (will be written to stdin)
76///
77/// # Security
78/// The password is written directly to chpasswd's stdin, never appearing
79/// in command arguments, environment variables, or process listings.
80///
81/// # Example
82/// ```ignore
83/// set_user_password(&client, "opencode-cloud", "admin", "secret123").await?;
84/// ```
85pub async fn set_user_password(
86    client: &DockerClient,
87    container: &str,
88    username: &str,
89    password: &str,
90) -> Result<(), DockerError> {
91    let cmd = vec!["chpasswd"];
92    let stdin_data = format!("{username}:{password}\n");
93
94    exec_command_with_stdin(client, container, cmd, &stdin_data).await?;
95
96    Ok(())
97}
98
99/// Check if a user exists in the container
100///
101/// # Arguments
102/// * `client` - Docker client
103/// * `container` - Container name or ID
104/// * `username` - Username to check
105///
106/// # Returns
107/// `true` if the user exists, `false` otherwise
108pub async fn user_exists(
109    client: &DockerClient,
110    container: &str,
111    username: &str,
112) -> Result<bool, DockerError> {
113    let cmd = vec!["id", "-u", username];
114    let exit_code = exec_command_exit_code(client, container, cmd).await?;
115
116    Ok(exit_code == 0)
117}
118
119/// Lock a user account (disable password authentication)
120///
121/// Uses `passwd -l` to lock the account. The user will not be able
122/// to log in using password authentication.
123///
124/// # Arguments
125/// * `client` - Docker client
126/// * `container` - Container name or ID
127/// * `username` - Username to lock
128pub async fn lock_user(
129    client: &DockerClient,
130    container: &str,
131    username: &str,
132) -> Result<(), DockerError> {
133    let cmd = vec!["passwd", "-l", username];
134    let exit_code = exec_command_exit_code(client, container, cmd).await?;
135
136    if exit_code != 0 {
137        return Err(DockerError::Container(format!(
138            "Failed to lock user '{username}': passwd returned exit code {exit_code}"
139        )));
140    }
141
142    Ok(())
143}
144
145/// Unlock a user account (re-enable password authentication)
146///
147/// Uses `passwd -u` to unlock the account.
148///
149/// # Arguments
150/// * `client` - Docker client
151/// * `container` - Container name or ID
152/// * `username` - Username to unlock
153pub async fn unlock_user(
154    client: &DockerClient,
155    container: &str,
156    username: &str,
157) -> Result<(), DockerError> {
158    let cmd = vec!["passwd", "-u", username];
159    let exit_code = exec_command_exit_code(client, container, cmd).await?;
160
161    if exit_code != 0 {
162        return Err(DockerError::Container(format!(
163            "Failed to unlock user '{username}': passwd returned exit code {exit_code}"
164        )));
165    }
166
167    Ok(())
168}
169
170/// Delete a user from the container
171///
172/// Uses `userdel -r` to remove the user and their home directory.
173///
174/// # Arguments
175/// * `client` - Docker client
176/// * `container` - Container name or ID
177/// * `username` - Username to delete
178pub async fn delete_user(
179    client: &DockerClient,
180    container: &str,
181    username: &str,
182) -> Result<(), DockerError> {
183    let cmd = vec!["userdel", "-r", username];
184    let exit_code = exec_command_exit_code(client, container, cmd).await?;
185
186    if exit_code != 0 {
187        // Check if user doesn't exist
188        if !user_exists(client, container, username).await? {
189            return Err(DockerError::Container(format!(
190                "User '{username}' does not exist"
191            )));
192        }
193        return Err(DockerError::Container(format!(
194            "Failed to delete user '{username}': userdel returned exit code {exit_code}"
195        )));
196    }
197
198    Ok(())
199}
200
201/// List users in the container with home directories
202///
203/// Returns users that have home directories under /home/.
204/// Excludes system users.
205///
206/// # Arguments
207/// * `client` - Docker client
208/// * `container` - Container name or ID
209pub async fn list_users(
210    client: &DockerClient,
211    container: &str,
212) -> Result<Vec<UserInfo>, DockerError> {
213    // Get all users with home directories in /home
214    let cmd = vec!["sh", "-c", "getent passwd | grep '/home/'"];
215    let output = exec_command(client, container, cmd).await?;
216
217    let mut users = Vec::new();
218
219    for line in output.lines() {
220        if let Some(info) = parse_passwd_line(line) {
221            // Check if user is locked
222            let locked = is_user_locked(client, container, &info.username).await?;
223
224            users.push(UserInfo {
225                username: info.username,
226                uid: info.uid,
227                home: info.home,
228                shell: info.shell,
229                locked,
230            });
231        }
232    }
233
234    Ok(users)
235}
236
237/// Check if a user account is locked
238///
239/// Uses `passwd -S` to get account status.
240/// Returns true if the status starts with "L" (locked).
241async fn is_user_locked(
242    client: &DockerClient,
243    container: &str,
244    username: &str,
245) -> Result<bool, DockerError> {
246    let cmd = vec!["passwd", "-S", username];
247    let output = exec_command(client, container, cmd).await?;
248
249    // passwd -S output format: "username L/P/NP ... "
250    // L = locked, P = password set, NP = no password
251    let parts: Vec<&str> = output.split_whitespace().collect();
252    if parts.len() >= 2 {
253        return Ok(parts[1] == "L");
254    }
255
256    Ok(false)
257}
258
259/// Parsed user info from /etc/passwd line (intermediate struct)
260struct ParsedUser {
261    username: String,
262    uid: u32,
263    home: String,
264    shell: String,
265}
266
267/// Parse a line from /etc/passwd
268///
269/// Format: username:x:uid:gid:gecos:home:shell
270fn parse_passwd_line(line: &str) -> Option<ParsedUser> {
271    let fields: Vec<&str> = line.split(':').collect();
272    if fields.len() < 7 {
273        return None;
274    }
275
276    let uid = fields[2].parse::<u32>().ok()?;
277
278    Some(ParsedUser {
279        username: fields[0].to_string(),
280        uid,
281        home: fields[5].to_string(),
282        shell: fields[6].to_string(),
283    })
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_parse_passwd_line_valid() {
292        let line = "admin:x:1001:1001:Admin User:/home/admin:/bin/bash";
293        let parsed = parse_passwd_line(line).unwrap();
294        assert_eq!(parsed.username, "admin");
295        assert_eq!(parsed.uid, 1001);
296        assert_eq!(parsed.home, "/home/admin");
297        assert_eq!(parsed.shell, "/bin/bash");
298    }
299
300    #[test]
301    fn test_parse_passwd_line_minimal() {
302        let line = "user:x:1000:1000::/home/user:/bin/sh";
303        let parsed = parse_passwd_line(line).unwrap();
304        assert_eq!(parsed.username, "user");
305        assert_eq!(parsed.uid, 1000);
306        assert_eq!(parsed.home, "/home/user");
307        assert_eq!(parsed.shell, "/bin/sh");
308    }
309
310    #[test]
311    fn test_parse_passwd_line_invalid() {
312        assert!(parse_passwd_line("invalid").is_none());
313        assert!(parse_passwd_line("too:few:fields").is_none());
314        assert!(parse_passwd_line("user:x:not_a_number:1000::/home/user:/bin/bash").is_none());
315    }
316
317    #[test]
318    fn test_user_info_struct() {
319        let info = UserInfo {
320            username: "admin".to_string(),
321            uid: 1001,
322            home: "/home/admin".to_string(),
323            shell: "/bin/bash".to_string(),
324            locked: false,
325        };
326        assert_eq!(info.username, "admin");
327        assert!(!info.locked);
328    }
329
330    #[test]
331    fn test_user_info_equality() {
332        let info1 = UserInfo {
333            username: "admin".to_string(),
334            uid: 1001,
335            home: "/home/admin".to_string(),
336            shell: "/bin/bash".to_string(),
337            locked: false,
338        };
339        let info2 = info1.clone();
340        assert_eq!(info1, info2);
341    }
342
343    #[test]
344    fn test_user_info_debug() {
345        let info = UserInfo {
346            username: "test".to_string(),
347            uid: 1000,
348            home: "/home/test".to_string(),
349            shell: "/bin/bash".to_string(),
350            locked: true,
351        };
352        let debug = format!("{info:?}");
353        assert!(debug.contains("test"));
354        assert!(debug.contains("1000"));
355        assert!(debug.contains("locked: true"));
356    }
357}