Skip to content

Commit 16ecda1

Browse files
committed
fix(ssh): add EMFILE backoff and exit notification to SSH accept loop
Apply the same two-layer defense from the proxy accept loop (#2369/#2370) to the SSH accept loop: classify transient vs terminal accept errors with exponential backoff on EMFILE/resource-exhaustion, and notify the sandbox when the accept loop exits so the container terminates instead of running without SSH access. - Add SshAcceptAction enum and classify_ssh_accept_error in ssh.rs, mirroring the proxy pattern (EMFILE/ENFILE/ENOBUFS → Retry with backoff, unknown errors → Terminal after 10 consecutive failures) - Replace the bare accept().await in run_ssh_server with a classify-and- retry loop; resets consecutive-error counter on each successful accept - Thread ssh_exit_tx: Option<oneshot::Sender<()>> through run_process; hold it as a drop-guard inside the SSH spawn so the receiver fires when the task ends for any reason - Wire ssh_exited future in lib.rs (created only when ssh_socket_path is Some) and select! on it in both process_enabled paths, returning an error so the sandbox container restarts Closes #2372 Signed-off-by: politerealism <burdcat17@gmail.com>
1 parent add923a commit 16ecda1

3 files changed

Lines changed: 226 additions & 34 deletions

File tree

crates/openshell-sandbox/src/lib.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,21 @@ pub async fn run_sandbox(
732732
}
733733
});
734734

735+
let (ssh_exit_tx, ssh_exit_rx) = if ssh_socket_path.is_some() {
736+
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
737+
(Some(tx), Some(rx))
738+
} else {
739+
(None, None)
740+
};
741+
let ssh_exited: Pin<Box<dyn Future<Output = ()> + Send>> = if let Some(rx) = ssh_exit_rx {
742+
Box::pin(async {
743+
let _ = rx.await;
744+
})
745+
} else {
746+
Box::pin(std::future::pending())
747+
};
748+
tokio::pin!(ssh_exited);
749+
735750
let entrypoint_started_tx =
736751
if process_uses_sidecar_control && let Some(writer) = process_control_writer.clone() {
737752
let (tx, rx) = tokio::sync::oneshot::channel();
@@ -764,6 +779,7 @@ pub async fn run_sandbox(
764779
openshell_endpoint.as_deref(),
765780
ssh_socket_path,
766781
sidecar_network_enforcement,
782+
ssh_exit_tx,
767783
&process_policy,
768784
resolved_process_identity,
769785
process_enforcement_mode,
@@ -814,6 +830,21 @@ pub async fn run_sandbox(
814830
"proxy accept loop exited unexpectedly"
815831
));
816832
}
833+
() = &mut ssh_exited => {
834+
ocsf_emit!(
835+
AppLifecycleBuilder::new(ocsf_ctx())
836+
.activity(ActivityId::Fail)
837+
.severity(SeverityId::High)
838+
.status(StatusId::Failure)
839+
.message(
840+
"SSH accept loop exited unexpectedly; terminating sandbox"
841+
)
842+
.build()
843+
);
844+
return Err(miette::miette!(
845+
"SSH accept loop exited unexpectedly"
846+
));
847+
}
817848
}
818849
} else {
819850
tokio::select! {
@@ -833,6 +864,21 @@ pub async fn run_sandbox(
833864
"proxy accept loop exited unexpectedly"
834865
));
835866
}
867+
() = &mut ssh_exited => {
868+
ocsf_emit!(
869+
AppLifecycleBuilder::new(ocsf_ctx())
870+
.activity(ActivityId::Fail)
871+
.severity(SeverityId::High)
872+
.status(StatusId::Failure)
873+
.message(
874+
"SSH accept loop exited unexpectedly; terminating sandbox"
875+
)
876+
.build()
877+
);
878+
return Err(miette::miette!(
879+
"SSH accept loop exited unexpectedly"
880+
));
881+
}
836882
}
837883
}
838884
} else {

crates/openshell-supervisor-process/src/run.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ pub async fn run_process(
6161
openshell_endpoint: Option<&str>,
6262
ssh_socket_path: Option<String>,
6363
shared_ssh_socket: bool,
64+
ssh_exit_tx: Option<tokio::sync::oneshot::Sender<()>>,
6465
policy: &SandboxPolicy,
6566
resolved_process_identity: ResolvedProcessIdentity,
6667
enforcement_mode: ProcessEnforcementMode,
@@ -245,6 +246,7 @@ pub async fn run_process(
245246
let (ssh_ready_tx, ssh_ready_rx) = tokio::sync::oneshot::channel();
246247

247248
tokio::spawn(async move {
249+
let _ssh_exit_guard = ssh_exit_tx;
248250
if let Err(err) = crate::ssh::run_ssh_server(
249251
listen_path,
250252
ssh_ready_tx,

crates/openshell-supervisor-process/src/ssh.rs

Lines changed: 178 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ use crate::process::{
1111
drop_privileges_with_identity, is_supervisor_only_env_var,
1212
};
1313
use crate::sandbox;
14+
#[cfg(unix)]
15+
use libc;
1416
use miette::{IntoDiagnostic, Result};
1517
use nix::pty::{Winsize, openpty};
1618
use nix::unistd::setsid;
@@ -141,42 +143,184 @@ pub async fn run_ssh_server(
141143
}
142144
};
143145

144-
loop {
145-
let (stream, _peer) = listener.accept().await.into_diagnostic()?;
146-
let config = config.clone();
147-
let policy = policy.clone();
148-
let workspace = workspace.clone();
149-
let proxy_url = proxy_url.clone();
150-
let ca_paths = ca_paths.clone();
151-
let provider_credentials = provider_credentials.clone();
152-
let user_environment = user_environment.clone();
146+
let mut consecutive_resource_errors: u32 = 0;
147+
let mut consecutive_unknown_errors: u32 = 0;
153148

154-
tokio::spawn(async move {
155-
if let Err(err) = handle_connection(
156-
stream,
157-
config,
158-
policy,
159-
workspace,
160-
netns_fd,
161-
proxy_url,
162-
ca_paths,
163-
provider_credentials,
164-
user_environment,
165-
resolved_identity,
166-
enforcement_mode,
167-
)
168-
.await
169-
{
170-
ocsf_emit!(
171-
SshActivityBuilder::new(openshell_ocsf::ctx::ctx())
172-
.activity(ActivityId::Fail)
173-
.severity(SeverityId::Low)
174-
.status(StatusId::Failure)
175-
.message(format!("SSH connection failed: {err}"))
176-
.build()
177-
);
149+
loop {
150+
match listener.accept().await {
151+
Ok((stream, _peer)) => {
152+
consecutive_resource_errors = 0;
153+
consecutive_unknown_errors = 0;
154+
let config = config.clone();
155+
let policy = policy.clone();
156+
let workspace = workspace.clone();
157+
let proxy_url = proxy_url.clone();
158+
let ca_paths = ca_paths.clone();
159+
let provider_credentials = provider_credentials.clone();
160+
let user_environment = user_environment.clone();
161+
162+
tokio::spawn(async move {
163+
if let Err(err) = handle_connection(
164+
stream,
165+
config,
166+
policy,
167+
workspace,
168+
netns_fd,
169+
proxy_url,
170+
ca_paths,
171+
provider_credentials,
172+
user_environment,
173+
resolved_identity,
174+
enforcement_mode,
175+
)
176+
.await
177+
{
178+
ocsf_emit!(
179+
SshActivityBuilder::new(openshell_ocsf::ctx::ctx())
180+
.activity(ActivityId::Fail)
181+
.severity(SeverityId::Low)
182+
.status(StatusId::Failure)
183+
.message(format!("SSH connection failed: {err}"))
184+
.build()
185+
);
186+
}
187+
});
178188
}
179-
});
189+
Err(err) => {
190+
match classify_ssh_accept_error(
191+
&err,
192+
&mut consecutive_resource_errors,
193+
&mut consecutive_unknown_errors,
194+
) {
195+
SshAcceptAction::Terminal => {
196+
ocsf_emit!(
197+
SshActivityBuilder::new(openshell_ocsf::ctx::ctx())
198+
.activity(ActivityId::Fail)
199+
.severity(SeverityId::High)
200+
.status(StatusId::Failure)
201+
.message(format!(
202+
"SSH accept loop exiting on terminal error: {err}"
203+
))
204+
.build()
205+
);
206+
break;
207+
}
208+
SshAcceptAction::Retry { backoff, severity } => {
209+
ocsf_emit!(
210+
SshActivityBuilder::new(openshell_ocsf::ctx::ctx())
211+
.activity(ActivityId::Fail)
212+
.severity(severity)
213+
.status(StatusId::Failure)
214+
.message(format!(
215+
"SSH accept error (retrying in {}ms): {err}",
216+
backoff.as_millis(),
217+
))
218+
.build()
219+
);
220+
tokio::time::sleep(backoff).await;
221+
}
222+
}
223+
}
224+
}
225+
}
226+
227+
Ok(())
228+
}
229+
230+
const MAX_CONSECUTIVE_UNKNOWN_SSH_ACCEPT_ERRORS: u32 = 10;
231+
232+
#[derive(Debug, PartialEq)]
233+
enum SshAcceptAction {
234+
Terminal,
235+
Retry {
236+
backoff: Duration,
237+
severity: SeverityId,
238+
},
239+
}
240+
241+
fn classify_ssh_accept_error(
242+
err: &std::io::Error,
243+
consecutive_resource_errors: &mut u32,
244+
consecutive_unknown_errors: &mut u32,
245+
) -> SshAcceptAction {
246+
#[cfg(unix)]
247+
if matches!(
248+
err.raw_os_error(),
249+
Some(libc::EBADF | libc::EINVAL | libc::ENOTSOCK)
250+
) {
251+
return SshAcceptAction::Terminal;
252+
}
253+
254+
#[cfg(unix)]
255+
if matches!(
256+
err.raw_os_error(),
257+
Some(
258+
libc::EMFILE
259+
| libc::ENFILE
260+
| libc::ENOBUFS
261+
| libc::ENOMEM
262+
| libc::ECONNABORTED
263+
| libc::ECONNRESET
264+
| libc::EINTR
265+
| libc::ENETDOWN
266+
| libc::EPROTO
267+
| libc::ENOPROTOOPT
268+
| libc::EHOSTDOWN
269+
| libc::EHOSTUNREACH
270+
| libc::EOPNOTSUPP
271+
| libc::ENETUNREACH
272+
| libc::ENOSR
273+
| libc::ESOCKTNOSUPPORT
274+
| libc::EPROTONOSUPPORT
275+
| libc::ETIMEDOUT
276+
)
277+
) {
278+
*consecutive_unknown_errors = 0;
279+
280+
#[cfg(unix)]
281+
let is_resource_pressure = matches!(
282+
err.raw_os_error(),
283+
Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::ENOSR)
284+
);
285+
#[cfg(not(unix))]
286+
let is_resource_pressure = false;
287+
288+
if is_resource_pressure {
289+
*consecutive_resource_errors = consecutive_resource_errors.saturating_add(1);
290+
let backoff_ms = 100u64
291+
.saturating_mul(1u64 << (*consecutive_resource_errors).min(7).saturating_sub(1))
292+
.min(5_000);
293+
return SshAcceptAction::Retry {
294+
backoff: Duration::from_millis(backoff_ms),
295+
severity: SeverityId::Medium,
296+
};
297+
}
298+
299+
*consecutive_resource_errors = 0;
300+
return SshAcceptAction::Retry {
301+
backoff: Duration::from_millis(100),
302+
severity: SeverityId::Low,
303+
};
304+
}
305+
306+
#[cfg(unix)]
307+
#[cfg(target_os = "linux")]
308+
if matches!(err.raw_os_error(), Some(libc::ENONET)) {
309+
*consecutive_unknown_errors = 0;
310+
*consecutive_resource_errors = 0;
311+
return SshAcceptAction::Retry {
312+
backoff: Duration::from_millis(100),
313+
severity: SeverityId::Low,
314+
};
315+
}
316+
317+
*consecutive_unknown_errors = consecutive_unknown_errors.saturating_add(1);
318+
if *consecutive_unknown_errors >= MAX_CONSECUTIVE_UNKNOWN_SSH_ACCEPT_ERRORS {
319+
return SshAcceptAction::Terminal;
320+
}
321+
SshAcceptAction::Retry {
322+
backoff: Duration::from_millis(100),
323+
severity: SeverityId::Low,
180324
}
181325
}
182326

0 commit comments

Comments
 (0)