@@ -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} ;
1314use crate :: commands:: service:: { StopSpinnerMessages , stop_service_with_spinner} ;
1415use crate :: commands:: start:: { StartArgs , cmd_start} ;
1516use crate :: output:: { CommandSpinner , show_docker_error} ;
@@ -21,8 +22,8 @@ use opencode_cloud_core::config::load_config_or_default;
2122use opencode_cloud_core:: config:: paths:: { get_config_dir, get_data_dir} ;
2223use opencode_cloud_core:: config:: save_config;
2324use 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} ;
2728use opencode_cloud_core:: platform:: { get_service_manager, is_service_registration_supported} ;
2829use 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+
93104pub 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.
110198async 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+ }
0 commit comments