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::volume::MOUNT_USERS;
12use super::{DockerClient, DockerError};
13use serde::{Deserialize, Serialize};
14
15/// User persistence store directory inside the container.
16///
17/// Format: one JSON file per user with strict permissions (root-owned, 0700 dir, 0600 files).
18/// Stored on a managed Docker volume mounted at this path.
19const USERS_STORE_DIR: &str = MOUNT_USERS;
20
21/// Information about a container user
22#[derive(Debug, Clone, PartialEq)]
23pub struct UserInfo {
24    /// Username
25    pub username: String,
26    /// User ID (uid)
27    pub uid: u32,
28    /// Home directory path
29    pub home: String,
30    /// Login shell
31    pub shell: String,
32    /// Whether the account is locked
33    pub locked: bool,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37struct PersistedUserRecord {
38    username: String,
39    password_hash: String,
40    locked: bool,
41}
42
43/// Create a new user in the container
44///
45/// Creates a user with a home directory and /bin/bash shell.
46/// Returns an error if the user already exists.
47///
48/// # Arguments
49/// * `client` - Docker client
50/// * `container` - Container name or ID
51/// * `username` - Username to create
52///
53/// # Example
54/// ```ignore
55/// create_user(&client, "opencode-cloud-sandbox", "admin").await?;
56/// ```
57pub async fn create_user(
58    client: &DockerClient,
59    container: &str,
60    username: &str,
61) -> Result<(), DockerError> {
62    let cmd = vec!["useradd", "-m", "-s", "/bin/bash", username];
63
64    let exit_code = exec_command_exit_code(client, container, cmd).await?;
65
66    if exit_code != 0 {
67        // Check if user already exists
68        if user_exists(client, container, username).await? {
69            return Err(DockerError::Container(format!(
70                "User '{username}' already exists"
71            )));
72        }
73        return Err(DockerError::Container(format!(
74            "Failed to create user '{username}': useradd returned exit code {exit_code}"
75        )));
76    }
77
78    Ok(())
79}
80
81/// Set or change a user's password
82///
83/// Uses chpasswd with stdin for secure password setting.
84/// The password never appears in command arguments or process list.
85///
86/// # Arguments
87/// * `client` - Docker client
88/// * `container` - Container name or ID
89/// * `username` - Username to set password for
90/// * `password` - New password (will be written to stdin)
91///
92/// # Security
93/// The password is written directly to chpasswd's stdin, never appearing
94/// in command arguments, environment variables, or process listings.
95///
96/// # Example
97/// ```ignore
98/// set_user_password(&client, "opencode-cloud-sandbox", "admin", "secret123").await?;
99/// ```
100pub async fn set_user_password(
101    client: &DockerClient,
102    container: &str,
103    username: &str,
104    password: &str,
105) -> Result<(), DockerError> {
106    let cmd = vec!["chpasswd"];
107    let stdin_data = format!("{username}:{password}\n");
108
109    exec_command_with_stdin(client, container, cmd, &stdin_data).await?;
110
111    Ok(())
112}
113
114/// Set a user's password hash directly (no plaintext required)
115///
116/// Uses `usermod -p` with a precomputed shadow hash.
117async fn set_user_password_hash(
118    client: &DockerClient,
119    container: &str,
120    username: &str,
121    password_hash: &str,
122) -> Result<(), DockerError> {
123    if password_hash.is_empty() {
124        return Ok(());
125    }
126
127    let cmd = vec!["usermod", "-p", password_hash, username];
128    let exit_code = exec_command_exit_code(client, container, cmd).await?;
129
130    if exit_code != 0 {
131        return Err(DockerError::Container(format!(
132            "Failed to set password hash for '{username}': usermod returned exit code {exit_code}"
133        )));
134    }
135
136    Ok(())
137}
138
139/// Check if a user exists in the container
140///
141/// # Arguments
142/// * `client` - Docker client
143/// * `container` - Container name or ID
144/// * `username` - Username to check
145///
146/// # Returns
147/// `true` if the user exists, `false` otherwise
148pub async fn user_exists(
149    client: &DockerClient,
150    container: &str,
151    username: &str,
152) -> Result<bool, DockerError> {
153    let cmd = vec!["id", "-u", username];
154    let exit_code = exec_command_exit_code(client, container, cmd).await?;
155
156    Ok(exit_code == 0)
157}
158
159/// Lock a user account (disable password authentication)
160///
161/// Uses `passwd -l` to lock the account. The user will not be able
162/// to log in using password authentication.
163///
164/// # Arguments
165/// * `client` - Docker client
166/// * `container` - Container name or ID
167/// * `username` - Username to lock
168pub async fn lock_user(
169    client: &DockerClient,
170    container: &str,
171    username: &str,
172) -> Result<(), DockerError> {
173    let cmd = vec!["passwd", "-l", username];
174    let exit_code = exec_command_exit_code(client, container, cmd).await?;
175
176    if exit_code != 0 {
177        return Err(DockerError::Container(format!(
178            "Failed to lock user '{username}': passwd returned exit code {exit_code}"
179        )));
180    }
181
182    Ok(())
183}
184
185/// Unlock a user account (re-enable password authentication)
186///
187/// Uses `passwd -u` to unlock the account.
188///
189/// # Arguments
190/// * `client` - Docker client
191/// * `container` - Container name or ID
192/// * `username` - Username to unlock
193pub async fn unlock_user(
194    client: &DockerClient,
195    container: &str,
196    username: &str,
197) -> Result<(), DockerError> {
198    let cmd = vec!["passwd", "-u", username];
199    let exit_code = exec_command_exit_code(client, container, cmd).await?;
200
201    if exit_code != 0 {
202        return Err(DockerError::Container(format!(
203            "Failed to unlock user '{username}': passwd returned exit code {exit_code}"
204        )));
205    }
206
207    Ok(())
208}
209
210/// Delete a user from the container
211///
212/// Uses `userdel -r` to remove the user and their home directory.
213///
214/// # Arguments
215/// * `client` - Docker client
216/// * `container` - Container name or ID
217/// * `username` - Username to delete
218pub async fn delete_user(
219    client: &DockerClient,
220    container: &str,
221    username: &str,
222) -> Result<(), DockerError> {
223    let cmd = vec!["userdel", "-r", username];
224    let exit_code = exec_command_exit_code(client, container, cmd).await?;
225
226    if exit_code != 0 {
227        // Check if user doesn't exist
228        if !user_exists(client, container, username).await? {
229            return Err(DockerError::Container(format!(
230                "User '{username}' does not exist"
231            )));
232        }
233        return Err(DockerError::Container(format!(
234            "Failed to delete user '{username}': userdel returned exit code {exit_code}"
235        )));
236    }
237
238    Ok(())
239}
240
241/// List users in the container with home directories
242///
243/// Returns users that have home directories under /home/.
244/// Excludes system users.
245///
246/// # Arguments
247/// * `client` - Docker client
248/// * `container` - Container name or ID
249pub async fn list_users(
250    client: &DockerClient,
251    container: &str,
252) -> Result<Vec<UserInfo>, DockerError> {
253    // Get all users with home directories in /home
254    let cmd = vec!["sh", "-c", "getent passwd | grep '/home/'"];
255    let output = exec_command(client, container, cmd).await?;
256
257    let mut users = Vec::new();
258
259    for line in output.lines() {
260        if let Some(info) = parse_passwd_line(line) {
261            // Check if user is locked
262            let locked = is_user_locked(client, container, &info.username).await?;
263
264            users.push(UserInfo {
265                username: info.username,
266                uid: info.uid,
267                home: info.home,
268                shell: info.shell,
269                locked,
270            });
271        }
272    }
273
274    Ok(users)
275}
276
277/// Persist a user's credentials and lock state to the managed volume.
278///
279/// Stores the shadow hash (not plaintext) and lock status in a JSON record.
280pub async fn persist_user(
281    client: &DockerClient,
282    container: &str,
283    username: &str,
284) -> Result<(), DockerError> {
285    ensure_users_store_dir(client, container).await?;
286
287    let shadow_hash = get_user_shadow_hash(client, container, username).await?;
288    let locked = is_user_locked(client, container, username).await?;
289
290    let record = PersistedUserRecord {
291        username: username.to_string(),
292        password_hash: shadow_hash,
293        locked,
294    };
295
296    write_user_record(client, container, &record).await?;
297    Ok(())
298}
299
300/// Remove a persisted user record from the managed volume.
301pub async fn remove_persisted_user(
302    client: &DockerClient,
303    container: &str,
304    username: &str,
305) -> Result<(), DockerError> {
306    let record_path = user_record_path(username);
307    let cmd_string = format!("rm -f {record_path}");
308    let cmd = vec!["sh", "-c", cmd_string.as_str()];
309    exec_command(client, container, cmd).await?;
310    Ok(())
311}
312
313/// Restore users from the persisted store into the container.
314///
315/// Returns the list of usernames restored or updated.
316pub async fn restore_persisted_users(
317    client: &DockerClient,
318    container: &str,
319) -> Result<Vec<String>, DockerError> {
320    let records = read_user_records(client, container).await?;
321    if records.is_empty() {
322        let users = list_users(client, container).await?;
323        let mut persisted = Vec::new();
324        for user in users {
325            persist_user(client, container, &user.username).await?;
326            persisted.push(user.username);
327        }
328        return Ok(persisted);
329    }
330
331    let mut restored = Vec::new();
332
333    for record in records {
334        if !user_exists(client, container, &record.username).await? {
335            create_user(client, container, &record.username).await?;
336        }
337
338        set_user_password_hash(client, container, &record.username, &record.password_hash).await?;
339
340        if record.locked {
341            lock_user(client, container, &record.username).await?;
342        } else {
343            unlock_user(client, container, &record.username).await?;
344        }
345
346        restored.push(record.username);
347    }
348
349    Ok(restored)
350}
351
352/// Check if a user account is locked
353///
354/// Uses `passwd -S` to get account status.
355/// Returns true if the status starts with "L" (locked).
356async fn is_user_locked(
357    client: &DockerClient,
358    container: &str,
359    username: &str,
360) -> Result<bool, DockerError> {
361    let cmd = vec!["passwd", "-S", username];
362    let output = exec_command(client, container, cmd).await?;
363
364    // passwd -S output format: "username L/P/NP ... "
365    // L = locked, P = password set, NP = no password
366    let parts: Vec<&str> = output.split_whitespace().collect();
367    if parts.len() >= 2 {
368        return Ok(parts[1] == "L");
369    }
370
371    Ok(false)
372}
373
374async fn ensure_users_store_dir(client: &DockerClient, container: &str) -> Result<(), DockerError> {
375    let cmd_string = format!("install -d -m 700 {USERS_STORE_DIR}");
376    let cmd = vec!["sh", "-c", cmd_string.as_str()];
377    exec_command(client, container, cmd).await?;
378    Ok(())
379}
380
381async fn get_user_shadow_hash(
382    client: &DockerClient,
383    container: &str,
384    username: &str,
385) -> Result<String, DockerError> {
386    let output = exec_command(client, container, vec!["getent", "shadow", username]).await?;
387    let line = output.lines().next().unwrap_or("").trim();
388    if line.is_empty() {
389        return Err(DockerError::Container(format!(
390            "Failed to read shadow entry for '{username}'"
391        )));
392    }
393
394    let fields: Vec<&str> = line.split(':').collect();
395    if fields.len() < 2 {
396        return Err(DockerError::Container(format!(
397            "Invalid shadow entry for '{username}'"
398        )));
399    }
400
401    Ok(fields[1].to_string())
402}
403
404async fn read_user_records(
405    client: &DockerClient,
406    container: &str,
407) -> Result<Vec<PersistedUserRecord>, DockerError> {
408    let list_command =
409        format!("if [ -d {USERS_STORE_DIR} ]; then ls -1 {USERS_STORE_DIR}/*.json 2>/dev/null; fi");
410    let list_cmd = vec!["sh", "-c", list_command.as_str()];
411    let output = exec_command(client, container, list_cmd).await?;
412    let mut records = Vec::new();
413
414    for path in output
415        .lines()
416        .map(str::trim)
417        .filter(|line| !line.is_empty())
418    {
419        let contents = exec_command(client, container, vec!["cat", path]).await?;
420        let record: PersistedUserRecord = serde_json::from_str(&contents).map_err(|e| {
421            DockerError::Container(format!("Failed to parse user record {path}: {e}"))
422        })?;
423        records.push(record);
424    }
425
426    Ok(records)
427}
428
429fn user_record_path(username: &str) -> String {
430    format!("{USERS_STORE_DIR}/{username}.json")
431}
432
433async fn write_user_record(
434    client: &DockerClient,
435    container: &str,
436    record: &PersistedUserRecord,
437) -> Result<(), DockerError> {
438    let payload =
439        serde_json::to_string_pretty(record).map_err(|e| DockerError::Container(e.to_string()))?;
440    let record_path = user_record_path(&record.username);
441    let write_command =
442        format!("install -d -m 700 {USERS_STORE_DIR} && umask 077 && cat > {record_path}");
443    let cmd = vec!["sh", "-c", write_command.as_str()];
444    exec_command_with_stdin(client, container, cmd, &payload).await?;
445    Ok(())
446}
447
448/// Parsed user info from /etc/passwd line (intermediate struct)
449struct ParsedUser {
450    username: String,
451    uid: u32,
452    home: String,
453    shell: String,
454}
455
456/// Parse a line from /etc/passwd
457///
458/// Format: username:x:uid:gid:gecos:home:shell
459fn parse_passwd_line(line: &str) -> Option<ParsedUser> {
460    let fields: Vec<&str> = line.split(':').collect();
461    if fields.len() < 7 {
462        return None;
463    }
464
465    let uid = fields[2].parse::<u32>().ok()?;
466
467    Some(ParsedUser {
468        username: fields[0].to_string(),
469        uid,
470        home: fields[5].to_string(),
471        shell: fields[6].to_string(),
472    })
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_parse_passwd_line_valid() {
481        let line = "admin:x:1001:1001:Admin User:/home/admin:/bin/bash";
482        let parsed = parse_passwd_line(line).unwrap();
483        assert_eq!(parsed.username, "admin");
484        assert_eq!(parsed.uid, 1001);
485        assert_eq!(parsed.home, "/home/admin");
486        assert_eq!(parsed.shell, "/bin/bash");
487    }
488
489    #[test]
490    fn test_parse_passwd_line_minimal() {
491        let line = "user:x:1000:1000::/home/user:/bin/sh";
492        let parsed = parse_passwd_line(line).unwrap();
493        assert_eq!(parsed.username, "user");
494        assert_eq!(parsed.uid, 1000);
495        assert_eq!(parsed.home, "/home/user");
496        assert_eq!(parsed.shell, "/bin/sh");
497    }
498
499    #[test]
500    fn test_parse_passwd_line_invalid() {
501        assert!(parse_passwd_line("invalid").is_none());
502        assert!(parse_passwd_line("too:few:fields").is_none());
503        assert!(parse_passwd_line("user:x:not_a_number:1000::/home/user:/bin/bash").is_none());
504    }
505
506    #[test]
507    fn test_user_info_struct() {
508        let info = UserInfo {
509            username: "admin".to_string(),
510            uid: 1001,
511            home: "/home/admin".to_string(),
512            shell: "/bin/bash".to_string(),
513            locked: false,
514        };
515        assert_eq!(info.username, "admin");
516        assert!(!info.locked);
517    }
518
519    #[test]
520    fn test_user_info_equality() {
521        let info1 = UserInfo {
522            username: "admin".to_string(),
523            uid: 1001,
524            home: "/home/admin".to_string(),
525            shell: "/bin/bash".to_string(),
526            locked: false,
527        };
528        let info2 = info1.clone();
529        assert_eq!(info1, info2);
530    }
531
532    #[test]
533    fn test_user_info_debug() {
534        let info = UserInfo {
535            username: "test".to_string(),
536            uid: 1000,
537            home: "/home/test".to_string(),
538            shell: "/bin/bash".to_string(),
539            locked: true,
540        };
541        let debug = format!("{info:?}");
542        assert!(debug.contains("test"));
543        assert!(debug.contains("1000"));
544        assert!(debug.contains("locked: true"));
545    }
546}