Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker";
/// Default domain used for browser-facing sandbox service URLs.
pub const DEFAULT_SERVICE_ROUTING_DOMAIN: &str = "openshell.localhost";

/// Default per-sandbox cap for each relay budget (SSH and service forwards).
pub const DEFAULT_MAX_CONNECTIONS_PER_SANDBOX: u32 = 20;

/// Gateway delivery path for supervisor configuration.
///
/// `Push` sends configuration over the supervisor session to supervisors that
Expand Down Expand Up @@ -312,6 +315,14 @@ pub struct Config {
/// TTL for SSH session tokens, in seconds. 0 disables expiry.
pub ssh_session_ttl_secs: u64,

/// Maximum concurrent SSH relays per sandbox. Must be at least 1.
pub max_ssh_connections_per_sandbox: u32,

/// Maximum concurrent service forward relays per sandbox. Must be at
/// least 1. Kept apart from the SSH budget so open forwards cannot block
/// SSH access.
pub max_service_connections_per_sandbox: u32,

/// Absolute image preparation and initial supervisor startup budget for new
/// sandbox attempts, in seconds. Must be between 1 and 86400, inclusive.
pub image_preparation_timeout_seconds: u32,
Expand Down Expand Up @@ -1011,6 +1022,8 @@ impl Config {
credential_drivers: Vec::new(),
default_credential_driver: None,
ssh_session_ttl_secs: default_ssh_session_ttl_secs(),
max_ssh_connections_per_sandbox: DEFAULT_MAX_CONNECTIONS_PER_SANDBOX,
max_service_connections_per_sandbox: DEFAULT_MAX_CONNECTIONS_PER_SANDBOX,
image_preparation_timeout_seconds: 1800,
grpc_rate_limit_requests: None,
grpc_rate_limit_window_secs: None,
Expand Down
81 changes: 81 additions & 0 deletions crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,19 @@ fn prepare_server_config_with_drivers(
config = config.with_ssh_session_ttl_secs(ttl);
}

if let Some(gateway_file) = file.as_ref().map(|f| &f.openshell.gateway) {
apply_connection_cap(
"max_ssh_connections_per_sandbox",
gateway_file.max_ssh_connections_per_sandbox,
&mut config.max_ssh_connections_per_sandbox,
)?;
apply_connection_cap(
"max_service_connections_per_sandbox",
gateway_file.max_service_connections_per_sandbox,
&mut config.max_service_connections_per_sandbox,
)?;
}

if let Some(mode) = file
.as_ref()
.and_then(|f| f.openshell.gateway.policy_validation_failure_mode)
Expand Down Expand Up @@ -1484,6 +1497,17 @@ fn validate_grpc_rate_limit_args(requests: Option<u64>, window_seconds: Option<u
Ok(())
}

/// Apply an optional per-sandbox relay cap from the gateway config file.
fn apply_connection_cap(name: &str, value: Option<u32>, cap: &mut u32) -> Result<()> {
if let Some(value) = value {
if value == 0 {
return Err(miette::miette!("{name} must be at least 1"));
}
*cap = value;
}
Ok(())
}

fn normalize_compute_driver_socket_args(args: &mut RunArgs) -> Result<()> {
let Some(socket) = args.compute_driver_socket.as_ref() else {
return Ok(());
Expand Down Expand Up @@ -4364,6 +4388,63 @@ mem_mib = "not-a-number"
});
}

#[test]
fn server_config_sets_and_validates_per_sandbox_connection_caps() {
let state = tempfile::tempdir().unwrap();
let tls = tempfile::tempdir().unwrap();
Environment::new()
.set("XDG_STATE_HOME", state.path().to_str().unwrap())
.set("OPENSHELL_LOCAL_TLS_DIR", tls.path().to_str().unwrap())
.run(|| {
let state = std::path::PathBuf::from(std::env::var_os("XDG_STATE_HOME").unwrap());
let config_path = state.join("gateway.toml");
let prepare = |gateway: &str| {
std::fs::write(
&config_path,
format!("[openshell]\nversion = 2\n[openshell.gateway]\n{gateway}\n"),
)
.unwrap();
let (mut args, matches) = parse_with_args(&[
"openshell-gateway",
"--config",
config_path.to_str().unwrap(),
"--db-url",
"sqlite::memory:",
"--compute-driver",
"docker",
"--disable-tls",
]);
super::prepare_server_config(&mut args, &matches)
};

let defaults = prepare("").expect("defaults");
assert_eq!(defaults.config.max_ssh_connections_per_sandbox, 20);
assert_eq!(defaults.config.max_service_connections_per_sandbox, 20);

let set = prepare(
"max_ssh_connections_per_sandbox = 5\nmax_service_connections_per_sandbox = 50",
)
.expect("caps are applied");
assert_eq!(set.config.max_ssh_connections_per_sandbox, 5);
assert_eq!(set.config.max_service_connections_per_sandbox, 50);

for key in [
"max_ssh_connections_per_sandbox",
"max_service_connections_per_sandbox",
] {
let Err(error) = prepare(&format!("{key} = 0")) else {
panic!("a zero cap must be rejected");
};
assert!(
error
.to_string()
.contains(&format!("{key} must be at least 1")),
"{error}"
);
}
});
}

#[test]
fn explicit_launch_signing_config_ignores_partial_local_bundle() {
let directory = tempfile::tempdir().unwrap();
Expand Down
7 changes: 7 additions & 0 deletions crates/openshell-server/src/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ pub struct GatewayFileSection {
// ── Sandbox / SSH ────────────────────────────────────────────────────
#[serde(default)]
pub ssh_session_ttl_secs: Option<u64>,
/// Per-sandbox cap on concurrent SSH relays; omit to keep the default of 20.
#[serde(default)]
pub max_ssh_connections_per_sandbox: Option<u32>,
/// Per-sandbox cap on concurrent service forward relays; omit to keep the
/// default of 20.
#[serde(default)]
pub max_service_connections_per_sandbox: Option<u32>,
/// Absolute preparation budget for new attempts; existing deadlines persist.
#[serde(default)]
pub image_preparation_timeout_seconds: Option<u32>,
Expand Down
Loading
Loading