@opencode-cloud/core 4.3.0 → 5.0.1
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.
- package/Cargo.toml +1 -1
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/docker/container.rs +3 -2
- package/src/docker/mod.rs +8 -5
- package/src/docker/users.rs +189 -0
- package/src/docker/volume.rs +12 -2
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -191,6 +191,12 @@ occ user add <username> --generate
|
|
|
191
191
|
- Remove user: `occ user remove <username>`
|
|
192
192
|
- Enable/disable account: `occ user enable <username>` / `occ user disable <username>`
|
|
193
193
|
|
|
194
|
+
### User Persistence
|
|
195
|
+
|
|
196
|
+
User accounts (including password hashes and lock status) persist across container updates and rebuilds.
|
|
197
|
+
The CLI stores user records in a managed Docker volume mounted at `/var/lib/opencode-users` inside the container.
|
|
198
|
+
No plaintext passwords are stored on the host.
|
|
199
|
+
|
|
194
200
|
### Legacy Authentication Fields
|
|
195
201
|
|
|
196
202
|
The `auth_username` and `auth_password` config fields are **deprecated** and ignored. They are kept in the config schema for backward compatibility with existing deployments, but new users should be created via `occ user add` instead.
|
package/package.json
CHANGED
package/src/docker/container.rs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
|
|
7
7
|
use super::mount::ParsedMount;
|
|
8
8
|
use super::volume::{
|
|
9
|
-
MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE,
|
|
10
|
-
VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_STATE,
|
|
9
|
+
MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE, MOUNT_USERS,
|
|
10
|
+
VOLUME_CACHE, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_STATE, VOLUME_USERS,
|
|
11
11
|
};
|
|
12
12
|
use super::{DockerClient, DockerError};
|
|
13
13
|
use bollard::container::{
|
|
@@ -121,6 +121,7 @@ pub async fn create_container(
|
|
|
121
121
|
add_volume_mount(MOUNT_CACHE, VOLUME_CACHE);
|
|
122
122
|
add_volume_mount(MOUNT_PROJECTS, VOLUME_PROJECTS);
|
|
123
123
|
add_volume_mount(MOUNT_CONFIG, VOLUME_CONFIG);
|
|
124
|
+
add_volume_mount(MOUNT_USERS, VOLUME_USERS);
|
|
124
125
|
|
|
125
126
|
// Add user-defined bind mounts from config/CLI
|
|
126
127
|
if let Some(ref user_mounts) = bind_mounts {
|
package/src/docker/mod.rs
CHANGED
|
@@ -54,15 +54,15 @@ pub use exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
|
|
|
54
54
|
|
|
55
55
|
// User management operations
|
|
56
56
|
pub use users::{
|
|
57
|
-
UserInfo, create_user, delete_user, list_users, lock_user,
|
|
58
|
-
user_exists,
|
|
57
|
+
UserInfo, create_user, delete_user, list_users, lock_user, persist_user, remove_persisted_user,
|
|
58
|
+
restore_persisted_users, set_user_password, unlock_user, user_exists,
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
// Volume management
|
|
62
62
|
pub use volume::{
|
|
63
|
-
MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE,
|
|
64
|
-
VOLUME_CONFIG, VOLUME_NAMES, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_STATE,
|
|
65
|
-
ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
|
|
63
|
+
MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE, MOUNT_USERS,
|
|
64
|
+
VOLUME_CACHE, VOLUME_CONFIG, VOLUME_NAMES, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_STATE,
|
|
65
|
+
VOLUME_USERS, ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
// Bind mount parsing and validation
|
|
@@ -136,6 +136,9 @@ pub async fn setup_and_start(
|
|
|
136
136
|
container::start_container(client, container::CONTAINER_NAME).await?;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// Restore persisted users after the container is running
|
|
140
|
+
users::restore_persisted_users(client, container::CONTAINER_NAME).await?;
|
|
141
|
+
|
|
139
142
|
Ok(container_id)
|
|
140
143
|
}
|
|
141
144
|
|
package/src/docker/users.rs
CHANGED
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
//! Instead, we use `chpasswd` which reads from stdin.
|
|
9
9
|
|
|
10
10
|
use super::exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
|
|
11
|
+
use super::volume::MOUNT_USERS;
|
|
11
12
|
use super::{DockerClient, DockerError};
|
|
13
|
+
use 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.
|
|
19
|
+
const USERS_STORE_DIR: &str = MOUNT_USERS;
|
|
12
20
|
|
|
13
21
|
/// Information about a container user
|
|
14
22
|
#[derive(Debug, Clone, PartialEq)]
|
|
@@ -25,6 +33,13 @@ pub struct UserInfo {
|
|
|
25
33
|
pub locked: bool,
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
37
|
+
struct PersistedUserRecord {
|
|
38
|
+
username: String,
|
|
39
|
+
password_hash: String,
|
|
40
|
+
locked: bool,
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/// Create a new user in the container
|
|
29
44
|
///
|
|
30
45
|
/// Creates a user with a home directory and /bin/bash shell.
|
|
@@ -96,6 +111,31 @@ pub async fn set_user_password(
|
|
|
96
111
|
Ok(())
|
|
97
112
|
}
|
|
98
113
|
|
|
114
|
+
/// Set a user's password hash directly (no plaintext required)
|
|
115
|
+
///
|
|
116
|
+
/// Uses `usermod -p` with a precomputed shadow hash.
|
|
117
|
+
async 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
|
+
|
|
99
139
|
/// Check if a user exists in the container
|
|
100
140
|
///
|
|
101
141
|
/// # Arguments
|
|
@@ -234,6 +274,81 @@ pub async fn list_users(
|
|
|
234
274
|
Ok(users)
|
|
235
275
|
}
|
|
236
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.
|
|
280
|
+
pub 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.
|
|
301
|
+
pub 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.
|
|
316
|
+
pub 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
|
+
|
|
237
352
|
/// Check if a user account is locked
|
|
238
353
|
///
|
|
239
354
|
/// Uses `passwd -S` to get account status.
|
|
@@ -256,6 +371,80 @@ async fn is_user_locked(
|
|
|
256
371
|
Ok(false)
|
|
257
372
|
}
|
|
258
373
|
|
|
374
|
+
async 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
|
+
|
|
381
|
+
async 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
|
+
|
|
404
|
+
async 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
|
+
|
|
429
|
+
fn user_record_path(username: &str) -> String {
|
|
430
|
+
format!("{USERS_STORE_DIR}/{username}.json")
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async 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
|
+
|
|
259
448
|
/// Parsed user info from /etc/passwd line (intermediate struct)
|
|
260
449
|
struct ParsedUser {
|
|
261
450
|
username: String,
|
package/src/docker/volume.rs
CHANGED
|
@@ -23,13 +23,17 @@ pub const VOLUME_PROJECTS: &str = "opencode-workspace";
|
|
|
23
23
|
/// Volume name for opencode configuration
|
|
24
24
|
pub const VOLUME_CONFIG: &str = "opencode-config";
|
|
25
25
|
|
|
26
|
+
/// Volume name for persisted user records
|
|
27
|
+
pub const VOLUME_USERS: &str = "opencode-users";
|
|
28
|
+
|
|
26
29
|
/// All volume names as array for iteration
|
|
27
|
-
pub const VOLUME_NAMES: [&str;
|
|
30
|
+
pub const VOLUME_NAMES: [&str; 6] = [
|
|
28
31
|
VOLUME_SESSION,
|
|
29
32
|
VOLUME_STATE,
|
|
30
33
|
VOLUME_CACHE,
|
|
31
34
|
VOLUME_PROJECTS,
|
|
32
35
|
VOLUME_CONFIG,
|
|
36
|
+
VOLUME_USERS,
|
|
33
37
|
];
|
|
34
38
|
|
|
35
39
|
/// Mount point for opencode data inside container
|
|
@@ -47,6 +51,9 @@ pub const MOUNT_PROJECTS: &str = "/home/opencode/workspace";
|
|
|
47
51
|
/// Mount point for configuration inside container
|
|
48
52
|
pub const MOUNT_CONFIG: &str = "/home/opencode/.config/opencode";
|
|
49
53
|
|
|
54
|
+
/// Mount point for persisted user records inside container
|
|
55
|
+
pub const MOUNT_USERS: &str = "/var/lib/opencode-users";
|
|
56
|
+
|
|
50
57
|
/// Ensure all required volumes exist
|
|
51
58
|
///
|
|
52
59
|
/// Creates volumes if they don't exist. This operation is idempotent -
|
|
@@ -145,16 +152,18 @@ mod tests {
|
|
|
145
152
|
assert_eq!(VOLUME_CACHE, "opencode-cache");
|
|
146
153
|
assert_eq!(VOLUME_PROJECTS, "opencode-workspace");
|
|
147
154
|
assert_eq!(VOLUME_CONFIG, "opencode-config");
|
|
155
|
+
assert_eq!(VOLUME_USERS, "opencode-users");
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
#[test]
|
|
151
159
|
fn volume_names_array_has_all_volumes() {
|
|
152
|
-
assert_eq!(VOLUME_NAMES.len(),
|
|
160
|
+
assert_eq!(VOLUME_NAMES.len(), 6);
|
|
153
161
|
assert!(VOLUME_NAMES.contains(&VOLUME_SESSION));
|
|
154
162
|
assert!(VOLUME_NAMES.contains(&VOLUME_STATE));
|
|
155
163
|
assert!(VOLUME_NAMES.contains(&VOLUME_CACHE));
|
|
156
164
|
assert!(VOLUME_NAMES.contains(&VOLUME_PROJECTS));
|
|
157
165
|
assert!(VOLUME_NAMES.contains(&VOLUME_CONFIG));
|
|
166
|
+
assert!(VOLUME_NAMES.contains(&VOLUME_USERS));
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
#[test]
|
|
@@ -164,5 +173,6 @@ mod tests {
|
|
|
164
173
|
assert_eq!(MOUNT_CACHE, "/home/opencode/.cache/opencode");
|
|
165
174
|
assert_eq!(MOUNT_PROJECTS, "/home/opencode/workspace");
|
|
166
175
|
assert_eq!(MOUNT_CONFIG, "/home/opencode/.config/opencode");
|
|
176
|
+
assert_eq!(MOUNT_USERS, "/var/lib/opencode-users");
|
|
167
177
|
}
|
|
168
178
|
}
|