Skip to content

Commit 60a9b4d

Browse files
fix(ssh): add EMFILE backoff and exit notification to SSH accept loop (#2705)
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 4d16a2a commit 60a9b4d

3 files changed

Lines changed: 228 additions & 36 deletions

File tree

crates/openshell-sandbox/src/lib.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,21 @@ pub async fn run_sandbox(
852852
}
853853
});
854854

855+
let (ssh_exit_tx, ssh_exit_rx) = if ssh_socket_path.is_some() {
856+
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
857+
(Some(tx), Some(rx))
858+
} else {
859+
(None, None)
860+
};
861+
let ssh_exited: Pin<Box<dyn Future<Output = ()> + Send>> = if let Some(rx) = ssh_exit_rx {
862+
Box::pin(async {
863+
let _ = rx.await;
864+
})
865+
} else {
866+
Box::pin(std::future::pending())
867+
};
868+
tokio::pin!(ssh_exited);
869+
855870
let entrypoint_started_tx =
856871
if process_uses_sidecar_control && let Some(writer) = process_control_writer.clone() {
857872
let (tx, rx) = tokio::sync::oneshot::channel();
@@ -914,6 +929,7 @@ pub async fn run_sandbox(
914929
openshell_endpoint.as_deref(),
915930
ssh_socket_path,
916931
sidecar_network_enforcement,
932+
ssh_exit_tx,
917933
&process_policy,
918934
resolved_process_identity,
919935
process_enforcement_mode,
@@ -965,6 +981,21 @@ pub async fn run_sandbox(
965981
"proxy accept loop exited unexpectedly"
966982
));
967983
}
984+
() = &mut ssh_exited => {
985+
ocsf_emit!(
986+
AppLifecycleBuilder::new(ocsf_ctx())
987+
.activity(ActivityId::Fail)
988+
.severity(SeverityId::High)
989+
.status(StatusId::Failure)
990+
.message(
991+
"SSH accept loop exited unexpectedly; terminating sandbox"
992+
)
993+
.build()
994+
);
995+
return Err(miette::miette!(
996+
"SSH accept loop exited unexpectedly"
997+
));
998+
}
968999
}
9691000
} else {
9701001
tokio::select! {
@@ -984,6 +1015,21 @@ pub async fn run_sandbox(
9841015
"proxy accept loop exited unexpectedly"
9851016
));
9861017
}
1018+
() = &mut ssh_exited => {
1019+
ocsf_emit!(
1020+
AppLifecycleBuilder::new(ocsf_ctx())
1021+
.activity(ActivityId::Fail)
1022+
.severity(SeverityId::High)
1023+
.status(StatusId::Failure)
1024+
.message(
1025+
"SSH accept loop exited unexpectedly; terminating sandbox"
1026+
)
1027+
.build()
1028+
);
1029+
return Err(miette::miette!(
1030+
"SSH accept loop exited unexpectedly"
1031+
));
1032+
}
9871033
}
9881034
}
9891035
} else {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ pub async fn run_process(
6767
openshell_endpoint: Option<&str>,
6868
ssh_socket_path: Option<String>,
6969
shared_ssh_socket: bool,
70+
ssh_exit_tx: Option<tokio::sync::oneshot::Sender<()>>,
7071
policy: &SandboxPolicy,
7172
resolved_process_identity: ResolvedProcessIdentity,
7273
enforcement_mode: ProcessEnforcementMode,
@@ -292,6 +293,7 @@ pub async fn run_process(
292293
let (ssh_ready_tx, ssh_ready_rx) = tokio::sync::oneshot::channel();
293294

294295
tokio::spawn(async move {
296+
let _ssh_exit_guard = ssh_exit_tx;
295297
if let Err(err) = crate::ssh::run_ssh_server(
296298
listen_path,
297299
ssh_ready_tx,

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

Lines changed: 180 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use crate::process::{
1212
drop_privileges_with_identity, is_supervisor_only_env_var, session_user_and_home,
1313
};
1414
use crate::sandbox;
15+
#[cfg(unix)]
16+
use libc;
1517
use miette::{IntoDiagnostic, Result};
1618
use nix::pty::{Winsize, openpty};
1719
use nix::unistd::setsid;
@@ -143,44 +145,186 @@ pub async fn run_ssh_server(
143145
}
144146
};
145147

146-
loop {
147-
let (stream, _peer) = listener.accept().await.into_diagnostic()?;
148-
let config = config.clone();
149-
let policy = policy.clone();
150-
let workspace = workspace.clone();
151-
let proxy_url = proxy_url.clone();
152-
let ca_paths = ca_paths.clone();
153-
let provider_credentials = provider_credentials.clone();
154-
let user_environment = user_environment.clone();
155-
let main_session = Arc::clone(&main_session);
148+
let mut consecutive_resource_errors: u32 = 0;
149+
let mut consecutive_unknown_errors: u32 = 0;
156150

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

0 commit comments

Comments
 (0)