From 57dca639f0ce9ee44ce8ddfee06364f2993ce4af Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:19:06 +0300 Subject: [PATCH] Gray Action for API by #630 Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/api/mod.rs | 37 +++++++++++++++++++-------- src/config/hot_reload.rs | 1 + src/config/load.rs | 55 ++++++++++++++++++++++++++++++++++++++++ src/config/types.rs | 21 +++++++++++++++ 4 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index e60a375..5f4861e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,6 @@ #![allow(clippy::too_many_arguments)] -use std::convert::Infallible; +use std::io::{Error as IoError, ErrorKind}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; @@ -16,7 +16,7 @@ use tokio::net::TcpListener; use tokio::sync::{Mutex, RwLock, watch}; use tracing::{debug, info, warn}; -use crate::config::ProxyConfig; +use crate::config::{ApiGrayAction, ProxyConfig}; use crate::ip_tracker::UserIpTracker; use crate::proxy::route_mode::RouteRuntimeController; use crate::startup::StartupTracker; @@ -184,7 +184,9 @@ pub async fn serve( .serve_connection(hyper_util::rt::TokioIo::new(stream), svc) .await { - debug!(error = %error, "API connection error"); + if !error.is_user() { + debug!(error = %error, "API connection error"); + } } }); } @@ -195,7 +197,7 @@ async fn handle( peer: SocketAddr, shared: Arc, config_rx: watch::Receiver>, -) -> Result>, Infallible> { +) -> Result>, IoError> { let request_id = shared.next_request_id(); let cfg = config_rx.borrow().clone(); let api_cfg = &cfg.server.api; @@ -213,14 +215,27 @@ async fn handle( if !api_cfg.whitelist.is_empty() && !api_cfg.whitelist.iter().any(|net| net.contains(peer.ip())) { - return Ok(error_response( - request_id, - ApiFailure::new( - StatusCode::FORBIDDEN, - "forbidden", - "Source IP is not allowed", + return match api_cfg.gray_action { + ApiGrayAction::Api => Ok(error_response( + request_id, + ApiFailure::new( + StatusCode::FORBIDDEN, + "forbidden", + "Source IP is not allowed", + ), + )), + ApiGrayAction::Ok200 => Ok( + Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html; charset=utf-8") + .body(Full::new(Bytes::new())) + .unwrap(), ), - )); + ApiGrayAction::Drop => Err(IoError::new( + ErrorKind::ConnectionAborted, + "api request dropped by gray_action=drop", + )), + }; } if !api_cfg.auth_header.is_empty() { diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 61c36eb..f481798 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -560,6 +560,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b if old.server.api.enabled != new.server.api.enabled || old.server.api.listen != new.server.api.listen || old.server.api.whitelist != new.server.api.whitelist + || old.server.api.gray_action != new.server.api.gray_action || old.server.api.auth_header != new.server.api.auth_header || old.server.api.request_body_limit_bytes != new.server.api.request_body_limit_bytes || old.server.api.minimal_runtime_enabled != new.server.api.minimal_runtime_enabled diff --git a/src/config/load.rs b/src/config/load.rs index b7bc9fa..481cf9d 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1492,6 +1492,7 @@ mod tests { assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop); assert_eq!(cfg.server.api.listen, default_api_listen()); assert_eq!(cfg.server.api.whitelist, default_api_whitelist()); + assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop); assert_eq!( cfg.server.api.request_body_limit_bytes, default_api_request_body_limit_bytes() @@ -1662,6 +1663,7 @@ mod tests { ); assert_eq!(server.api.listen, default_api_listen()); assert_eq!(server.api.whitelist, default_api_whitelist()); + assert_eq!(server.api.gray_action, ApiGrayAction::Drop); assert_eq!( server.api.request_body_limit_bytes, default_api_request_body_limit_bytes() @@ -1809,6 +1811,59 @@ mod tests { ); } + #[test] + fn api_gray_action_parses_and_defaults_to_drop() { + let cfg_default: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + "#, + ) + .unwrap(); + assert_eq!(cfg_default.server.api.gray_action, ApiGrayAction::Drop); + + let cfg_api: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "api" + "#, + ) + .unwrap(); + assert_eq!(cfg_api.server.api.gray_action, ApiGrayAction::Api); + + let cfg_200: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "200" + "#, + ) + .unwrap(); + assert_eq!(cfg_200.server.api.gray_action, ApiGrayAction::Ok200); + + let cfg_drop: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [server.api] + gray_action = "drop" + "#, + ) + .unwrap(); + assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop); + } + #[test] fn dc_overrides_allow_string_and_array() { let toml = r#" diff --git a/src/config/types.rs b/src/config/types.rs index e287246..ee52cb7 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1183,6 +1183,13 @@ pub struct ApiConfig { #[serde(default = "default_api_whitelist")] pub whitelist: Vec, + /// Behavior for requests from source IPs outside `whitelist`. + /// - `api`: return structured API forbidden response. + /// - `200`: return `200 OK` with an empty body. + /// - `drop`: close the connection without HTTP response. + #[serde(default)] + pub gray_action: ApiGrayAction, + /// Optional static value for `Authorization` header validation. /// Empty string disables header auth. #[serde(default)] @@ -1227,6 +1234,7 @@ impl Default for ApiConfig { enabled: default_true(), listen: default_api_listen(), whitelist: default_api_whitelist(), + gray_action: ApiGrayAction::default(), auth_header: String::new(), request_body_limit_bytes: default_api_request_body_limit_bytes(), minimal_runtime_enabled: default_api_minimal_runtime_enabled(), @@ -1240,6 +1248,19 @@ impl Default for ApiConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ApiGrayAction { + /// Preserve current API behavior for denied source IPs. + Api, + /// Mimic a plain web endpoint by returning `200 OK` with an empty body. + #[serde(rename = "200")] + Ok200, + /// Drop connection without HTTP response for denied source IPs. + #[default] + Drop, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ConntrackMode {