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