Skip to content

Commit 7871350

Browse files
committed
Harden bootstrap user detection and managed user semantics
1 parent e9e5ee0 commit 7871350

7 files changed

Lines changed: 258 additions & 105 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,10 @@ occ install
154154
occ start
155155
```
156156

157-
If this is the first startup with no configured users, the container logs will print an **Initial One-Time Password (IOTP)**.
157+
If this is the first startup with no configured managed users, the container logs will print an **Initial One-Time Password (IOTP)**.
158158
Open the login page, use the first-time setup panel, and enroll a passkey for the default `opencoder` account.
159159
The IOTP is invalidated after successful passkey enrollment.
160+
Built-in image users (for example `ubuntu`/`opencoder`) do not count as configured users for IOTP bootstrap.
160161

161162
Quick IOTP extraction from logs:
162163

@@ -359,12 +360,13 @@ occ config show
359360
opencode-cloud uses **PAM (Pluggable Authentication Modules)** for authentication.
360361
361362
First boot path:
362-
- If no users are configured, startup logs print an Initial One-Time Password (IOTP).
363+
- If no managed users are configured, startup logs print an Initial One-Time Password (IOTP).
363364
- Extract only the IOTP quickly: `occ logs | grep -F "INITIAL ONE-TIME PASSWORD (IOTP): " | tail -n1 | sed 's/.*INITIAL ONE-TIME PASSWORD (IOTP): //'`
364365
- `occ setup` attempts to auto-detect and print the IOTP after starting/restarting the service.
365366
- Enter that IOTP in the web login page first-time setup panel.
366367
- Enroll a passkey for the default `opencoder` account.
367368
- The IOTP is deleted after successful passkey enrollment.
369+
- Built-in image users (for example `ubuntu`/`opencoder`) do not disable IOTP bootstrap.
368370
369371
Admin path:
370372
- You can always create/manage users directly via `occ user add`, `occ user passwd`, and related user commands.
@@ -388,7 +390,7 @@ occ user add <username> --generate
388390
389391
### Managing Users
390392
391-
- List users: `occ user list`
393+
- List users: `occ user list` (managed users only)
392394
- Change password: `occ user passwd <username>`
393395
- Remove user: `occ user remove <username>`
394396
- Enable/disable account: `occ user enable <username>` / `occ user disable <username>`
@@ -397,6 +399,7 @@ occ user add <username> --generate
397399
398400
User accounts (including password hashes and lock status) persist across container updates and rebuilds.
399401
The CLI stores user records in a managed Docker volume mounted at `/var/lib/opencode-users` inside the container.
402+
This record store is the source of truth for configured users and bootstrap decisions.
400403
No plaintext passwords are stored on the host.
401404
402405
### Rebuilding the Docker Image

packages/cli-rust/src/commands/start.rs

Lines changed: 11 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -575,34 +575,6 @@ fn display_network_exposure_warning(bind_addr: &str) {
575575
eprintln!();
576576
}
577577

578-
fn display_bootstrap_mode_warning(bind_addr: &str) {
579-
eprintln!();
580-
eprintln!(
581-
"{} {}",
582-
style("Bootstrap mode enabled:").yellow().bold(),
583-
style("no preconfigured users were found.").yellow()
584-
);
585-
eprintln!();
586-
eprintln!(
587-
"The service will start on {} and print an Initial One-Time Password (IOTP) in container logs.",
588-
style(bind_addr).cyan()
589-
);
590-
eprintln!(
591-
"{}",
592-
style("Note: persisted users in /var/lib/opencode-users disable IOTP bootstrap.")
593-
.yellow()
594-
.dim()
595-
);
596-
eprintln!("Open the login page and use the first-time setup panel with that IOTP.");
597-
eprintln!("The IOTP is invalidated immediately after the first successful passkey enrollment.");
598-
eprintln!();
599-
eprintln!(
600-
"Admin alternative: {}",
601-
style("occ user add <username>").dim()
602-
);
603-
eprintln!();
604-
}
605-
606578
/// Start the opencode service
607579
///
608580
/// This command:
@@ -715,12 +687,6 @@ pub async fn cmd_start(
715687
// Determine whether this is first container start
716688
let is_first_start = !container_exists(&client, CONTAINER_NAME).await?;
717689

718-
let bootstrap_mode =
719-
is_first_start && config.users.is_empty() && !config.allow_unauthenticated_network;
720-
if bootstrap_mode && !quiet {
721-
display_bootstrap_mode_warning(bind_addr);
722-
}
723-
724690
// Check for port mismatch on existing container
725691
if !is_first_start
726692
&& !recreate_container
@@ -874,7 +840,7 @@ pub async fn cmd_start(
874840
quiet,
875841
host_name.as_deref(),
876842
);
877-
maybe_print_iotp_info(&client, host_name.as_deref(), &config, bootstrap_mode).await;
843+
maybe_print_iotp_info(&client, host_name.as_deref(), &config).await;
878844
open_browser_if_requested(args.open, port, bind_addr);
879845

880846
if args.no_daemon {
@@ -1204,12 +1170,13 @@ async fn maybe_print_iotp_info(
12041170
client: &DockerClient,
12051171
host: Option<&str>,
12061172
config: &opencode_cloud_core::Config,
1207-
bootstrap_mode_hint: bool,
12081173
) {
12091174
if config.allow_unauthenticated_network || !config.users.is_empty() {
12101175
return;
12111176
}
12121177

1178+
// The bootstrap helper is the source of truth after container startup.
1179+
// Do not infer onboarding state from pre-start config heuristics.
12131180
let snapshot = fetch_iotp_snapshot(client).await;
12141181

12151182
println!();
@@ -1234,23 +1201,16 @@ async fn maybe_print_iotp_info(
12341201
"{}",
12351202
style("Could not retrieve an IOTP from bootstrap state.").yellow()
12361203
);
1237-
println!(
1238-
"Reason: {}",
1239-
iotp_unavailable_reason(&snapshot, bootstrap_mode_hint)
1240-
);
1204+
println!("Reason: {}", iotp_unavailable_reason(&snapshot));
12411205
println!("Fetch logs with: {}", style("occ logs").cyan());
12421206
println!("Try extracting IOTP with:\n {IOTP_FALLBACK_COMMAND}");
12431207
}
12441208

1245-
fn iotp_unavailable_reason(snapshot: &IotpSnapshot, bootstrap_mode_hint: bool) -> String {
1209+
fn iotp_unavailable_reason(snapshot: &IotpSnapshot) -> String {
12461210
if let Some(reason) = snapshot.detail.as_deref() {
12471211
return reason.to_string();
12481212
}
12491213

1250-
if bootstrap_mode_hint {
1251-
return "bootstrap mode was requested but no active IOTP is available".to_string();
1252-
}
1253-
12541214
format!("IOTP state is {}", snapshot.state_label)
12551215
}
12561216

@@ -1519,20 +1479,21 @@ mod tests {
15191479
otp: None,
15201480
detail: Some("bootstrap helper unavailable".to_string()),
15211481
};
1522-
let reason = super::iotp_unavailable_reason(&snapshot, true);
1482+
let reason = super::iotp_unavailable_reason(&snapshot);
15231483
assert_eq!(reason, "bootstrap helper unavailable");
15241484
}
15251485

15261486
#[test]
1527-
fn iotp_unavailable_reason_uses_bootstrap_hint() {
1487+
fn iotp_unavailable_reason_does_not_invent_bootstrap_hint() {
15281488
let snapshot = IotpSnapshot {
15291489
state: IotpState::Unavailable,
15301490
state_label: "unavailable".to_string(),
15311491
otp: None,
15321492
detail: None,
15331493
};
1534-
let reason = super::iotp_unavailable_reason(&snapshot, true);
1535-
assert!(reason.contains("bootstrap mode was requested"));
1494+
let reason = super::iotp_unavailable_reason(&snapshot);
1495+
assert_eq!(reason, "IOTP state is unavailable");
1496+
assert!(!reason.contains("bootstrap mode was requested"));
15361497
}
15371498

15381499
#[test]
@@ -1543,7 +1504,7 @@ mod tests {
15431504
otp: None,
15441505
detail: None,
15451506
};
1546-
let reason = super::iotp_unavailable_reason(&snapshot, false);
1507+
let reason = super::iotp_unavailable_reason(&snapshot);
15471508
assert_eq!(reason, "IOTP state is inactive (users configured)");
15481509
}
15491510
}

packages/core/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,10 @@ occ install
154154
occ start
155155
```
156156

157-
If this is the first startup with no configured users, the container logs will print an **Initial One-Time Password (IOTP)**.
157+
If this is the first startup with no configured managed users, the container logs will print an **Initial One-Time Password (IOTP)**.
158158
Open the login page, use the first-time setup panel, and enroll a passkey for the default `opencoder` account.
159159
The IOTP is invalidated after successful passkey enrollment.
160+
Built-in image users (for example `ubuntu`/`opencoder`) do not count as configured users for IOTP bootstrap.
160161

161162
Quick IOTP extraction from logs:
162163

@@ -359,12 +360,13 @@ occ config show
359360
opencode-cloud uses **PAM (Pluggable Authentication Modules)** for authentication.
360361
361362
First boot path:
362-
- If no users are configured, startup logs print an Initial One-Time Password (IOTP).
363+
- If no managed users are configured, startup logs print an Initial One-Time Password (IOTP).
363364
- Extract only the IOTP quickly: `occ logs | grep -F "INITIAL ONE-TIME PASSWORD (IOTP): " | tail -n1 | sed 's/.*INITIAL ONE-TIME PASSWORD (IOTP): //'`
364365
- `occ setup` attempts to auto-detect and print the IOTP after starting/restarting the service.
365366
- Enter that IOTP in the web login page first-time setup panel.
366367
- Enroll a passkey for the default `opencoder` account.
367368
- The IOTP is deleted after successful passkey enrollment.
369+
- Built-in image users (for example `ubuntu`/`opencoder`) do not disable IOTP bootstrap.
368370
369371
Admin path:
370372
- You can always create/manage users directly via `occ user add`, `occ user passwd`, and related user commands.
@@ -388,7 +390,7 @@ occ user add <username> --generate
388390
389391
### Managing Users
390392
391-
- List users: `occ user list`
393+
- List users: `occ user list` (managed users only)
392394
- Change password: `occ user passwd <username>`
393395
- Remove user: `occ user remove <username>`
394396
- Enable/disable account: `occ user enable <username>` / `occ user disable <username>`
@@ -397,6 +399,7 @@ occ user add <username> --generate
397399
398400
User accounts (including password hashes and lock status) persist across container updates and rebuilds.
399401
The CLI stores user records in a managed Docker volume mounted at `/var/lib/opencode-users` inside the container.
402+
This record store is the source of truth for configured users and bootstrap decisions.
400403
No plaintext passwords are stored on the host.
401404
402405
### Rebuilding the Docker Image

packages/core/src/docker/Dockerfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,12 @@ RUN --mount=type=cache,target=/var/lib/apt/lists \
162162
# Create 'opencode' user with passwordless sudo
163163
RUN useradd -m -s /bin/bash -G sudo opencoder \
164164
&& echo "opencoder ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/opencoder \
165-
&& chmod 0440 /etc/sudoers.d/opencoder
165+
&& chmod 0440 /etc/sudoers.d/opencoder \
166+
# Snapshot built-in home users from the image so runtime auth logic can
167+
# ignore defaults (e.g. ubuntu) when deciding if onboarding should run.
168+
&& install -d -m 0755 /etc/opencode-cloud \
169+
&& awk -F: '$6 ~ /^\/home\// {print $1}' /etc/passwd | sort -u > /etc/opencode-cloud/builtin-home-users.txt \
170+
&& chmod 0644 /etc/opencode-cloud/builtin-home-users.txt
166171

167172
# Switch to opencode user for remaining setup
168173
USER opencoder

packages/core/src/docker/files/entrypoint.sh

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ export OPENCODE_PORT OPENCODE_HOST
9292
BOOTSTRAP_HELPER="/usr/local/bin/opencode-cloud-bootstrap"
9393
BOOTSTRAP_STATE_DIR="/var/lib/opencode-users"
9494
PROTECTED_USER="opencoder"
95+
BUILTIN_USERS_FILE="/etc/opencode-cloud/builtin-home-users.txt"
96+
FALLBACK_BUILTIN_HOME_USERS=("opencoder" "ubuntu")
97+
BUILTIN_HOME_USERS=()
9598
# NOTE: Do not change this prefix; admins may depend on it for log grep extraction.
9699
greppable_iotp_prefix="INITIAL ONE-TIME PASSWORD (IOTP): "
97100

@@ -160,6 +163,43 @@ else
160163
# Ensure user records directory exists (ephemeral unless mounted)
161164
install -d -m 0700 /var/lib/opencode-users
162165

166+
load_builtin_home_users() {
167+
BUILTIN_HOME_USERS=("${FALLBACK_BUILTIN_HOME_USERS[@]}")
168+
169+
if [ ! -r "${BUILTIN_USERS_FILE}" ]; then
170+
return 0
171+
fi
172+
173+
local username
174+
while IFS= read -r username; do
175+
username="$(printf "%s" "${username}" | tr -d '\r\n')"
176+
if [ -n "${username}" ]; then
177+
BUILTIN_HOME_USERS+=("${username}")
178+
fi
179+
done < "${BUILTIN_USERS_FILE}"
180+
}
181+
182+
is_builtin_home_user() {
183+
local username="$1"
184+
local builtin
185+
for builtin in "${BUILTIN_HOME_USERS[@]}"; do
186+
if [ "${username}" = "${builtin}" ]; then
187+
return 0
188+
fi
189+
done
190+
return 1
191+
}
192+
193+
user_record_path() {
194+
local username="$1"
195+
printf "/var/lib/opencode-users/%s.json" "${username}"
196+
}
197+
198+
user_record_exists() {
199+
local username="$1"
200+
[ -f "$(user_record_path "${username}")" ]
201+
}
202+
163203
ensure_auth_config() {
164204
local config_dir="/home/opencoder/.config/opencode"
165205
local config_json="${config_dir}/opencode.json"
@@ -203,8 +243,8 @@ EOF
203243
log "Skipping invalid user record: ${record}"
204244
continue
205245
fi
206-
if [ "${username}" = "${PROTECTED_USER}" ]; then
207-
log "Skipping protected user record: ${record}"
246+
if is_builtin_home_user "${username}"; then
247+
log "Skipping built-in user record: ${record}"
208248
continue
209249
fi
210250
if ! id -u "${username}" >/dev/null 2>&1; then
@@ -226,8 +266,8 @@ EOF
226266

227267
persist_user_record() {
228268
local username="$1"
229-
if [ "${username}" = "${PROTECTED_USER}" ]; then
230-
log "Skipping persistence for protected user: ${username}"
269+
if is_builtin_home_user "${username}"; then
270+
log "Skipping persistence for built-in user: ${username}"
231271
return 0
232272
fi
233273
local shadow_hash
@@ -242,7 +282,8 @@ EOF
242282
if [ "${status}" = "L" ]; then
243283
locked="true"
244284
fi
245-
local record_path="/var/lib/opencode-users/${username}.json"
285+
local record_path
286+
record_path="$(user_record_path "${username}")"
246287
umask 077
247288
jq -n --arg username "${username}" --arg hash "${shadow_hash}" --argjson locked "${locked}" '{username:$username,password_hash:$hash,locked:$locked}' > "${record_path}"
248289
chmod 600 "${record_path}"
@@ -275,26 +316,49 @@ EOF
275316
}
276317

277318
has_non_protected_persisted_users() {
319+
# Persisted records are the source of truth for managed login users.
320+
# Built-in image users (e.g., ubuntu/opencoder) must not disable IOTP.
278321
shopt -s nullglob
279322
local records=(/var/lib/opencode-users/*.json)
280323
local record username
281324
for record in "${records[@]}"; do
282325
username="$(jq -r ".username // empty" "${record}" 2>/dev/null || true)"
283-
if [ -n "${username}" ] && [ "${username}" != "${PROTECTED_USER}" ]; then
326+
if [ -z "${username}" ]; then
327+
continue
328+
fi
329+
if is_builtin_home_user "${username}"; then
330+
continue
331+
fi
332+
if [ "${username}" != "${PROTECTED_USER}" ]; then
284333
return 0
285334
fi
286335
done
287336

288-
local line home
337+
return 1
338+
}
339+
340+
migrate_unmanaged_home_users_to_records() {
341+
# Migrate any non-built-in Linux users before bootstrap checks so real
342+
# manually-created accounts disable IOTP in a consistent, managed way.
343+
local line username home
289344
while IFS= read -r line; do
290345
username="$(cut -d: -f1 <<< "${line}")"
291346
home="$(cut -d: -f6 <<< "${line}")"
292-
if [[ "${home}" == /home/* ]] && [ "${username}" != "${PROTECTED_USER}" ]; then
293-
return 0
347+
if [[ "${home}" != /home/* ]]; then
348+
continue
349+
fi
350+
if is_builtin_home_user "${username}"; then
351+
continue
352+
fi
353+
if user_record_exists "${username}"; then
354+
continue
355+
fi
356+
if persist_user_record "${username}"; then
357+
log "Migrated unmanaged user to managed records: ${username}"
358+
else
359+
log "WARNING: Failed to migrate unmanaged user to records: ${username}"
294360
fi
295361
done < <(getent passwd)
296-
297-
return 1
298362
}
299363

300364
clear_bootstrap_state() {
@@ -381,8 +445,10 @@ EOF
381445
maybe_initialize_bootstrap_mode
382446
}
383447

448+
load_builtin_home_users
384449
ensure_auth_config
385450
restore_or_bootstrap_users
451+
migrate_unmanaged_home_users_to_records
386452
sync_bootstrap_state
387453

388454
log "Starting opencode on ${OPENCODE_HOST}:${OPENCODE_PORT}"

0 commit comments

Comments
 (0)