Skip to content

Commit fccf3ea

Browse files
committed
Add IOTP reset flow, localhost URL UX, and drift warnings
1 parent 9b45d64 commit fccf3ea

21 files changed

Lines changed: 734 additions & 65 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ occ reset container
269269
# Remove container and volumes (data loss)
270270
occ reset container --volumes --force
271271

272+
# Reset completed IOTP bootstrap and generate a fresh one-time password
273+
occ reset iotp
274+
275+
# If bind_address is exposed (for example behind HTTPS reverse proxy), confirm intentionally
276+
occ reset iotp --force
277+
272278
# Clean bind mount contents (data loss)
273279
occ mount clean --force
274280

@@ -366,6 +372,7 @@ First boot path:
366372
- Enter that IOTP in the web login page first-time setup panel.
367373
- Enroll a passkey for the default `opencoder` account.
368374
- The IOTP is deleted after successful passkey enrollment.
375+
- To restart first-time onboarding after completion, run `occ reset iotp`.
369376
- Built-in image users (for example `ubuntu`/`opencoder`) do not disable IOTP bootstrap.
370377
371378
Admin path:

docs/deploy/digitalocean-droplet.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ Goal: keep opencode on localhost, expose only `80/443` publicly.
156156
### 1) Ensure opencode binds to localhost
157157

158158
```bash
159-
occ config set bind_address 127.0.0.1
159+
occ config set bind_address localhost
160160
occ restart
161161
```
162162

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

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! Opens the Cockpit web console in the default browser.
44
55
use crate::constants::COCKPIT_EXPOSED;
6+
use crate::output::localhost_display_addr;
67
use anyhow::{Result, bail};
78
use clap::Args;
89
use console::style;
@@ -59,12 +60,7 @@ pub async fn cmd_cockpit(_args: &CockpitArgs, maybe_host: Option<&str>, quiet: b
5960

6061
let running = container_is_running(&client, CONTAINER_NAME).await?;
6162
if !running {
62-
// For 0.0.0.0 or :: bind addresses, use localhost for display
63-
let display_addr = if config.bind_address == "0.0.0.0" || config.bind_address == "::" {
64-
"127.0.0.1"
65-
} else {
66-
&config.bind_address
67-
};
63+
let display_addr = localhost_display_addr(&config.bind_address);
6864
bail!(
6965
"{}\n\n\
7066
The container is not running. Cockpit runs inside the container.\n\n\
@@ -77,12 +73,7 @@ pub async fn cmd_cockpit(_args: &CockpitArgs, maybe_host: Option<&str>, quiet: b
7773
}
7874

7975
// Build URL
80-
// For 0.0.0.0 or :: bind addresses, use localhost for browser
81-
let browser_addr = if config.bind_address == "0.0.0.0" || config.bind_address == "::" {
82-
"127.0.0.1"
83-
} else {
84-
&config.bind_address
85-
};
76+
let browser_addr = localhost_display_addr(&config.bind_address);
8677
let url = format!("http://{}:{}", browser_addr, config.cockpit_port);
8778

8879
if !quiet {

packages/cli-rust/src/commands/config/set.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ pub fn cmd_config_set(key: &str, value: Option<&str>, quiet: bool, force: bool)
8888
eprintln!();
8989
eprintln!(
9090
"To bind to localhost only: {}",
91-
style("occ config set bind_address 127.0.0.1").cyan()
91+
style("occ config set bind_address localhost").cyan()
9292
);
9393
eprintln!();
9494
}

packages/cli-rust/src/commands/container/status.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::commands::runtime_shared::collect_status_view;
66
use crate::commands::runtime_shared::status_model::{
77
OpencodeHealthStatus, format_broker_health_label,
88
};
9-
use crate::output::state_style;
9+
use crate::output::{format_service_url, state_style};
1010
use anyhow::Result;
1111
use console::style;
1212
use opencode_cloud_core::docker::get_cli_version;
@@ -55,7 +55,7 @@ pub async fn cmd_status_container(
5555
"{}",
5656
format_kv(
5757
"URL:",
58-
style(format!("http://127.0.0.1:{host_port}")).cyan()
58+
style(format_service_url(None, "127.0.0.1", host_port)).cyan()
5959
)
6060
);
6161

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,19 @@ impl IotpSnapshot {
3535
}
3636

3737
pub(crate) async fn fetch_iotp_snapshot(client: &DockerClient) -> IotpSnapshot {
38-
let (output, status) = match exec_command_with_status(
38+
query_iotp_snapshot(
3939
client,
40-
CONTAINER_NAME,
4140
vec![BOOTSTRAP_HELPER_PATH, "status", "--include-secret"],
4241
)
4342
.await
44-
{
43+
}
44+
45+
pub(crate) async fn reset_iotp_snapshot(client: &DockerClient) -> IotpSnapshot {
46+
query_iotp_snapshot(client, vec![BOOTSTRAP_HELPER_PATH, "reset"]).await
47+
}
48+
49+
async fn query_iotp_snapshot(client: &DockerClient, command: Vec<&str>) -> IotpSnapshot {
50+
let (output, status) = match exec_command_with_status(client, CONTAINER_NAME, command).await {
4551
Ok(result) => result,
4652
Err(err) => {
4753
return IotpSnapshot::unavailable(format!("failed to query bootstrap helper: {err}"));

packages/cli-rust/src/commands/reset/mod.rs

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::commands::disk_usage::{
1010
DiskUsageReport, HostDiskReport, format_disk_usage_report, format_host_disk_report,
1111
get_disk_usage_report, get_host_disk_report,
1212
};
13+
use crate::commands::iotp::{IotpState, reset_iotp_snapshot};
1314
use crate::commands::service::{StopSpinnerMessages, stop_service_with_spinner};
1415
use crate::commands::start::{StartArgs, cmd_start};
1516
use crate::output::{CommandSpinner, show_docker_error};
@@ -21,8 +22,8 @@ use opencode_cloud_core::config::load_config_or_default;
2122
use opencode_cloud_core::config::paths::{get_config_dir, get_data_dir};
2223
use opencode_cloud_core::config::save_config;
2324
use opencode_cloud_core::docker::{
24-
CONTAINER_NAME, DEFAULT_STOP_TIMEOUT_SECS, clear_state, container_exists, remove_all_volumes,
25-
remove_images_by_name,
25+
CONTAINER_NAME, DEFAULT_STOP_TIMEOUT_SECS, clear_state, container_exists, container_is_running,
26+
remove_all_volumes, remove_images_by_name,
2627
};
2728
use opencode_cloud_core::platform::{get_service_manager, is_service_registration_supported};
2829
use std::fs;
@@ -48,6 +49,8 @@ pub enum ResetCommands {
4849
Container(ResetContainerArgs),
4950
/// Factory reset the host installation (container, volumes, mounts, config, data)
5051
Host(ResetHostArgs),
52+
/// Reset bootstrap IOTP state and generate a fresh one-time password
53+
Iotp(ResetIotpArgs),
5154
}
5255

5356
/// Arguments for reset container
@@ -90,6 +93,14 @@ pub struct ResetHostArgs {
9093
pub images: bool,
9194
}
9295

96+
/// Arguments for reset iotp
97+
#[derive(Args)]
98+
pub struct ResetIotpArgs {
99+
/// Proceed even when bind_address is non-localhost
100+
#[arg(long)]
101+
pub force: bool,
102+
}
103+
93104
pub async fn cmd_reset(
94105
args: &ResetArgs,
95106
maybe_host: Option<&str>,
@@ -103,9 +114,86 @@ pub async fn cmd_reset(
103114
ResetCommands::Host(host_args) => {
104115
cmd_reset_host(host_args, maybe_host, quiet, verbose).await
105116
}
117+
ResetCommands::Iotp(iotp_args) => cmd_reset_iotp(iotp_args, maybe_host, quiet).await,
106118
}
107119
}
108120

121+
async fn cmd_reset_iotp(args: &ResetIotpArgs, maybe_host: Option<&str>, quiet: bool) -> Result<()> {
122+
let (client, host_name) = crate::resolve_docker_client(maybe_host).await?;
123+
client.verify_connection().await.map_err(|e| {
124+
let msg = crate::output::format_docker_error(&e);
125+
anyhow!("{msg}")
126+
})?;
127+
128+
if !container_is_running(&client, CONTAINER_NAME).await? {
129+
bail!(
130+
"Service is not running.\n\
131+
Start it first with: {}",
132+
style("occ start").cyan()
133+
);
134+
}
135+
136+
let config = load_config_or_default()?;
137+
if should_block_iotp_reset(&config, args.force) {
138+
bail!("{}", iotp_reset_force_message(&config.bind_address));
139+
}
140+
141+
let snapshot = reset_iotp_snapshot(&client).await;
142+
if matches!(snapshot.state, IotpState::ActiveUnused)
143+
&& let Some(iotp) = snapshot.otp.as_deref()
144+
{
145+
if quiet {
146+
println!("{iotp}");
147+
return Ok(());
148+
}
149+
150+
println!();
151+
println!(
152+
"{}",
153+
style(crate::format_host_message(
154+
host_name.as_deref(),
155+
"IOTP reset"
156+
))
157+
.cyan()
158+
.bold()
159+
);
160+
println!(
161+
"Initial One-Time Password (IOTP): {}",
162+
style(iotp).green().bold()
163+
);
164+
println!(
165+
"Enter this in the web login first-time setup panel, then enroll a passkey for {}.",
166+
style("opencoder").cyan()
167+
);
168+
return Ok(());
169+
}
170+
171+
let reason = snapshot
172+
.detail
173+
.as_deref()
174+
.map(str::to_owned)
175+
.unwrap_or_else(|| format!("IOTP state is {}", snapshot.state_label));
176+
bail!(
177+
"IOTP was not reset to an active onboarding state.\n\
178+
Reason: {reason}"
179+
);
180+
}
181+
182+
fn iotp_reset_force_message(bind_address: &str) -> String {
183+
format!(
184+
"Refusing to reset IOTP while bind_address={} is non-localhost.\n\
185+
Resetting IOTP reopens first-time onboarding and can expose bootstrap enrollment beyond localhost.\n\
186+
If this host is intentionally fronted by a trusted HTTPS reverse proxy with access controls,\n\
187+
rerun explicitly with:\n {}",
188+
style(bind_address).cyan(),
189+
style("occ reset iotp --force").cyan()
190+
)
191+
}
192+
193+
fn should_block_iotp_reset(config: &opencode_cloud_core::Config, force: bool) -> bool {
194+
!config.is_localhost() && !force
195+
}
196+
109197
/// Capture Docker + host disk usage for reporting before/after destructive steps.
110198
async fn capture_disk_usage_snapshot(
111199
client: &opencode_cloud_core::docker::DockerClient,
@@ -640,3 +728,51 @@ fn remove_dir_if_exists(path: Option<PathBuf>, label: &str, quiet: bool, errors:
640728
println!("Removed {label} directory: {}", style(path.display()).dim());
641729
}
642730
}
731+
732+
#[cfg(test)]
733+
mod tests {
734+
use super::*;
735+
736+
#[test]
737+
fn should_block_iotp_reset_for_exposed_bind_without_force() {
738+
let config = opencode_cloud_core::Config {
739+
bind_address: "0.0.0.0".to_string(),
740+
..Default::default()
741+
};
742+
assert!(should_block_iotp_reset(&config, false));
743+
}
744+
745+
#[test]
746+
fn should_allow_iotp_reset_for_exposed_bind_with_force() {
747+
let config = opencode_cloud_core::Config {
748+
bind_address: "::".to_string(),
749+
..Default::default()
750+
};
751+
assert!(!should_block_iotp_reset(&config, true));
752+
}
753+
754+
#[test]
755+
fn should_block_iotp_reset_for_specific_non_localhost_without_force() {
756+
let config = opencode_cloud_core::Config {
757+
bind_address: "192.168.1.10".to_string(),
758+
..Default::default()
759+
};
760+
assert!(should_block_iotp_reset(&config, false));
761+
}
762+
763+
#[test]
764+
fn should_allow_iotp_reset_for_localhost_without_force() {
765+
let config = opencode_cloud_core::Config {
766+
bind_address: "127.0.0.1".to_string(),
767+
..Default::default()
768+
};
769+
assert!(!should_block_iotp_reset(&config, false));
770+
}
771+
772+
#[test]
773+
fn iotp_reset_force_message_mentions_reverse_proxy_and_force_flag() {
774+
let message = iotp_reset_force_message("0.0.0.0");
775+
assert!(message.contains("reverse proxy"));
776+
assert!(message.contains("occ reset iotp --force"));
777+
}
778+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
use crate::commands::runtime_shared::mounts::{collect_bind_mounts, mounts_equal};
66
use crate::commands::start::{wait_for_broker_ready, wait_for_service_ready};
77
use crate::constants::COCKPIT_EXPOSED;
8-
use crate::output::{CommandSpinner, format_docker_error, show_docker_error};
8+
use crate::output::{CommandSpinner, format_docker_error, format_service_url, show_docker_error};
99
use anyhow::{Result, anyhow};
1010
use clap::Args;
1111
use console::style;
@@ -242,7 +242,7 @@ pub async fn cmd_restart(
242242
));
243243

244244
if !quiet {
245-
let url = format!("http://{bind_addr}:{port}");
245+
let url = format_service_url(None, bind_addr, port);
246246
println!();
247247
println!("URL: {}", style(&url).cyan());
248248
println!(

0 commit comments

Comments
 (0)