@opencode-cloud/core 1.0.8 → 3.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,357 @@
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
+
10
+ use super::exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
11
+ use super::{DockerClient, DockerError};
12
+
13
+ /// Information about a container user
14
+ #[derive(Debug, Clone, PartialEq)]
15
+ pub 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
+ /// ```
42
+ pub 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
+ /// ```
85
+ pub 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
108
+ pub 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
128
+ pub 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
153
+ pub 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
178
+ pub 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
209
+ pub 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).
241
+ async 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)
260
+ struct 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
270
+ fn 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)]
287
+ mod 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
+ }
@@ -0,0 +1,95 @@
1
+ //! Docker image version detection
2
+ //!
3
+ //! Reads version information from Docker image labels.
4
+
5
+ use super::{DockerClient, DockerError};
6
+
7
+ /// Version label key in Docker image
8
+ pub const VERSION_LABEL: &str = "org.opencode-cloud.version";
9
+
10
+ /// Get version from image label
11
+ ///
12
+ /// Returns None if image doesn't exist or has no version label.
13
+ /// Version label is set during automated builds; local builds have "dev".
14
+ pub async fn get_image_version(
15
+ client: &DockerClient,
16
+ image_name: &str,
17
+ ) -> Result<Option<String>, DockerError> {
18
+ let inspect = match client.inner().inspect_image(image_name).await {
19
+ Ok(info) => info,
20
+ Err(bollard::errors::Error::DockerResponseServerError {
21
+ status_code: 404, ..
22
+ }) => {
23
+ return Ok(None);
24
+ }
25
+ Err(e) => {
26
+ return Err(DockerError::Connection(format!(
27
+ "Failed to inspect image: {e}"
28
+ )));
29
+ }
30
+ };
31
+
32
+ // Extract version from labels
33
+ let version = inspect
34
+ .config
35
+ .and_then(|c| c.labels)
36
+ .and_then(|labels| labels.get(VERSION_LABEL).cloned());
37
+
38
+ Ok(version)
39
+ }
40
+
41
+ /// CLI version from Cargo.toml
42
+ pub fn get_cli_version() -> &'static str {
43
+ env!("CARGO_PKG_VERSION")
44
+ }
45
+
46
+ /// Compare versions and determine if they match
47
+ ///
48
+ /// Returns true if versions are compatible (same or dev build).
49
+ /// Returns false if versions differ and user should be prompted.
50
+ pub fn versions_compatible(cli_version: &str, image_version: Option<&str>) -> bool {
51
+ match image_version {
52
+ None => true, // No version label = local build, assume compatible
53
+ Some("dev") => true, // Dev build, assume compatible
54
+ Some(img_ver) => cli_version == img_ver,
55
+ }
56
+ }
57
+
58
+ #[cfg(test)]
59
+ mod tests {
60
+ use super::*;
61
+
62
+ #[test]
63
+ fn test_versions_compatible_none() {
64
+ assert!(versions_compatible("1.0.8", None));
65
+ }
66
+
67
+ #[test]
68
+ fn test_versions_compatible_dev() {
69
+ assert!(versions_compatible("1.0.8", Some("dev")));
70
+ }
71
+
72
+ #[test]
73
+ fn test_versions_compatible_same() {
74
+ assert!(versions_compatible("1.0.8", Some("1.0.8")));
75
+ }
76
+
77
+ #[test]
78
+ fn test_versions_compatible_different() {
79
+ assert!(!versions_compatible("1.0.8", Some("1.0.7")));
80
+ }
81
+
82
+ #[test]
83
+ fn test_get_cli_version_format() {
84
+ let version = get_cli_version();
85
+ // Should be semver format
86
+ assert!(version.contains('.'));
87
+ let parts: Vec<&str> = version.split('.').collect();
88
+ assert_eq!(parts.len(), 3);
89
+ }
90
+
91
+ #[test]
92
+ fn test_version_label_constant() {
93
+ assert_eq!(VERSION_LABEL, "org.opencode-cloud.version");
94
+ }
95
+ }
@@ -0,0 +1,61 @@
1
+ //! Host-specific error types
2
+ //!
3
+ //! Errors that can occur during remote host operations.
4
+
5
+ use thiserror::Error;
6
+
7
+ /// Errors that can occur during host operations
8
+ #[derive(Error, Debug)]
9
+ pub enum HostError {
10
+ /// Failed to spawn SSH process
11
+ #[error("Failed to spawn SSH: {0}")]
12
+ SshSpawn(String),
13
+
14
+ /// SSH connection failed
15
+ #[error("SSH connection failed: {0}")]
16
+ ConnectionFailed(String),
17
+
18
+ /// SSH authentication failed (key not in agent, passphrase needed)
19
+ #[error("SSH authentication failed. Ensure your key is loaded: ssh-add {}", .key_hint.as_deref().unwrap_or("~/.ssh/id_rsa"))]
20
+ AuthFailed { key_hint: Option<String> },
21
+
22
+ /// Host not found in hosts.json
23
+ #[error("Host not found: {0}")]
24
+ NotFound(String),
25
+
26
+ /// Host already exists
27
+ #[error("Host already exists: {0}")]
28
+ AlreadyExists(String),
29
+
30
+ /// Failed to allocate local port for tunnel
31
+ #[error("Failed to allocate local port: {0}")]
32
+ PortAllocation(String),
33
+
34
+ /// Failed to load hosts file
35
+ #[error("Failed to load hosts file: {0}")]
36
+ LoadFailed(String),
37
+
38
+ /// Failed to save hosts file
39
+ #[error("Failed to save hosts file: {0}")]
40
+ SaveFailed(String),
41
+
42
+ /// Invalid host configuration
43
+ #[error("Invalid host configuration: {0}")]
44
+ InvalidConfig(String),
45
+
46
+ /// Tunnel connection timed out
47
+ #[error("SSH tunnel connection timed out after {0} attempts")]
48
+ TunnelTimeout(u32),
49
+
50
+ /// Remote Docker not available
51
+ #[error("Docker not available on remote host: {0}")]
52
+ RemoteDockerUnavailable(String),
53
+
54
+ /// Failed to read SSH config
55
+ #[error("Failed to read SSH config: {0}")]
56
+ SshConfigRead(String),
57
+
58
+ /// Failed to write SSH config
59
+ #[error("Failed to write SSH config: {0}")]
60
+ SshConfigWrite(String),
61
+ }
@@ -0,0 +1,29 @@
1
+ //! Host management module
2
+ //!
3
+ //! Provides functionality for managing remote Docker hosts:
4
+ //! - Host configuration schema and storage
5
+ //! - SSH tunnel management for remote Docker access
6
+ //! - Connection testing and validation
7
+ //! - SSH config file parsing and writing
8
+ //! - Remote Docker provisioning
9
+
10
+ mod error;
11
+ mod provision;
12
+ mod schema;
13
+ mod ssh_config;
14
+ mod storage;
15
+ mod tunnel;
16
+
17
+ // Public exports
18
+ pub use error::HostError;
19
+ pub use provision::{
20
+ DistroFamily, DistroInfo, detect_distro, get_docker_install_commands, install_docker,
21
+ verify_docker_installed,
22
+ };
23
+ pub use schema::{HostConfig, HostsFile};
24
+ pub use ssh_config::{
25
+ SshConfigMatch, get_ssh_config_path, host_exists_in_ssh_config, query_ssh_config,
26
+ write_ssh_config_entry,
27
+ };
28
+ pub use storage::{load_hosts, save_hosts};
29
+ pub use tunnel::{SshTunnel, test_connection};