Gray Action for API by #630

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-04-14 19:19:06 +03:00
parent 13f86062f4
commit 57dca639f0
4 changed files with 103 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
#![allow(clippy::too_many_arguments)] #![allow(clippy::too_many_arguments)]
use std::convert::Infallible; use std::io::{Error as IoError, ErrorKind};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@@ -16,7 +16,7 @@ use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock, watch}; use tokio::sync::{Mutex, RwLock, watch};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::config::ProxyConfig; use crate::config::{ApiGrayAction, ProxyConfig};
use crate::ip_tracker::UserIpTracker; use crate::ip_tracker::UserIpTracker;
use crate::proxy::route_mode::RouteRuntimeController; use crate::proxy::route_mode::RouteRuntimeController;
use crate::startup::StartupTracker; use crate::startup::StartupTracker;
@@ -184,8 +184,10 @@ pub async fn serve(
.serve_connection(hyper_util::rt::TokioIo::new(stream), svc) .serve_connection(hyper_util::rt::TokioIo::new(stream), svc)
.await .await
{ {
if !error.is_user() {
debug!(error = %error, "API connection error"); debug!(error = %error, "API connection error");
} }
}
}); });
} }
} }
@@ -195,7 +197,7 @@ async fn handle(
peer: SocketAddr, peer: SocketAddr,
shared: Arc<ApiShared>, shared: Arc<ApiShared>,
config_rx: watch::Receiver<Arc<ProxyConfig>>, config_rx: watch::Receiver<Arc<ProxyConfig>>,
) -> Result<Response<Full<Bytes>>, Infallible> { ) -> Result<Response<Full<Bytes>>, IoError> {
let request_id = shared.next_request_id(); let request_id = shared.next_request_id();
let cfg = config_rx.borrow().clone(); let cfg = config_rx.borrow().clone();
let api_cfg = &cfg.server.api; 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())) if !api_cfg.whitelist.is_empty() && !api_cfg.whitelist.iter().any(|net| net.contains(peer.ip()))
{ {
return Ok(error_response( return match api_cfg.gray_action {
ApiGrayAction::Api => Ok(error_response(
request_id, request_id,
ApiFailure::new( ApiFailure::new(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"forbidden", "forbidden",
"Source IP is not allowed", "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() { if !api_cfg.auth_header.is_empty() {

View File

@@ -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 if old.server.api.enabled != new.server.api.enabled
|| old.server.api.listen != new.server.api.listen || old.server.api.listen != new.server.api.listen
|| old.server.api.whitelist != new.server.api.whitelist || 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.auth_header != new.server.api.auth_header
|| old.server.api.request_body_limit_bytes != new.server.api.request_body_limit_bytes || 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 || old.server.api.minimal_runtime_enabled != new.server.api.minimal_runtime_enabled

View File

@@ -1492,6 +1492,7 @@ mod tests {
assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop); assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop);
assert_eq!(cfg.server.api.listen, default_api_listen()); assert_eq!(cfg.server.api.listen, default_api_listen());
assert_eq!(cfg.server.api.whitelist, default_api_whitelist()); assert_eq!(cfg.server.api.whitelist, default_api_whitelist());
assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop);
assert_eq!( assert_eq!(
cfg.server.api.request_body_limit_bytes, cfg.server.api.request_body_limit_bytes,
default_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.listen, default_api_listen());
assert_eq!(server.api.whitelist, default_api_whitelist()); assert_eq!(server.api.whitelist, default_api_whitelist());
assert_eq!(server.api.gray_action, ApiGrayAction::Drop);
assert_eq!( assert_eq!(
server.api.request_body_limit_bytes, server.api.request_body_limit_bytes,
default_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] #[test]
fn dc_overrides_allow_string_and_array() { fn dc_overrides_allow_string_and_array() {
let toml = r#" let toml = r#"

View File

@@ -1183,6 +1183,13 @@ pub struct ApiConfig {
#[serde(default = "default_api_whitelist")] #[serde(default = "default_api_whitelist")]
pub whitelist: Vec<IpNetwork>, pub whitelist: Vec<IpNetwork>,
/// 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. /// Optional static value for `Authorization` header validation.
/// Empty string disables header auth. /// Empty string disables header auth.
#[serde(default)] #[serde(default)]
@@ -1227,6 +1234,7 @@ impl Default for ApiConfig {
enabled: default_true(), enabled: default_true(),
listen: default_api_listen(), listen: default_api_listen(),
whitelist: default_api_whitelist(), whitelist: default_api_whitelist(),
gray_action: ApiGrayAction::default(),
auth_header: String::new(), auth_header: String::new(),
request_body_limit_bytes: default_api_request_body_limit_bytes(), request_body_limit_bytes: default_api_request_body_limit_bytes(),
minimal_runtime_enabled: default_api_minimal_runtime_enabled(), 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ConntrackMode { pub enum ConntrackMode {