Skip to content

Commit b6c87a7

Browse files
authored
feat(server): add grpc rate limiting gateway-wide (#1566)
Signed-off-by: Adrien Langou <alangou@nvidia.com>
1 parent 1dc5985 commit b6c87a7

11 files changed

Lines changed: 710 additions & 2 deletions

File tree

architecture/gateway.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ health, metrics, or tunnel routes. The plaintext service router also rejects
3737
browser requests whose Fetch Metadata, Origin, or Referer headers indicate a
3838
cross-origin or sibling-subdomain request.
3939

40+
Operators can configure a gateway-wide gRPC request rate limit. The limit is
41+
applied only to gRPC API traffic after protocol multiplexing; health, metrics,
42+
and local sandbox-service HTTP routes are not rate limited by this control.
43+
4044
Supported auth modes:
4145

4246
| Mode | Use |

crates/openshell-core/src/config.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ use std::os::unix::fs::FileTypeExt;
1313
use std::path::{Path, PathBuf};
1414
use std::process::Command;
1515
use std::str::FromStr;
16-
#[cfg(unix)]
1716
use std::time::Duration;
1817

1918
// ── Public default constants ────────────────────────────────────────────
@@ -364,6 +363,20 @@ pub struct Config {
364363
/// TTL for SSH session tokens, in seconds. 0 disables expiry.
365364
pub ssh_session_ttl_secs: u64,
366365

366+
/// Maximum gRPC requests allowed per rate-limit window.
367+
///
368+
/// When paired with [`Self::grpc_rate_limit_window_secs`], positive values
369+
/// enable gateway-wide gRPC request rate limiting. `None` or `0` disables
370+
/// the limit.
371+
pub grpc_rate_limit_requests: Option<u64>,
372+
373+
/// gRPC rate-limit window length in seconds.
374+
///
375+
/// When paired with [`Self::grpc_rate_limit_requests`], positive values
376+
/// enable gateway-wide gRPC request rate limiting. `None` or `0` disables
377+
/// the limit.
378+
pub grpc_rate_limit_window_secs: Option<u64>,
379+
367380
/// Browser-facing sandbox service routing configuration.
368381
pub service_routing: ServiceRoutingConfig,
369382
}
@@ -547,6 +560,8 @@ impl Config {
547560
database_url: String::new(),
548561
compute_drivers: vec![],
549562
ssh_session_ttl_secs: default_ssh_session_ttl_secs(),
563+
grpc_rate_limit_requests: None,
564+
grpc_rate_limit_window_secs: None,
550565
service_routing: ServiceRoutingConfig::default(),
551566
}
552567
}
@@ -614,6 +629,29 @@ impl Config {
614629
self
615630
}
616631

632+
/// Set the gateway-wide gRPC request rate limit.
633+
#[must_use]
634+
pub const fn with_grpc_rate_limit(
635+
mut self,
636+
requests: Option<u64>,
637+
window_secs: Option<u64>,
638+
) -> Self {
639+
self.grpc_rate_limit_requests = requests;
640+
self.grpc_rate_limit_window_secs = window_secs;
641+
self
642+
}
643+
644+
/// Return the effective gRPC rate limit, if fully configured and enabled.
645+
#[must_use]
646+
pub fn grpc_rate_limit(&self) -> Option<(u64, Duration)> {
647+
let requests = self.grpc_rate_limit_requests?;
648+
let window_secs = self.grpc_rate_limit_window_secs?;
649+
if requests == 0 || window_secs == 0 {
650+
None
651+
} else {
652+
Some((requests, Duration::from_secs(window_secs)))
653+
}
654+
}
617655
/// Set the OIDC configuration for JWT-based authentication.
618656
#[must_use]
619657
pub fn with_oidc(mut self, oidc: OidcConfig) -> Self {
@@ -737,6 +775,7 @@ mod tests {
737775
#[cfg(unix)]
738776
use std::os::unix::net::UnixListener;
739777
use std::path::PathBuf;
778+
use std::time::Duration;
740779

741780
#[test]
742781
fn compute_driver_kind_parses_supported_values() {
@@ -794,6 +833,29 @@ mod tests {
794833
assert_eq!(cfg.ttl_secs, 0);
795834
}
796835

836+
#[test]
837+
fn grpc_rate_limit_requires_positive_pair() {
838+
assert!(Config::new(None).grpc_rate_limit().is_none());
839+
assert!(
840+
Config::new(None)
841+
.with_grpc_rate_limit(Some(10), None)
842+
.grpc_rate_limit()
843+
.is_none()
844+
);
845+
assert!(
846+
Config::new(None)
847+
.with_grpc_rate_limit(Some(0), Some(60))
848+
.grpc_rate_limit()
849+
.is_none()
850+
);
851+
assert_eq!(
852+
Config::new(None)
853+
.with_grpc_rate_limit(Some(10), Some(60))
854+
.grpc_rate_limit(),
855+
Some((10, Duration::from_secs(60)))
856+
);
857+
}
858+
797859
#[test]
798860
fn service_routing_allows_loopback_plaintext_http_by_default() {
799861
let cfg = Config::new(None);

crates/openshell-server/src/cli.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,14 @@ struct RunArgs {
175175
#[arg(long, env = "OPENSHELL_OIDC_SCOPES_CLAIM", default_value = "")]
176176
oidc_scopes_claim: String,
177177

178+
/// Maximum gRPC requests allowed per rate-limit window. Set to 0 to disable.
179+
#[arg(long, env = "OPENSHELL_GRPC_RATE_LIMIT_REQUESTS")]
180+
grpc_rate_limit_requests: Option<u64>,
181+
182+
/// gRPC rate-limit window length in seconds. Set to 0 to disable.
183+
#[arg(long, env = "OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS")]
184+
grpc_rate_limit_window_seconds: Option<u64>,
185+
178186
/// Subject Alternative Names configured on the gateway server certificate.
179187
/// Wildcard DNS SANs also enable sandbox service URLs under that domain.
180188
#[arg(
@@ -353,8 +361,16 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> {
353361
config = config
354362
.with_database_url(db_url)
355363
.with_compute_drivers(args.drivers.clone())
364+
.with_grpc_rate_limit(
365+
args.grpc_rate_limit_requests,
366+
args.grpc_rate_limit_window_seconds,
367+
)
356368
.with_server_sans(args.server_sans.clone())
357369
.with_loopback_service_http(args.enable_loopback_service_http);
370+
validate_grpc_rate_limit_args(
371+
args.grpc_rate_limit_requests,
372+
args.grpc_rate_limit_window_seconds,
373+
)?;
358374

359375
if let Some(ttl) = file
360376
.as_ref()
@@ -608,6 +624,37 @@ fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches:
608624
args.oidc_scopes_claim.clone_from(&oidc.scopes_claim);
609625
}
610626
}
627+
if let Some(requests) = file.grpc_rate_limit_requests
628+
&& args.grpc_rate_limit_requests.is_none()
629+
&& arg_defaulted(matches, "grpc_rate_limit_requests")
630+
{
631+
args.grpc_rate_limit_requests = Some(requests);
632+
}
633+
if let Some(window) = file.grpc_rate_limit_window_seconds
634+
&& args.grpc_rate_limit_window_seconds.is_none()
635+
&& arg_defaulted(matches, "grpc_rate_limit_window_seconds")
636+
{
637+
args.grpc_rate_limit_window_seconds = Some(window);
638+
}
639+
}
640+
641+
fn validate_grpc_rate_limit_args(requests: Option<u64>, window_seconds: Option<u64>) -> Result<()> {
642+
let disabled = matches!(requests, Some(0)) || matches!(window_seconds, Some(0));
643+
if disabled {
644+
return Ok(());
645+
}
646+
if matches!(
647+
(requests, window_seconds),
648+
(Some(requests), None) if requests > 0
649+
) || matches!(
650+
(requests, window_seconds),
651+
(None, Some(window_seconds)) if window_seconds > 0
652+
) {
653+
return Err(miette::miette!(
654+
"gRPC rate limiting requires both --grpc-rate-limit-requests and --grpc-rate-limit-window-seconds (TOML keys grpc_rate_limit_requests and grpc_rate_limit_window_seconds) to be positive; set either value to 0 to disable"
655+
));
656+
}
657+
Ok(())
611658
}
612659

613660
fn effective_single_driver(args: &RunArgs) -> Option<ComputeDriverKind> {
@@ -893,6 +940,41 @@ mod tests {
893940
assert!(cli.run.enable_mtls_auth);
894941
}
895942

943+
#[test]
944+
fn command_parses_grpc_rate_limit_flags() {
945+
let _lock = ENV_LOCK
946+
.lock()
947+
.unwrap_or_else(std::sync::PoisonError::into_inner);
948+
let _g1 = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_REQUESTS");
949+
let _g2 = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS");
950+
951+
let cli = Cli::try_parse_from([
952+
"openshell-gateway",
953+
"--db-url",
954+
"sqlite::memory:",
955+
"--grpc-rate-limit-requests",
956+
"120",
957+
"--grpc-rate-limit-window-seconds",
958+
"60",
959+
])
960+
.unwrap();
961+
962+
assert_eq!(cli.run.grpc_rate_limit_requests, Some(120));
963+
assert_eq!(cli.run.grpc_rate_limit_window_seconds, Some(60));
964+
}
965+
966+
#[test]
967+
fn validate_grpc_rate_limit_args_requires_positive_pair() {
968+
assert!(super::validate_grpc_rate_limit_args(None, None).is_ok());
969+
assert!(super::validate_grpc_rate_limit_args(Some(0), None).is_ok());
970+
assert!(super::validate_grpc_rate_limit_args(None, Some(0)).is_ok());
971+
assert!(super::validate_grpc_rate_limit_args(Some(0), Some(60)).is_ok());
972+
assert!(super::validate_grpc_rate_limit_args(Some(120), Some(0)).is_ok());
973+
assert!(super::validate_grpc_rate_limit_args(Some(120), Some(60)).is_ok());
974+
assert!(super::validate_grpc_rate_limit_args(Some(120), None).is_err());
975+
assert!(super::validate_grpc_rate_limit_args(None, Some(60)).is_err());
976+
}
977+
896978
#[test]
897979
fn command_rejects_removed_driver_flags() {
898980
let err = command()
@@ -1316,6 +1398,45 @@ audience = "openshell-cli"
13161398
assert_eq!(args.oidc_audience, "openshell-cli");
13171399
}
13181400

1401+
#[test]
1402+
fn file_grpc_rate_limit_populates_args_when_cli_omits() {
1403+
let (mut args, matches) =
1404+
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
1405+
let file = config_file_from_toml(
1406+
r"
1407+
[openshell.gateway]
1408+
grpc_rate_limit_requests = 100
1409+
grpc_rate_limit_window_seconds = 30
1410+
",
1411+
);
1412+
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);
1413+
1414+
assert_eq!(args.grpc_rate_limit_requests, Some(100));
1415+
assert_eq!(args.grpc_rate_limit_window_seconds, Some(30));
1416+
}
1417+
1418+
#[test]
1419+
fn cli_grpc_rate_limit_overrides_file_value() {
1420+
let (mut args, matches) = parse_with_args(&[
1421+
"openshell-gateway",
1422+
"--db-url",
1423+
"sqlite::memory:",
1424+
"--grpc-rate-limit-requests",
1425+
"20",
1426+
]);
1427+
let file = config_file_from_toml(
1428+
r"
1429+
[openshell.gateway]
1430+
grpc_rate_limit_requests = 100
1431+
grpc_rate_limit_window_seconds = 30
1432+
",
1433+
);
1434+
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);
1435+
1436+
assert_eq!(args.grpc_rate_limit_requests, Some(20));
1437+
assert_eq!(args.grpc_rate_limit_window_seconds, Some(30));
1438+
}
1439+
13191440
#[test]
13201441
fn aux_listener_preserves_file_ip_against_public_bind() {
13211442
use std::net::SocketAddr;

crates/openshell-server/src/config_file.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ pub struct GatewayFileSection {
9494
pub sandbox_namespace: Option<String>,
9595
#[serde(default)]
9696
pub ssh_session_ttl_secs: Option<u64>,
97+
#[serde(default)]
98+
pub grpc_rate_limit_requests: Option<u64>,
99+
#[serde(default)]
100+
pub grpc_rate_limit_window_seconds: Option<u64>,
97101

98102
// ── Service routing ──────────────────────────────────────────────────
99103
/// Subject Alternative Names configured on the gateway server certificate.
@@ -349,6 +353,8 @@ health_bind_address = "0.0.0.0:8081"
349353
log_level = "info"
350354
compute_drivers = ["kubernetes"]
351355
sandbox_namespace = "agents"
356+
grpc_rate_limit_requests = 120
357+
grpc_rate_limit_window_seconds = 60
352358
default_image = "ghcr.io/nvidia/openshell/sandbox:latest"
353359
supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest"
354360
client_tls_secret_name = "openshell-sandbox-tls"
@@ -375,6 +381,8 @@ grpc_endpoint = "https://openshell-gateway.agents.svc:8080"
375381
gw.default_image.as_deref(),
376382
Some("ghcr.io/nvidia/openshell/sandbox:latest")
377383
);
384+
assert_eq!(gw.grpc_rate_limit_requests, Some(120));
385+
assert_eq!(gw.grpc_rate_limit_window_seconds, Some(60));
378386
assert!(gw.tls.is_some());
379387
assert!(gw.oidc.is_some());
380388
assert!(file.openshell.drivers.contains_key("kubernetes"));

crates/openshell-server/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ pub struct ServerState {
132132
/// `IssueSandboxToken` bootstrap path. Only present when the gateway
133133
/// runs in-cluster.
134134
pub k8s_sa_authenticator: Option<Arc<auth::k8s_sa::K8sServiceAccountAuthenticator>>,
135+
136+
/// Gateway-wide gRPC request rate limiter shared by every multiplex path.
137+
pub(crate) grpc_rate_limiter: Option<multiplex::GrpcRateLimiter>,
135138
}
136139

137140
fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool {
@@ -164,6 +167,7 @@ impl ServerState {
164167
supervisor_sessions: Arc<supervisor_session::SupervisorSessionRegistry>,
165168
oidc_cache: Option<Arc<auth::oidc::JwksCache>>,
166169
) -> Self {
170+
let grpc_rate_limiter = multiplex::GrpcRateLimiter::from_config(&config);
167171
Self {
168172
config,
169173
store,
@@ -180,6 +184,7 @@ impl ServerState {
180184
sandbox_jwt_issuer: None,
181185
sandbox_jwt_authenticator: None,
182186
k8s_sa_authenticator: None,
187+
grpc_rate_limiter,
183188
}
184189
}
185190
}

0 commit comments

Comments
 (0)