@@ -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
613660fn 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 ;
0 commit comments