Skip to content

Commit dc817c3

Browse files
authored
fix(cli): check port availability before starting SSH forward (#309)
1 parent 8c17177 commit dc817c3

File tree

7 files changed

+469
-52
lines changed

7 files changed

+469
-52
lines changed

crates/openshell-cli/src/main.rs

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,9 +1093,9 @@ enum SandboxCommands {
10931093
policy: Option<String>,
10941094

10951095
/// Forward a local port to the sandbox before the initial command or shell starts.
1096-
/// Keeps the sandbox alive.
1096+
/// Accepts [bind_address:]port (e.g. 8080, 0.0.0.0:8080). Keeps the sandbox alive.
10971097
#[arg(long, conflicts_with = "no_keep")]
1098-
forward: Option<u16>,
1098+
forward: Option<String>,
10991099

11001100
/// Allocate a pseudo-terminal for the remote command.
11011101
/// Defaults to auto-detection (on when stdin and stdout are terminals).
@@ -1359,8 +1359,8 @@ enum ForwardCommands {
13591359
/// Start forwarding a local port to a sandbox.
13601360
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
13611361
Start {
1362-
/// Port to forward (used as both local and remote port).
1363-
port: u16,
1362+
/// Port to forward: [bind_address:]port (e.g. 8080, 0.0.0.0:8080).
1363+
port: String,
13641364

13651365
/// Sandbox name (defaults to last-used sandbox).
13661366
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
@@ -1377,7 +1377,7 @@ enum ForwardCommands {
13771377
/// Port that was forwarded.
13781378
port: u16,
13791379

1380-
/// Sandbox name (defaults to last-used sandbox).
1380+
/// Sandbox name (auto-detected from active forwards if omitted).
13811381
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
13821382
name: Option<String>,
13831383
},
@@ -1575,8 +1575,19 @@ async fn main() -> Result<()> {
15751575
command: Some(fwd_cmd),
15761576
}) => match fwd_cmd {
15771577
ForwardCommands::Stop { port, name } => {
1578-
let gateway_name = resolve_gateway_name(&cli.gateway).unwrap_or_default();
1579-
let name = resolve_sandbox_name(name, &gateway_name)?;
1578+
let name = match name {
1579+
Some(n) => n,
1580+
None => match run::find_forward_by_port(port)? {
1581+
Some(n) => {
1582+
eprintln!("→ Found forward on sandbox '{n}'");
1583+
n
1584+
}
1585+
None => {
1586+
eprintln!("{} No active forward found for port {port}", "!".yellow(),);
1587+
return Ok(());
1588+
}
1589+
},
1590+
};
15801591
if run::stop_forward(&name, port)? {
15811592
eprintln!(
15821593
"{} Stopped forward of port {port} for sandbox {name}",
@@ -1600,12 +1611,20 @@ async fn main() -> Result<()> {
16001611
.max()
16011612
.unwrap_or(7)
16021613
.max(7);
1614+
let bind_width = forwards
1615+
.iter()
1616+
.map(|f| f.bind_addr.len())
1617+
.max()
1618+
.unwrap_or(4)
1619+
.max(4);
16031620
println!(
1604-
"{:<width$} {:<8} {:<10} STATUS",
1621+
"{:<nw$} {:<bw$} {:<8} {:<10} STATUS",
16051622
"SANDBOX",
1623+
"BIND",
16061624
"PORT",
16071625
"PID",
1608-
width = name_width,
1626+
nw = name_width,
1627+
bw = bind_width,
16091628
);
16101629
for f in &forwards {
16111630
let status = if f.alive {
@@ -1614,12 +1633,14 @@ async fn main() -> Result<()> {
16141633
"dead".red().to_string()
16151634
};
16161635
println!(
1617-
"{:<width$} {:<8} {:<10} {}",
1636+
"{:<nw$} {:<bw$} {:<8} {:<10} {}",
16181637
f.sandbox,
1638+
f.bind_addr,
16191639
f.port,
16201640
f.pid,
16211641
status,
1622-
width = name_width,
1642+
nw = name_width,
1643+
bw = bind_width,
16231644
);
16241645
}
16251646
}
@@ -1629,18 +1650,20 @@ async fn main() -> Result<()> {
16291650
name,
16301651
background,
16311652
} => {
1653+
let spec = openshell_core::forward::ForwardSpec::parse(&port)?;
16321654
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
16331655
let mut tls = tls.with_gateway_name(&ctx.name);
16341656
apply_edge_auth(&mut tls, &ctx.name);
16351657
let name = resolve_sandbox_name(name, &ctx.name)?;
1636-
run::sandbox_forward(&ctx.endpoint, &name, port, background, &tls).await?;
1658+
run::sandbox_forward(&ctx.endpoint, &name, &spec, background, &tls).await?;
16371659
if background {
16381660
eprintln!(
1639-
"{} Forwarding port {port} to sandbox {name} in the background",
1661+
"{} Forwarding port {} to sandbox {name} in the background",
16401662
"✓".green().bold(),
1663+
spec.port,
16411664
);
1642-
eprintln!(" Access at: http://127.0.0.1:{port}/");
1643-
eprintln!(" Stop with: openshell forward stop {port} {name}");
1665+
eprintln!(" Access at: {}", spec.access_url());
1666+
eprintln!(" Stop with: openshell forward stop {} {name}", spec.port);
16441667
}
16451668
}
16461669
},
@@ -1864,6 +1887,9 @@ async fn main() -> Result<()> {
18641887
});
18651888

18661889
let editor = editor.map(Into::into);
1890+
let forward = forward
1891+
.map(|s| openshell_core::forward::ForwardSpec::parse(&s))
1892+
.transpose()?;
18671893
let keep = keep || !no_keep || editor.is_some() || forward.is_some();
18681894

18691895
// For `sandbox create`, a missing cluster is not fatal — the

crates/openshell-cli/src/run.rs

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ pub use crate::ssh::{
4848
sandbox_connect, sandbox_connect_editor, sandbox_exec, sandbox_forward, sandbox_ssh_proxy,
4949
sandbox_ssh_proxy_by_name, sandbox_sync_down, sandbox_sync_up, sandbox_sync_up_files,
5050
};
51-
pub use openshell_core::forward::{list_forwards, stop_forward, stop_forwards_for_sandbox};
51+
pub use openshell_core::forward::{
52+
find_forward_by_port, list_forwards, stop_forward, stop_forwards_for_sandbox,
53+
};
5254

5355
/// Convert a sandbox phase integer to a human-readable string.
5456
fn phase_name(phase: i32) -> &'static str {
@@ -1726,7 +1728,7 @@ pub async fn sandbox_create_with_bootstrap(
17261728
ssh_key: Option<&str>,
17271729
providers: &[String],
17281730
policy: Option<&str>,
1729-
forward: Option<u16>,
1731+
forward: Option<openshell_core::forward::ForwardSpec>,
17301732
command: &[String],
17311733
tty_override: Option<bool>,
17321734
bootstrap_override: Option<bool>,
@@ -1767,7 +1769,10 @@ pub async fn sandbox_create_with_bootstrap(
17671769
.await
17681770
}
17691771

1770-
fn sandbox_should_persist(keep: bool, forward: Option<u16>) -> bool {
1772+
fn sandbox_should_persist(
1773+
keep: bool,
1774+
forward: Option<&openshell_core::forward::ForwardSpec>,
1775+
) -> bool {
17711776
keep || forward.is_some()
17721777
}
17731778

@@ -1808,7 +1813,7 @@ pub async fn sandbox_create(
18081813
ssh_key: Option<&str>,
18091814
providers: &[String],
18101815
policy: Option<&str>,
1811-
forward: Option<u16>,
1816+
forward: Option<openshell_core::forward::ForwardSpec>,
18121817
command: &[String],
18131818
tty_override: Option<bool>,
18141819
bootstrap_override: Option<bool>,
@@ -1821,6 +1826,12 @@ pub async fn sandbox_create(
18211826
));
18221827
}
18231828

1829+
// Check port availability *before* creating the sandbox so we don't
1830+
// leave an orphaned sandbox behind when the forward would fail.
1831+
if let Some(ref spec) = forward {
1832+
openshell_core::forward::check_port_available(spec)?;
1833+
}
1834+
18241835
// Try connecting to the gateway. If the connection fails due to a
18251836
// connectivity error and bootstrap is allowed, start a new gateway.
18261837
//
@@ -1918,7 +1929,7 @@ pub async fn sandbox_create(
19181929
.ok_or_else(|| miette::miette!("sandbox missing from response"))?;
19191930

19201931
let interactive = std::io::stdout().is_terminal();
1921-
let persist = sandbox_should_persist(keep, forward);
1932+
let persist = sandbox_should_persist(keep, forward.as_ref());
19221933
let sandbox_name = sandbox.name.clone();
19231934

19241935
// Record this sandbox as the last-used for the active gateway only when it
@@ -2195,21 +2206,25 @@ pub async fn sandbox_create(
21952206
// If --forward was requested, start the background port forward
21962207
// *before* running the command so that long-running processes
21972208
// (e.g. `openclaw gateway`) are reachable immediately.
2198-
if let Some(port) = forward {
2209+
if let Some(ref spec) = forward {
21992210
sandbox_forward(
22002211
&effective_server,
22012212
&sandbox_name,
2202-
port,
2213+
spec,
22032214
true, // background
22042215
&effective_tls,
22052216
)
22062217
.await?;
22072218
eprintln!(
2208-
" {} Forwarding port {port} to sandbox {sandbox_name} in the background\n",
2219+
" {} Forwarding port {} to sandbox {sandbox_name} in the background\n",
22092220
"\u{2713}".green().bold(),
2221+
spec.port,
2222+
);
2223+
eprintln!(" Access at: {}", spec.access_url());
2224+
eprintln!(
2225+
" Stop with: openshell forward stop {} {sandbox_name}",
2226+
spec.port,
22102227
);
2211-
eprintln!(" Access at: http://127.0.0.1:{port}/");
2212-
eprintln!(" Stop with: openshell forward stop {port} {sandbox_name}",);
22132228
}
22142229

22152230
if let Some(editor) = editor {
@@ -4420,7 +4435,8 @@ mod tests {
44204435

44214436
#[test]
44224437
fn sandbox_should_persist_when_forward_is_requested() {
4423-
assert!(sandbox_should_persist(false, Some(8080)));
4438+
let spec = openshell_core::forward::ForwardSpec::new(8080);
4439+
assert!(sandbox_should_persist(false, Some(&spec)));
44244440
}
44254441

44264442
#[test]

crates/openshell-cli/src/ssh.rs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,12 @@ pub async fn sandbox_connect_editor(
307307
pub async fn sandbox_forward(
308308
server: &str,
309309
name: &str,
310-
port: u16,
310+
spec: &openshell_core::forward::ForwardSpec,
311311
background: bool,
312312
tls: &TlsOptions,
313313
) -> Result<()> {
314+
openshell_core::forward::check_port_available(spec)?;
315+
314316
let session = ssh_session_config(server, name, tls).await?;
315317

316318
let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command));
@@ -319,7 +321,7 @@ pub async fn sandbox_forward(
319321
.arg("-o")
320322
.arg("ExitOnForwardFailure=yes")
321323
.arg("-L")
322-
.arg(format!("{port}:127.0.0.1:{port}"));
324+
.arg(spec.ssh_forward_arg());
323325

324326
if background {
325327
// SSH -f: fork to background after authentication.
@@ -332,14 +334,16 @@ pub async fn sandbox_forward(
332334
.stdout(Stdio::inherit())
333335
.stderr(Stdio::inherit());
334336

337+
let port = spec.port;
338+
335339
let status = if background {
336340
command.status().await.into_diagnostic()?
337341
} else {
338342
let mut child = command.spawn().into_diagnostic()?;
339343
match tokio::time::timeout(FOREGROUND_FORWARD_STARTUP_GRACE_PERIOD, child.wait()).await {
340344
Ok(status) => status.into_diagnostic()?,
341345
Err(_) => {
342-
eprintln!("{}", foreground_forward_started_message(name, port));
346+
eprintln!("{}", foreground_forward_started_message(name, spec));
343347
child.wait().await.into_diagnostic()?
344348
}
345349
}
@@ -352,7 +356,7 @@ pub async fn sandbox_forward(
352356
if background {
353357
// SSH has forked — find its PID and record it.
354358
if let Some(pid) = find_ssh_forward_pid(&session.sandbox_id, port) {
355-
write_forward_pid(name, port, pid, &session.sandbox_id)?;
359+
write_forward_pid(name, port, pid, &session.sandbox_id, &spec.bind_addr)?;
356360
} else {
357361
eprintln!(
358362
"{} Could not discover backgrounded SSH process; \
@@ -365,10 +369,15 @@ pub async fn sandbox_forward(
365369
Ok(())
366370
}
367371

368-
fn foreground_forward_started_message(name: &str, port: u16) -> String {
372+
fn foreground_forward_started_message(
373+
name: &str,
374+
spec: &openshell_core::forward::ForwardSpec,
375+
) -> String {
369376
format!(
370-
"{} Forwarding port {port} to sandbox {name}\n Access at: http://127.0.0.1:{port}/\n Press Ctrl+C to stop\n {}",
377+
"{} Forwarding port {} to sandbox {name}\n Access at: {}\n Press Ctrl+C to stop\n {}",
371378
"✓".green().bold(),
379+
spec.port,
380+
spec.access_url(),
372381
"Hint: pass --background to start forwarding without blocking your terminal".dimmed(),
373382
)
374383
}
@@ -1130,7 +1139,8 @@ mod tests {
11301139

11311140
#[test]
11321141
fn foreground_forward_started_message_includes_port_and_stop_hint() {
1133-
let message = foreground_forward_started_message("demo", 8080);
1142+
let spec = openshell_core::forward::ForwardSpec::new(8080);
1143+
let message = foreground_forward_started_message("demo", &spec);
11341144
assert!(message.contains("Forwarding port 8080 to sandbox demo"));
11351145
assert!(message.contains("Access at: http://127.0.0.1:8080/"));
11361146
assert!(message.contains("sandbox demo"));
@@ -1139,4 +1149,12 @@ mod tests {
11391149
"Hint: pass --background to start forwarding without blocking your terminal"
11401150
));
11411151
}
1152+
1153+
#[test]
1154+
fn foreground_forward_started_message_custom_bind_addr() {
1155+
let spec = openshell_core::forward::ForwardSpec::parse("0.0.0.0:3000").unwrap();
1156+
let message = foreground_forward_started_message("demo", &spec);
1157+
assert!(message.contains("Forwarding port 3000 to sandbox demo"));
1158+
assert!(message.contains("Access at: http://localhost:3000/"));
1159+
}
11421160
}

crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {
704704
None,
705705
&[],
706706
None,
707-
Some(8080),
707+
Some(openshell_core::forward::ForwardSpec::new(8080)),
708708
&["echo".to_string(), "OK".to_string()],
709709
Some(false),
710710
Some(false),

0 commit comments

Comments
 (0)