diff --git a/Cargo.lock b/Cargo.lock index eae4fa3b..9856a4fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1773,6 +1773,7 @@ dependencies = [ "hickory-resolver", "http", "http-body-util", + "httpdate", "hyper", "hyper-rustls", "hyper-util", @@ -1796,6 +1797,7 @@ dependencies = [ "tokio-rustls", "tower-service", "tracing", + "uuid", ] [[package]] @@ -2505,6 +2507,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2de165cf..7e377738 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ futures-util = "0.3" flate2 = "1.1" http = "1.4" http-body-util = "0.1" +httpdate = "1.0" h3 = "0.0.8" h3-quinn = "0.0.10" hickory-resolver = "0.24.4" @@ -56,4 +57,5 @@ tokio-rustls = "0.26" tower-service = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +uuid = { version = "1.18", features = ["v7", "std"] } webpki = { package = "rustls-webpki", version = "0.103.11", default-features = false, features = ["std", "aws-lc-rs"] } diff --git a/RELEASE_NOTES_v0.1.3-rc.13.md b/RELEASE_NOTES_v0.1.3-rc.13.md index 6b09c1d2..8b4495b0 100644 --- a/RELEASE_NOTES_v0.1.3-rc.13.md +++ b/RELEASE_NOTES_v0.1.3-rc.13.md @@ -62,5 +62,4 @@ Release-oriented validation passed for `v0.1.3-rc.13` with: short smoke path behind manual dispatch - fuzz smoke is currently a prerelease gate, not a stable-release-only differentiator -- the nginx comparison harness remains external to the normal test matrix and is - still optional for prerelease preparation +- the HTTP/3 release gate does not include an in-repo nginx comparison harness diff --git a/crates/rginx-app/tests/phase1.rs b/crates/rginx-app/tests/phase1.rs index fad1268f..8855a8a7 100644 --- a/crates/rginx-app/tests/phase1.rs +++ b/crates/rginx-app/tests/phase1.rs @@ -267,11 +267,19 @@ fn parse_http_response(bytes: &[u8]) -> Result { fn assert_generated_request_id(value: Option<&str>) { let value = value.expect("response should include x-request-id"); - assert_eq!(value.len(), "rginx-0000000000000000".len()); - assert!(value.starts_with("rginx-"), "generated request id should use the rginx- prefix"); + assert_eq!(value.len(), 36, "generated request id should be a UUIDv7"); + assert_eq!(value.as_bytes()[8], b'-'); + assert_eq!(value.as_bytes()[13], b'-'); + assert_eq!(value.as_bytes()[14], b'7', "generated request id should be UUIDv7"); + assert_eq!(value.as_bytes()[18], b'-'); assert!( - value["rginx-".len()..].chars().all(|ch| ch.is_ascii_hexdigit()), - "generated request id should end with lowercase hex digits, got {value:?}" + matches!(value.as_bytes()[19].to_ascii_lowercase(), b'8' | b'9' | b'a' | b'b'), + "generated request id should use the RFC 4122 variant, got {value:?}" + ); + assert_eq!(value.as_bytes()[23], b'-'); + assert!( + value.chars().filter(|ch| *ch != '-').all(|ch| ch.is_ascii_hexdigit()), + "generated request id should use UUID hex digits, got {value:?}" ); } diff --git a/crates/rginx-app/tests/upstream_http2.rs b/crates/rginx-app/tests/upstream_http2.rs index 2e919323..a91a2af0 100644 --- a/crates/rginx-app/tests/upstream_http2.rs +++ b/crates/rginx-app/tests/upstream_http2.rs @@ -32,6 +32,7 @@ const TEST_SERVER_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkq struct ObservedRequest { version: Version, path: String, + authority: Option, alpn_protocol: Option, } @@ -63,6 +64,37 @@ async fn proxies_to_https_upstreams_over_http2_when_alpn_negotiates_h2() { fs::remove_dir_all(upstream_temp_dir).expect("upstream temp dir should be removed"); } +#[tokio::test(flavor = "multi_thread")] +async fn preserves_hostname_authority_when_h2_upstream_dials_resolved_ip() { + let (upstream_addr, observed_rx, upstream_task, upstream_temp_dir) = spawn_h2_upstream().await; + + let listen_addr = reserve_loopback_addr(); + let upstream_url = format!("https://localhost:{}", upstream_addr.port()); + let mut server = ServerHarness::spawn("rginx-upstream-h2-authority", |_| { + proxy_config_for_upstream_url(listen_addr, &upstream_url) + }); + server.wait_for_http_ready(listen_addr, Duration::from_secs(5)); + + let (status, body) = fetch_text_response(listen_addr, "/") + .expect("rginx should return a successful upstream response"); + assert_eq!(status, 200); + assert_eq!(body, "upstream h2 ok\n"); + + let observed = tokio::time::timeout(Duration::from_secs(5), observed_rx) + .await + .expect("upstream request should be observed before timeout") + .expect("upstream observation channel should complete"); + assert_eq!(observed.version, Version::HTTP_2); + assert_eq!(observed.path, "/"); + assert_eq!(observed.alpn_protocol.as_deref(), Some("h2")); + let expected_authority = upstream_url.strip_prefix("https://").unwrap(); + assert_eq!(observed.authority.as_deref(), Some(expected_authority)); + + server.shutdown_and_wait(Duration::from_secs(5)); + upstream_task.await.expect("upstream h2 server task should finish"); + fs::remove_dir_all(upstream_temp_dir).expect("upstream temp dir should be removed"); +} + async fn spawn_h2_upstream() -> (SocketAddr, oneshot::Receiver, JoinHandle<()>, PathBuf) { let temp_dir = temp_dir("rginx-upstream-h2"); @@ -109,6 +141,7 @@ async fn spawn_h2_upstream() let _ = sender.send(ObservedRequest { version: request.version(), path: request.uri().path().to_string(), + authority: request.uri().authority().map(|value| value.to_string()), alpn_protocol, }); } @@ -133,10 +166,17 @@ async fn spawn_h2_upstream() } fn proxy_config(listen_addr: SocketAddr, upstream_addr: SocketAddr) -> String { + proxy_config_for_upstream_url( + listen_addr, + &format!("https://127.0.0.1:{}", upstream_addr.port()), + ) +} + +fn proxy_config_for_upstream_url(listen_addr: SocketAddr, upstream_url: &str) -> String { format!( "Config(\n runtime: RuntimeConfig(\n shutdown_timeout_secs: 2,\n ),\n server: ServerConfig(\n listen: {:?},\n ),\n upstreams: [\n UpstreamConfig(\n name: \"backend\",\n peers: [\n UpstreamPeerConfig(\n url: {:?},\n ),\n ],\n tls: Some(Insecure),\n protocol: Auto,\n server_name_override: Some(\"localhost\"),\n ),\n ],\n locations: [\n{ready_route} LocationConfig(\n matcher: Exact(\"/\"),\n handler: Proxy(\n upstream: \"backend\",\n ),\n ),\n ],\n)\n", listen_addr.to_string(), - format!("https://127.0.0.1:{}", upstream_addr.port()), + upstream_url, ready_route = READY_ROUTE_CONFIG, ) } diff --git a/crates/rginx-config/src/compile/mod.rs b/crates/rginx-config/src/compile/mod.rs index 607fc656..0cff341c 100644 --- a/crates/rginx-config/src/compile/mod.rs +++ b/crates/rginx-config/src/compile/mod.rs @@ -57,8 +57,9 @@ pub fn compile_with_base(raw: Config, base_dir: impl AsRef) -> Result Result { let ServerConfig { listen, + server_header, proxy_protocol, default_certificate, server_names, @@ -56,6 +58,7 @@ pub(super) fn compile_legacy_server( let compiled = compile_server_fields( ServerFieldConfig { listen, + server_header, default_certificate, trusted_proxies, keep_alive, @@ -93,6 +96,7 @@ pub(super) fn compile_legacy_server( /// Compiles explicit listener blocks into runtime listener definitions. pub(super) fn compile_listeners( listeners: Vec, + default_server_header: Option, base_dir: &Path, ) -> Result> { listeners @@ -101,6 +105,7 @@ pub(super) fn compile_listeners( let ListenerConfig { name, listen, + server_header, proxy_protocol, default_certificate, trusted_proxies, @@ -119,6 +124,7 @@ pub(super) fn compile_listeners( let compiled = compile_server_fields( ServerFieldConfig { listen, + server_header: server_header.or_else(|| default_server_header.clone()), default_certificate, trusted_proxies, keep_alive, @@ -289,6 +295,7 @@ struct CompiledServerFields { struct ServerFieldConfig { listen: String, + server_header: Option, default_certificate: Option, trusted_proxies: Vec, keep_alive: Option, @@ -309,6 +316,7 @@ fn compile_server_fields( ) -> Result { let ServerFieldConfig { listen, + server_header, default_certificate, trusted_proxies, keep_alive, @@ -326,6 +334,7 @@ fn compile_server_fields( Ok(CompiledServerFields { server: Server { listen_addr: listen.parse()?, + server_header: compile_server_header(server_header)?, default_certificate: compile_default_certificate(default_certificate), trusted_proxies: compile_trusted_proxies(trusted_proxies)?, keep_alive: keep_alive.unwrap_or(true), @@ -342,6 +351,14 @@ fn compile_server_fields( }) } +/// Parses the configured `Server` response header value, defaulting to `rginx`. +fn compile_server_header(server_header: Option) -> Result { + let value = server_header.unwrap_or_else(|| DEFAULT_SERVER_HEADER.to_string()); + let value = value.trim(); + HeaderValue::from_str(value) + .map_err(|error| Error::Config(format!("server_header `{value}` is invalid: {error}"))) +} + /// Normalizes the optional default certificate server name. fn compile_default_certificate(default_certificate: Option) -> Option { default_certificate.map(|name| name.trim().to_lowercase()) diff --git a/crates/rginx-config/src/compile/tests.rs b/crates/rginx-config/src/compile/tests.rs index c7d21879..57dd7d02 100644 --- a/crates/rginx-config/src/compile/tests.rs +++ b/crates/rginx-config/src/compile/tests.rs @@ -61,6 +61,7 @@ fn compile_accepts_https_upstreams() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -123,6 +124,7 @@ fn compile_accepts_https_upstreams() { }; let snapshot = compile(config).expect("https upstream should compile"); + assert_eq!(default_listener_server(&snapshot).server_header, "rginx"); let proxy = match &snapshot.default_vhost.routes[0].action { rginx_core::RouteAction::Proxy(proxy) => proxy, _ => panic!("expected proxy route"), @@ -159,6 +161,49 @@ fn compile_accepts_https_upstreams() { assert_eq!(active_health.healthy_successes_required, DEFAULT_HEALTHY_SUCCESSES_REQUIRED); } +#[test] +fn compile_applies_custom_server_header() { + let config = Config { + runtime: RuntimeConfig { + shutdown_timeout_secs: 10, + worker_threads: None, + accept_workers: None, + }, + listeners: Vec::new(), + server: ServerConfig { + listen: Some("127.0.0.1:8080".to_string()), + server_header: Some("edge-rginx".to_string()), + proxy_protocol: None, + default_certificate: None, + server_names: Vec::new(), + trusted_proxies: Vec::new(), + keep_alive: None, + max_headers: None, + max_request_body_bytes: None, + max_connections: None, + header_read_timeout_secs: None, + request_body_read_timeout_secs: None, + response_write_timeout_secs: None, + access_log_format: None, + tls: None, + http3: None, + }, + upstreams: Vec::new(), + locations: vec![test_location( + MatcherConfig::Exact("/".to_string()), + HandlerConfig::Return { + status: 200, + location: String::new(), + body: Some("ok\n".to_string()), + }, + )], + servers: Vec::new(), + }; + + let snapshot = compile(config).expect("custom server_header should compile"); + assert_eq!(default_listener_server(&snapshot).server_header, "edge-rginx"); +} + #[test] fn compile_defaults_grpc_health_check_path_when_service_is_set() { let config = Config { @@ -170,6 +215,7 @@ fn compile_defaults_grpc_health_check_path_when_service_is_set() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -257,6 +303,7 @@ fn compile_applies_granular_upstream_transport_settings() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -349,6 +396,7 @@ fn compile_accepts_least_conn_load_balance() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -452,6 +500,7 @@ fn compile_applies_peer_weights() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -570,6 +619,7 @@ fn compile_accepts_backup_peers() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -688,6 +738,7 @@ fn compile_uses_legacy_request_timeout_fallbacks_and_disables_pool_idle_timeout( listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -793,6 +844,7 @@ fn compile_uses_default_pool_idle_timeout() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -896,6 +948,7 @@ fn compile_resolves_custom_ca_relative_to_config_base() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1021,6 +1074,7 @@ fn compile_accepts_https_http3_upstreams() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1123,6 +1177,7 @@ fn compile_resolves_upstream_mtls_identity_and_tls_versions_relative_to_config_b listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1238,6 +1293,7 @@ fn compile_normalizes_server_name_override() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1334,6 +1390,7 @@ fn compile_preserves_upstream_server_name_toggle() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1436,6 +1493,7 @@ fn compile_rejects_invalid_server_name_override() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1527,6 +1585,7 @@ fn compile_attaches_route_access_control() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1585,6 +1644,7 @@ fn compile_attaches_route_rate_limit() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1645,6 +1705,7 @@ fn compile_applies_route_transport_policy_defaults_and_overrides() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1740,6 +1801,7 @@ fn compile_generates_distinct_route_and_vhost_ids() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: vec!["default.example.com".to_string()], @@ -1805,6 +1867,7 @@ fn compile_resolves_server_tls_paths_relative_to_config_base() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1886,6 +1949,7 @@ fn compile_preserves_server_tls_policy_fields() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -1973,6 +2037,7 @@ fn compile_preserves_server_tls_ocsp_policy_fields() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -2049,6 +2114,7 @@ fn compile_normalizes_trusted_proxy_ips_and_cidrs() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -2108,6 +2174,7 @@ fn compile_attaches_server_hardening_settings() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -2187,6 +2254,7 @@ fn compile_prioritizes_grpc_constrained_routes_with_same_path_matcher() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -2254,6 +2322,7 @@ fn compile_rejects_invalid_server_access_log_format() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), @@ -2311,6 +2380,7 @@ fn compile_supports_explicit_multi_listener_configs() { listeners: vec![ ListenerConfig { name: "http".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8080".to_string(), @@ -2328,6 +2398,7 @@ fn compile_supports_explicit_multi_listener_configs() { }, ListenerConfig { name: "https".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8443".to_string(), @@ -2346,6 +2417,7 @@ fn compile_supports_explicit_multi_listener_configs() { ], server: ServerConfig { listen: None, + server_header: None, proxy_protocol: None, default_certificate: None, server_names: vec!["example.com".to_string()], @@ -2412,6 +2484,7 @@ fn compile_http3_listener_defaults_to_tcp_listen_addr_and_default_alt_svc_policy listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8443".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: vec!["localhost".to_string()], @@ -2513,6 +2586,7 @@ fn compile_http3_applies_transport_settings_and_resolves_host_key_path() { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8443".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: vec!["localhost".to_string()], diff --git a/crates/rginx-config/src/model.rs b/crates/rginx-config/src/model.rs index 493bf844..4f47dec7 100644 --- a/crates/rginx-config/src/model.rs +++ b/crates/rginx-config/src/model.rs @@ -51,6 +51,7 @@ pub struct Http3Config { #[derive(Debug, Clone)] pub struct ServerConfig { pub listen: Option, + pub server_header: Option, pub proxy_protocol: Option, pub default_certificate: Option, pub server_names: Vec, @@ -72,6 +73,8 @@ pub struct ListenerConfig { pub name: String, pub listen: String, #[serde(default)] + pub server_header: Option, + #[serde(default)] pub proxy_protocol: Option, #[serde(default)] pub default_certificate: Option, @@ -435,6 +438,8 @@ impl<'de> Deserialize<'de> for ServerConfig { #[serde(default)] listen: MaybeString, #[serde(default)] + server_header: Option, + #[serde(default)] proxy_protocol: Option, #[serde(default)] default_certificate: Option, @@ -467,6 +472,7 @@ impl<'de> Deserialize<'de> for ServerConfig { let server = ServerConfigDe::deserialize(deserializer)?; Ok(ServerConfig { listen: server.listen.0, + server_header: server.server_header, proxy_protocol: server.proxy_protocol, default_certificate: server.default_certificate, server_names: server.server_names, diff --git a/crates/rginx-config/src/validate/server.rs b/crates/rginx-config/src/validate/server.rs index 3fa39517..1671835d 100644 --- a/crates/rginx-config/src/validate/server.rs +++ b/crates/rginx-config/src/validate/server.rs @@ -15,6 +15,7 @@ pub(super) fn validate_server(server: &ServerConfig) -> Result<()> { validate_listener_like(ListenerLikeRef { owner_label: "server", listen: server.listen.as_deref(), + server_header: server.server_header.as_deref(), proxy_protocol: server.proxy_protocol, default_certificate: server.default_certificate.as_deref(), trusted_proxies: &server.trusted_proxies, @@ -73,6 +74,7 @@ pub(super) fn validate_listeners( validate_listener_like(ListenerLikeRef { owner_label: &owner, listen: Some(listener.listen.as_str()), + server_header: listener.server_header.as_deref(), proxy_protocol: listener.proxy_protocol, default_certificate: listener.default_certificate.as_deref(), trusted_proxies: &listener.trusted_proxies, @@ -167,6 +169,7 @@ pub(super) fn validate_server_names( struct ListenerLikeRef<'a> { owner_label: &'a str, listen: Option<&'a str>, + server_header: Option<&'a str>, proxy_protocol: Option, default_certificate: Option<&'a str>, trusted_proxies: &'a [String], @@ -193,6 +196,10 @@ fn validate_listener_like(config: ListenerLikeRef<'_>) -> Result<()> { let _ = config.proxy_protocol; + if let Some(server_header) = config.server_header { + validate_server_header(config.owner_label, server_header)?; + } + if config.default_certificate.is_some_and(|value| value.trim().is_empty()) { return Err(Error::Config(format!( "{} default_certificate must not be empty", @@ -355,6 +362,19 @@ fn validate_listener_like(config: ListenerLikeRef<'_>) -> Result<()> { Ok(()) } +fn validate_server_header(owner_label: &str, server_header: &str) -> Result<()> { + let value = server_header.trim(); + if value.is_empty() { + return Err(Error::Config(format!("{owner_label} server_header must not be empty"))); + } + + http::HeaderValue::from_str(value).map_err(|error| { + Error::Config(format!("{owner_label} server_header `{server_header}` is invalid: {error}")) + })?; + + Ok(()) +} + /// Validates HTTP/3-specific configuration and TLS compatibility requirements. fn validate_http3( owner_label: &str, diff --git a/crates/rginx-config/src/validate/tests.rs b/crates/rginx-config/src/validate/tests.rs index 265ee7a3..58f33175 100644 --- a/crates/rginx-config/src/validate/tests.rs +++ b/crates/rginx-config/src/validate/tests.rs @@ -461,6 +461,15 @@ fn validate_rejects_empty_server_access_log_format() { assert!(error.to_string().contains("server access_log_format must not be empty")); } +#[test] +fn validate_rejects_empty_server_header() { + let mut config = base_config(); + config.server.server_header = Some(" ".to_string()); + + let error = validate(&config).expect_err("empty server header should be rejected"); + assert!(error.to_string().contains("server server_header must not be empty")); +} + #[test] fn validate_rejects_http3_without_tls_on_same_listener() { let mut config = base_config(); @@ -1125,6 +1134,7 @@ fn validate_accepts_explicit_listeners_when_legacy_listener_fields_are_empty() { config.listeners = vec![ ListenerConfig { name: "http".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8080".to_string(), @@ -1142,6 +1152,7 @@ fn validate_accepts_explicit_listeners_when_legacy_listener_fields_are_empty() { }, ListenerConfig { name: "https".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8443".to_string(), @@ -1206,6 +1217,7 @@ fn validate_rejects_request_buffering_on_without_explicit_listener_body_limits() config.listeners = vec![ ListenerConfig { name: "http".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8080".to_string(), @@ -1223,6 +1235,7 @@ fn validate_rejects_request_buffering_on_without_explicit_listener_body_limits() }, ListenerConfig { name: "https".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8443".to_string(), @@ -1255,6 +1268,7 @@ fn validate_rejects_duplicate_listener_names_after_ascii_normalization() { config.listeners = vec![ ListenerConfig { name: " HTTP ".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8080".to_string(), @@ -1272,6 +1286,7 @@ fn validate_rejects_duplicate_listener_names_after_ascii_normalization() { }, ListenerConfig { name: "http".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8443".to_string(), @@ -1298,6 +1313,7 @@ fn validate_rejects_mixing_legacy_listener_fields_with_explicit_listeners() { let mut config = base_config(); config.listeners = vec![ListenerConfig { name: "http".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8080".to_string(), @@ -1324,6 +1340,7 @@ fn validate_rejects_vhost_tls_without_any_tls_listener() { config.server.listen = None; config.listeners = vec![ListenerConfig { name: "http".to_string(), + server_header: None, proxy_protocol: None, default_certificate: None, listen: "127.0.0.1:8080".to_string(), @@ -1385,6 +1402,7 @@ fn base_config() -> Config { listeners: Vec::new(), server: ServerConfig { listen: Some("127.0.0.1:8080".to_string()), + server_header: None, proxy_protocol: None, default_certificate: None, server_names: Vec::new(), diff --git a/crates/rginx-core/src/config.rs b/crates/rginx-core/src/config.rs index f871a1c4..94834ecb 100644 --- a/crates/rginx-core/src/config.rs +++ b/crates/rginx-core/src/config.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use http::HeaderValue; use ipnet::IpNet; mod access_log; @@ -282,6 +283,7 @@ pub struct RuntimeSettings { #[derive(Debug, Clone)] pub struct Server { pub listen_addr: SocketAddr, + pub server_header: HeaderValue, pub default_certificate: Option, pub trusted_proxies: Vec, pub keep_alive: bool, @@ -301,5 +303,11 @@ impl Server { } } +pub const DEFAULT_SERVER_HEADER: &str = "rginx"; + +pub fn default_server_header() -> HeaderValue { + HeaderValue::from_static(DEFAULT_SERVER_HEADER) +} + #[cfg(test)] mod tests; diff --git a/crates/rginx-core/src/config/tests.rs b/crates/rginx-core/src/config/tests.rs index 6dfdb774..5834bf53 100644 --- a/crates/rginx-core/src/config/tests.rs +++ b/crates/rginx-core/src/config/tests.rs @@ -7,7 +7,7 @@ use http::StatusCode; use super::{ AccessLogFormat, AccessLogValues, ConfigSnapshot, Listener, ListenerApplicationProtocol, ListenerHttp3, ListenerTransportKind, ReturnAction, Route, RouteAccessControl, RouteAction, - RouteMatcher, RuntimeSettings, Server, VirtualHost, match_server_name, + RouteMatcher, RuntimeSettings, Server, VirtualHost, default_server_header, match_server_name, }; #[test] @@ -43,6 +43,7 @@ fn route_access_control_denies_before_allowing() { fn server_matches_trusted_proxy_cidrs() { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: default_server_header(), default_certificate: None, trusted_proxies: vec!["10.0.0.0/8".parse().unwrap(), "::1/128".parse().unwrap()], keep_alive: true, @@ -65,6 +66,7 @@ fn server_matches_trusted_proxy_cidrs() { fn config_snapshot_counts_routes_across_all_vhosts() { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, @@ -127,6 +129,7 @@ fn listener_transport_bindings_include_udp_http3_binding_when_configured() { name: "default".to_string(), server: Server { listen_addr: "127.0.0.1:443".parse().unwrap(), + server_header: default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, diff --git a/crates/rginx-core/src/lib.rs b/crates/rginx-core/src/lib.rs index 981ce979..b7b0be11 100644 --- a/crates/rginx-core/src/lib.rs +++ b/crates/rginx-core/src/lib.rs @@ -6,13 +6,14 @@ pub mod types; pub use config::{ AccessLogFormat, AccessLogValues, ActiveHealthCheck, ClientIdentity, ConfigSnapshot, - GrpcRouteMatch, Listener, ListenerApplicationProtocol, ListenerHttp3, ListenerTransportBinding, - ListenerTransportKind, OcspConfig, OcspNonceMode, OcspResponderPolicy, ProxyTarget, - ReturnAction, Route, RouteAccessControl, RouteAction, RouteBufferingPolicy, - RouteCompressionPolicy, RouteMatcher, RouteRateLimit, RuntimeSettings, Server, - ServerCertificateBundle, ServerClientAuthMode, ServerClientAuthPolicy, ServerNameMatch, + DEFAULT_SERVER_HEADER, GrpcRouteMatch, Listener, ListenerApplicationProtocol, ListenerHttp3, + ListenerTransportBinding, ListenerTransportKind, OcspConfig, OcspNonceMode, + OcspResponderPolicy, ProxyTarget, ReturnAction, Route, RouteAccessControl, RouteAction, + RouteBufferingPolicy, RouteCompressionPolicy, RouteMatcher, RouteRateLimit, RuntimeSettings, + Server, ServerCertificateBundle, ServerClientAuthMode, ServerClientAuthPolicy, ServerNameMatch, ServerTls, TlsCipherSuite, TlsKeyExchangeGroup, TlsVersion, Upstream, UpstreamDnsPolicy, UpstreamLoadBalance, UpstreamPeer, UpstreamProtocol, UpstreamSettings, UpstreamTls, - VirtualHost, VirtualHostTls, best_matching_server_name_pattern, match_server_name, + VirtualHost, VirtualHostTls, best_matching_server_name_pattern, default_server_header, + match_server_name, }; pub use error::{Error, Result}; diff --git a/crates/rginx-http/Cargo.toml b/crates/rginx-http/Cargo.toml index aed662f6..9ebc4835 100644 --- a/crates/rginx-http/Cargo.toml +++ b/crates/rginx-http/Cargo.toml @@ -21,6 +21,7 @@ flate2.workspace = true futures-util.workspace = true http.workspace = true http-body-util.workspace = true +httpdate.workspace = true h3.workspace = true h3-quinn.workspace = true hickory-resolver.workspace = true @@ -43,6 +44,7 @@ tokio = { workspace = true, features = ["fs", "io-util"] } tokio-rustls.workspace = true tower-service.workspace = true tracing.workspace = true +uuid.workspace = true webpki.workspace = true [dev-dependencies] diff --git a/crates/rginx-http/src/client_ip.rs b/crates/rginx-http/src/client_ip.rs index 947841f1..806114b0 100644 --- a/crates/rginx-http/src/client_ip.rs +++ b/crates/rginx-http/src/client_ip.rs @@ -144,6 +144,7 @@ mod tests { fn untrusted_peer_ignores_spoofed_x_forwarded_for() { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: vec!["10.0.0.0/8".parse().unwrap()], keep_alive: true, @@ -181,6 +182,7 @@ mod tests { fn trusted_peer_uses_last_untrusted_x_forwarded_for_entry() { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: vec!["10.0.0.0/8".parse().unwrap()], keep_alive: true, @@ -218,6 +220,7 @@ mod tests { fn trusted_peer_keeps_leftmost_entry_when_chain_is_all_trusted() { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: vec!["10.0.0.0/8".parse().unwrap()], keep_alive: true, @@ -254,6 +257,7 @@ mod tests { fn malformed_x_forwarded_for_falls_back_to_peer() { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: vec!["10.0.0.0/8".parse().unwrap()], keep_alive: true, @@ -290,6 +294,7 @@ mod tests { fn x_forwarded_for_entries_may_include_socket_addresses() { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: vec!["10.0.0.0/8".parse().unwrap()], keep_alive: true, @@ -326,6 +331,7 @@ mod tests { fn trusted_proxy_protocol_source_is_used_when_xff_is_absent() { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: vec!["10.0.0.0/8".parse().unwrap()], keep_alive: true, diff --git a/crates/rginx-http/src/handler/dispatch.rs b/crates/rginx-http/src/handler/dispatch.rs index cb7245f8..c72daf31 100644 --- a/crates/rginx-http/src/handler/dispatch.rs +++ b/crates/rginx-http/src/handler/dispatch.rs @@ -9,7 +9,15 @@ use super::response::{ use super::*; use crate::client_ip::{ConnectionPeerAddrs, TlsClientIdentity}; use crate::compression::ResponseCompressionOptions; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +static HTTP_DATE_CACHE: OnceLock> = OnceLock::new(); + +struct CachedHttpDate { + unix_epoch_seconds: u64, + value: HeaderValue, +} #[derive(Clone, Copy)] struct ListenerRequestContext<'a> { @@ -48,6 +56,7 @@ pub async fn handle( .cloned() .expect("listener id should remain available while serving requests"); let access_log_format = listener.server.access_log_format.clone(); + let server_header = listener.server.server_header.clone(); let method = request.method().clone(); let request_version = request.version(); let request_headers = request.headers().clone(); @@ -170,6 +179,7 @@ pub async fn handle( response, grpc_request, alt_svc_header, + server_header, ) .await; let status = finalized.status; @@ -261,6 +271,7 @@ pub(super) async fn finalize_downstream_response( mut response: HttpResponse, grpc_request: Option>, alt_svc_header: Option, + server_header: HeaderValue, ) -> FinalizedDownstreamResponse { // The final response pipeline is intentionally explicit: // 1. Detect gRPC early from response headers. @@ -283,6 +294,10 @@ pub(super) async fn finalize_downstream_response( if let Some(alt_svc_header) = alt_svc_header { response.headers_mut().insert(http::header::ALT_SVC, alt_svc_header); } + if !response.headers().contains_key(http::header::DATE) { + response.headers_mut().insert(http::header::DATE, current_http_date()); + } + response.headers_mut().insert(http::header::SERVER, server_header); response.headers_mut().insert("x-request-id", request_id_header); let status = response.status(); @@ -290,6 +305,30 @@ pub(super) async fn finalize_downstream_response( FinalizedDownstreamResponse { response, status, body_bytes_sent, grpc } } +fn current_http_date() -> HeaderValue { + let unix_epoch_seconds = current_unix_epoch_seconds(); + let cache = HTTP_DATE_CACHE.get_or_init(|| Mutex::new(CachedHttpDate::new(unix_epoch_seconds))); + let mut cached = cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if cached.unix_epoch_seconds != unix_epoch_seconds { + *cached = CachedHttpDate::new(unix_epoch_seconds); + } + cached.value.clone() +} + +impl CachedHttpDate { + fn new(unix_epoch_seconds: u64) -> Self { + let timestamp = UNIX_EPOCH + Duration::from_secs(unix_epoch_seconds); + let value = httpdate::fmt_http_date(timestamp); + let value = + HeaderValue::from_str(&value).expect("formatted HTTP date should be a valid header"); + Self { unix_epoch_seconds, value } + } +} + +fn current_unix_epoch_seconds() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|duration| duration.as_secs()).unwrap_or(0) +} + pub(super) fn response_body_bytes_sent(method: &str, response: &HttpResponse) -> Option { if method == Method::HEAD.as_str() { return Some(0); diff --git a/crates/rginx-http/src/handler/tests.rs b/crates/rginx-http/src/handler/tests.rs index 7c12311f..43882684 100644 --- a/crates/rginx-http/src/handler/tests.rs +++ b/crates/rginx-http/src/handler/tests.rs @@ -7,7 +7,7 @@ use http::{HeaderMap, HeaderValue, Request, StatusCode, header::HOST}; use http_body_util::BodyExt; use rginx_core::{ AccessLogFormat, ConfigSnapshot, GrpcRouteMatch, ReturnAction, Route, RouteAccessControl, - RouteAction, RouteMatcher, RuntimeSettings, Server, VirtualHost, + RouteAction, RouteMatcher, RuntimeSettings, Server, VirtualHost, default_server_header, }; use super::access_log::{AccessLogContext, render_access_log_line}; @@ -624,6 +624,7 @@ async fn finalize_downstream_response_compresses_plain_text_responses() { ), None, None, + default_server_header(), ) .await; @@ -658,6 +659,7 @@ async fn finalize_downstream_response_skips_compression_for_grpc_responses() { text_response(StatusCode::OK, "application/grpc", "hello grpc pipeline\n".repeat(32)), grpc_request_metadata(&request_headers, "/grpc.health.v1.Health/Check"), None, + default_server_header(), ) .await; @@ -687,6 +689,7 @@ async fn finalize_downstream_response_strips_head_body_after_final_transforms() ), None, None, + default_server_header(), ) .await; @@ -725,6 +728,7 @@ async fn finalize_downstream_response_injects_alt_svc_when_provided() { text_response(StatusCode::OK, "text/plain; charset=utf-8", "hello"), None, Some(HeaderValue::from_static("h3=\":443\"; ma=7200")), + HeaderValue::from_static("edge-test"), ) .await; @@ -736,6 +740,38 @@ async fn finalize_downstream_response_injects_alt_svc_when_provided() { .and_then(|value| value.to_str().ok()), Some("h3=\":443\"; ma=7200") ); + assert_eq!( + finalized + .response + .headers() + .get(http::header::SERVER) + .and_then(|value| value.to_str().ok()), + Some("edge-test") + ); + assert!(finalized.response.headers().get(http::header::DATE).is_some()); +} + +#[tokio::test] +async fn finalize_downstream_response_preserves_existing_date_header() { + let request_headers = HeaderMap::new(); + let compression_options = ResponseCompressionOptions::default(); + let upstream_date = HeaderValue::from_static("Tue, 15 Nov 1994 08:12:31 GMT"); + let mut response = text_response(StatusCode::OK, "text/plain; charset=utf-8", "hello"); + response.headers_mut().insert(http::header::DATE, upstream_date.clone()); + + let finalized = finalize_downstream_response( + &http::Method::GET, + &request_headers, + &compression_options, + HeaderValue::from_static("req-date"), + response, + None, + None, + default_server_header(), + ) + .await; + + assert_eq!(finalized.response.headers().get(http::header::DATE), Some(&upstream_date)); } #[tokio::test] @@ -759,6 +795,7 @@ async fn finalize_downstream_response_respects_response_buffering_off() { ), None, None, + default_server_header(), ) .await; @@ -782,6 +819,7 @@ async fn finalize_downstream_response_force_compresses_small_responses() { text_response(StatusCode::OK, "text/plain; charset=utf-8", "a".repeat(128)), None, None, + default_server_header(), ) .await; @@ -810,6 +848,7 @@ fn grpc_web_observability_body() -> Vec { fn test_config(default_vhost: VirtualHost, vhosts: Vec) -> ConfigSnapshot { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, diff --git a/crates/rginx-http/src/proxy/clients/mod.rs b/crates/rginx-http/src/proxy/clients/mod.rs index ecab05a6..fc6c302b 100644 --- a/crates/rginx-http/src/proxy/clients/mod.rs +++ b/crates/rginx-http/src/proxy/clients/mod.rs @@ -1,6 +1,10 @@ use std::error::Error as StdError; +use std::future::{Ready, ready}; +use std::io; +use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Mutex; +use std::task::{Context, Poll}; use super::health::{ ActivePeerGuard, ActiveProbeStatus, PeerFailureStatus, PeerHealthRegistry, SelectedPeers, @@ -8,6 +12,8 @@ use super::health::{ }; use super::*; use rginx_core::{ClientIdentity, TlsVersion}; +use rustls::ClientConfig; +use tower_service::Service; mod http3; #[cfg(test)] @@ -17,19 +23,39 @@ mod tls; #[cfg(test)] pub(super) use tls::load_custom_ca_store; -pub type HyperProxyClient = Client, HttpBody>; +type HyperProxyClient = Client>, HttpBody>; pub(crate) type HealthChangeNotifier = Arc; +const ENDPOINT_CLIENT_CACHE_MIN_CAPACITY: usize = 16; +const ENDPOINT_CLIENT_CACHE_MAX_CAPACITY: usize = 1024; +const ENDPOINT_CLIENT_CACHE_POOL_MULTIPLIER: usize = 4; + #[derive(Clone)] pub(crate) struct HttpProxyClient { - client: Box, + // Hyper pools by selected socket. `pool_max_idle_per_host` applies to every + // endpoint client, so effective idle capacity is per live endpoint until LRU + // eviction trims stale DNS endpoints from this bounded cache. + endpoint_clients: Arc>, resolver: Arc, - server_name_resolver: DynamicServerNameResolver, + profile: UpstreamClientProfile, + tls_config: ClientConfig, + server_name_override: Option>, +} + +struct EndpointClientCache { + entries: HashMap, + capacity: usize, + next_access: u64, } -#[derive(Clone, Default)] -struct DynamicServerNameResolver { - endpoint_server_names: Arc>>, +struct EndpointClientCacheEntry { + client: HyperProxyClient, + last_used: u64, +} + +#[derive(Clone, Debug)] +struct FixedEndpointResolver { + socket_addr: SocketAddr, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -86,7 +112,7 @@ pub struct ProxyClients { #[derive(Clone)] pub(crate) enum ProxyClient { - Http(HttpProxyClient), + Http(Arc), Http3(http3::Http3Client), } @@ -243,9 +269,8 @@ impl ProxyClient { ) -> Result, Error> { match self { Self::Http(client) => { - client.server_name_resolver.register(&peer.dial_authority, &peer.server_name); + let client = client.client_for_peer(peer)?; client - .client .request(request) .await .map(|response| response.map(crate::handler::boxed_body)) @@ -258,37 +283,79 @@ impl ProxyClient { } } -impl DynamicServerNameResolver { - fn register(&self, authority: &str, server_name: &str) { - self.endpoint_server_names - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(authority.to_string(), server_name.to_string()); +impl HttpProxyClient { + fn client_for_peer(&self, peer: &ResolvedUpstreamPeer) -> Result { + let mut endpoint_clients = + self.endpoint_clients.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(client) = endpoint_clients.get(peer.socket_addr) { + return Ok(client); + } + + // Keep construction under the cache lock so concurrent requests for the + // same newly resolved endpoint do not build duplicate Hyper clients. + let client = build_hyper_client_for_endpoint(self, peer.socket_addr)?; + Ok(endpoint_clients.insert(peer.socket_addr, client)) } } -impl ResolveServerName for DynamicServerNameResolver { - fn resolve( - &self, - uri: &Uri, - ) -> Result, Box> { - let authority = uri.authority().map(|value| value.as_str().to_string()); - let server_name = authority - .as_deref() - .and_then(|authority| { - self.endpoint_server_names - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get(authority) - .cloned() - }) - .or_else(|| uri.host().map(str::to_string)) - .ok_or_else(|| { - Box::::from( - "failed to resolve TLS server name from upstream URI", - ) - })?; - ServerName::try_from(server_name).map_err(|error| Box::new(error) as _) +impl EndpointClientCache { + fn new(capacity: usize) -> Self { + Self { entries: HashMap::new(), capacity: capacity.max(1), next_access: 0 } + } + + fn get(&mut self, socket_addr: SocketAddr) -> Option { + let last_used = self.next_access(); + self.entries.get_mut(&socket_addr).map(|entry| { + entry.last_used = last_used; + entry.client.clone() + }) + } + + fn insert(&mut self, socket_addr: SocketAddr, client: HyperProxyClient) -> HyperProxyClient { + if !self.entries.contains_key(&socket_addr) && self.entries.len() >= self.capacity { + self.evict_lru(); + } + let last_used = self.next_access(); + self.entries + .insert(socket_addr, EndpointClientCacheEntry { client: client.clone(), last_used }); + client + } + + fn evict_lru(&mut self) { + let Some(socket_addr) = self + .entries + .iter() + .min_by_key(|(_socket_addr, entry)| entry.last_used) + .map(|(socket_addr, _entry)| *socket_addr) + else { + return; + }; + self.entries.remove(&socket_addr); + } + + fn next_access(&mut self) -> u64 { + self.next_access = self.next_access.saturating_add(1); + self.next_access + } +} + +impl FixedEndpointResolver { + fn new(socket_addr: SocketAddr) -> Self { + Self { socket_addr } + } +} + +impl Service for FixedEndpointResolver { + type Response = std::vec::IntoIter; + type Error = io::Error; + type Future = Ready>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _name: hyper_util::client::legacy::connect::dns::Name) -> Self::Future { + ready(Ok(vec![self.socket_addr].into_iter())) } } @@ -321,12 +388,6 @@ fn build_client_for_profile(profile: &UpstreamClientProfile) -> Result Result Result { + let profile = &client.profile; + let mut connector = HttpConnector::new_with_resolver(FixedEndpointResolver::new(socket_addr)); + connector.enforce_http(false); + connector.set_connect_timeout(Some(profile.connect_timeout)); + connector.set_keepalive(profile.tcp_keepalive); + connector.set_nodelay(profile.tcp_nodelay); + + let builder = + HttpsConnectorBuilder::new().with_tls_config(client.tls_config.clone()).https_or_http(); + let builder = if let Some(server_name_override) = &client.server_name_override { + builder + .with_server_name_resolver(FixedServerNameResolver::new(server_name_override.clone())) } else { - builder.with_server_name_resolver(dynamic_server_name_resolver.clone()) + builder }; let connector = match profile.protocol { UpstreamProtocol::Auto => builder.enable_all_versions().wrap_connector(connector), @@ -369,9 +460,11 @@ fn build_client_for_profile(profile: &UpstreamClientProfile) -> Result usize { + pool_max_idle_per_host + .saturating_mul(ENDPOINT_CLIENT_CACHE_POOL_MULTIPLIER) + .clamp(ENDPOINT_CLIENT_CACHE_MIN_CAPACITY, ENDPOINT_CLIENT_CACHE_MAX_CAPACITY) } diff --git a/crates/rginx-http/src/proxy/clients/tests.rs b/crates/rginx-http/src/proxy/clients/tests.rs index bb72d045..f76c1645 100644 --- a/crates/rginx-http/src/proxy/clients/tests.rs +++ b/crates/rginx-http/src/proxy/clients/tests.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -8,7 +9,38 @@ use rginx_core::{ UpstreamTls, VirtualHost, }; -use super::ProxyClients; +use super::{ + EndpointClientCache, ProxyClients, UpstreamClientProfile, build_client_for_profile, + build_hyper_client_for_endpoint, endpoint_client_cache_capacity, +}; + +#[test] +fn endpoint_client_cache_capacity_is_bounded_by_pool_settings() { + assert_eq!(endpoint_client_cache_capacity(0), 16); + assert_eq!(endpoint_client_cache_capacity(4), 16); + assert_eq!(endpoint_client_cache_capacity(8), 32); + assert_eq!(endpoint_client_cache_capacity(usize::MAX), 1024); +} + +#[test] +fn endpoint_client_cache_evicts_least_recently_used_entry() { + let proxy_client = http_proxy_client_for_cache_tests(); + let first = socket_addr(1); + let second = socket_addr(2); + let third = socket_addr(3); + let mut cache = EndpointClientCache::new(2); + + cache.insert(first, hyper_client_for_endpoint(&proxy_client, first)); + cache.insert(second, hyper_client_for_endpoint(&proxy_client, second)); + assert!(cache.get(first).is_some()); + + cache.insert(third, hyper_client_for_endpoint(&proxy_client, third)); + + assert_eq!(cache.entries.len(), 2); + assert!(cache.entries.contains_key(&first)); + assert!(!cache.entries.contains_key(&second)); + assert!(cache.entries.contains_key(&third)); +} #[tokio::test] async fn peer_health_snapshot_delegates_to_registry() { @@ -57,6 +89,7 @@ async fn peer_health_snapshot_delegates_to_registry() { )); let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, @@ -100,3 +133,42 @@ async fn peer_health_snapshot_delegates_to_registry() { assert_eq!(snapshot[0].peers.len(), 1); assert_eq!(snapshot[0].peers[0].peer_url, "http://127.0.0.1:9000"); } + +fn http_proxy_client_for_cache_tests() -> Arc { + let profile = UpstreamClientProfile { + tls: UpstreamTls::Insecure, + dns: UpstreamDnsPolicy::default(), + tls_versions: None, + server_verify_depth: None, + server_crl_path: None, + client_identity: None, + protocol: UpstreamProtocol::Auto, + server_name: false, + server_name_override: None, + connect_timeout: Duration::from_secs(1), + pool_idle_timeout: Some(Duration::from_secs(1)), + pool_max_idle_per_host: 1, + tcp_keepalive: None, + tcp_nodelay: true, + http2_keep_alive_interval: None, + http2_keep_alive_timeout: Duration::from_secs(20), + http2_keep_alive_while_idle: false, + }; + + match build_client_for_profile(&profile).expect("proxy client should build") { + super::ProxyClient::Http(client) => client, + super::ProxyClient::Http3(_) => panic!("cache test profile should build an HTTP client"), + } +} + +fn hyper_client_for_endpoint( + proxy_client: &super::HttpProxyClient, + socket_addr: SocketAddr, +) -> super::HyperProxyClient { + build_hyper_client_for_endpoint(proxy_client, socket_addr) + .expect("endpoint hyper client should build") +} + +fn socket_addr(last_octet: u8) -> SocketAddr { + SocketAddr::from(([127, 0, 0, last_octet], 9000)) +} diff --git a/crates/rginx-http/src/proxy/common.rs b/crates/rginx-http/src/proxy/common.rs index 59456f26..9185f8bb 100644 --- a/crates/rginx-http/src/proxy/common.rs +++ b/crates/rginx-http/src/proxy/common.rs @@ -25,7 +25,7 @@ pub(super) fn build_proxy_uri( Uri::builder() .scheme(peer.scheme.as_str()) - .authority(peer.dial_authority.as_str()) + .authority(peer.upstream_authority.as_str()) .path_and_query(path_and_query) .build() } diff --git a/crates/rginx-http/src/proxy/health/registry.rs b/crates/rginx-http/src/proxy/health/registry.rs index b7a5be27..86df874f 100644 --- a/crates/rginx-http/src/proxy/health/registry.rs +++ b/crates/rginx-http/src/proxy/health/registry.rs @@ -965,6 +965,7 @@ mod tests { ); let server = rginx_core::Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, diff --git a/crates/rginx-http/src/proxy/mod.rs b/crates/rginx-http/src/proxy/mod.rs index 85ac724f..7ef67366 100644 --- a/crates/rginx-http/src/proxy/mod.rs +++ b/crates/rginx-http/src/proxy/mod.rs @@ -13,9 +13,7 @@ pub(super) use http::{Method, Request, Response, StatusCode, Uri, Version}; pub(super) use http_body_util::BodyExt; pub(super) use hyper::body::{Body as _, Frame, SizeHint}; pub(super) use hyper::upgrade::OnUpgrade; -pub(super) use hyper_rustls::{ - FixedServerNameResolver, HttpsConnector, HttpsConnectorBuilder, ResolveServerName, -}; +pub(super) use hyper_rustls::{FixedServerNameResolver, HttpsConnector, HttpsConnectorBuilder}; pub(super) use hyper_util::client::legacy::Client; pub(super) use hyper_util::client::legacy::connect::HttpConnector; pub(super) use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer}; diff --git a/crates/rginx-http/src/proxy/tests/mod.rs b/crates/rginx-http/src/proxy/tests/mod.rs index c95c0bbd..59978527 100644 --- a/crates/rginx-http/src/proxy/tests/mod.rs +++ b/crates/rginx-http/src/proxy/tests/mod.rs @@ -155,6 +155,7 @@ async fn select( fn default_server() -> rginx_core::Server { rginx_core::Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, diff --git a/crates/rginx-http/src/proxy/tests/request_headers.rs b/crates/rginx-http/src/proxy/tests/request_headers.rs index d087ee12..f7083145 100644 --- a/crates/rginx-http/src/proxy/tests/request_headers.rs +++ b/crates/rginx-http/src/proxy/tests/request_headers.rs @@ -16,6 +16,17 @@ fn proxy_uri_keeps_https_scheme() { assert_eq!(uri, "https://example.com/healthz".parse::().unwrap()); } +#[test] +fn proxy_uri_uses_upstream_authority_not_resolved_dial_authority() { + let mut peer = resolved_peer_from_url("https://httpbingo.org"); + peer.dial_authority = "203.0.113.10:443".to_string(); + peer.socket_addr = "203.0.113.10:443".parse().unwrap(); + + let uri = build_proxy_uri(&peer, &"/anything?demo=1".parse().unwrap(), None).unwrap(); + + assert_eq!(uri, "https://httpbingo.org/anything?demo=1".parse::().unwrap()); +} + #[test] fn sanitize_request_headers_overwrites_x_forwarded_for_with_sanitized_chain() { let mut headers = HeaderMap::new(); diff --git a/crates/rginx-http/src/server/http3.rs b/crates/rginx-http/src/server/http3.rs index 804fb990..93dcbfdf 100644 --- a/crates/rginx-http/src/server/http3.rs +++ b/crates/rginx-http/src/server/http3.rs @@ -650,6 +650,10 @@ async fn serve_http3_connection( } Ok(None) => break, Err(error) => { + if is_clean_http3_accept_close(&error) { + tracing::debug!(%error, "http3 peer closed connection cleanly"); + break; + } state.record_http3_request_accept_error(&listener_id); return Err(Error::Server(format!("http3 request accept failed: {error}"))); } @@ -789,6 +793,24 @@ where }) } +fn is_clean_http3_accept_close(error: &h3::error::ConnectionError) -> bool { + if error.is_h3_no_error() { + return true; + } + + // h3 keeps the remote QUIC error variant private, so callers cannot + // downcast the nested Quinn error. Keep this fallback exact to avoid + // downgrading unrelated accept failures that merely mention the same code. + let message = error.to_string(); + matches!( + message.as_str(), + "Remote error: ApplicationClose: 0x0" + | "Remote error: ApplicationClose: 0" + | "Remote error: Error undefined by h3: closed by peer: ApplicationClose: 0x0" + | "Remote error: Error undefined by h3: closed by peer: ApplicationClose: 0" + ) +} + /// Logs the completion result of a spawned HTTP/3 connection task. fn log_connection_task_result(result: std::result::Result, JoinError>) { match result { @@ -827,3 +849,95 @@ fn log_request_task_result(result: std::result::Result, JoinError>) { fn stream_error(error: impl std::fmt::Display) -> BoxError { std::io::Error::other(format!("{error}")).into() } + +#[cfg(test)] +mod tests { + use super::*; + use std::future::poll_fn; + use tokio::time::timeout; + + #[tokio::test(flavor = "multi_thread")] + async fn clean_accept_close_detects_quinn_application_close_zero() { + let (server_endpoint, client_endpoint, server_addr) = test_http3_endpoint_pair(); + let server_task = tokio::spawn(async move { + let incoming = server_endpoint.accept().await.expect("connection should arrive"); + let connection = incoming.await.expect("connection should establish"); + let mut h3 = + match H3Connection::<_, Bytes>::new(h3_quinn::Connection::new(connection)).await { + Ok(h3) => h3, + Err(error) => return error, + }; + + match h3.accept().await { + Err(error) => error, + Ok(Some(_resolver)) => { + panic!("client application close should not produce a request") + } + Ok(None) => panic!("client application close should produce an h3 accept error"), + } + }); + + let connection = client_endpoint + .connect(server_addr, "localhost") + .expect("client connect should start") + .await + .expect("client connection should establish"); + let close_connection = connection.clone(); + let (mut driver, _send_request) = h3::client::new(h3_quinn::Connection::new(connection)) + .await + .expect("h3 client should initialize"); + let driver_task = tokio::spawn(async move { + let _ = poll_fn(|cx| driver.poll_close(cx)).await; + }); + + close_connection.close(quinn::VarInt::from_u32(0), b""); + let error = timeout(Duration::from_secs(5), server_task) + .await + .expect("server should observe clean close") + .expect("server task should not panic"); + + assert!( + is_clean_http3_accept_close(&error), + "clean QUIC application close should be treated as debug-only, got {error}" + ); + + driver_task.abort(); + let _ = driver_task.await; + } + + fn test_http3_endpoint_pair() -> (quinn::Endpoint, quinn::Endpoint, SocketAddr) { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("cert should generate"); + let cert_der = rustls::pki_types::CertificateDer::from(cert.cert); + let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()); + + let mut server_crypto = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der.clone()], key_der.into()) + .expect("server cert should configure"); + server_crypto.alpn_protocols = vec![b"h3".to_vec()]; + let server_config = quinn::ServerConfig::with_crypto(Arc::new( + quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto) + .expect("quic server config should build"), + )); + let server_endpoint = + quinn::Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()) + .expect("server endpoint should bind"); + let server_addr = server_endpoint.local_addr().expect("server addr should exist"); + + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert_der).expect("client should trust test certificate"); + let mut client_crypto = + rustls::ClientConfig::builder().with_root_certificates(roots).with_no_client_auth(); + client_crypto.alpn_protocols = vec![b"h3".to_vec()]; + let client_config = quinn::ClientConfig::new(Arc::new( + quinn::crypto::rustls::QuicClientConfig::try_from(client_crypto) + .expect("quic client config should build"), + )); + let mut client_endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()) + .expect("client endpoint should bind"); + client_endpoint.set_default_client_config(client_config); + + (server_endpoint, client_endpoint, server_addr) + } +} diff --git a/crates/rginx-http/src/state/lifecycle.rs b/crates/rginx-http/src/state/lifecycle.rs index 8f293de7..46561421 100644 --- a/crates/rginx-http/src/state/lifecycle.rs +++ b/crates/rginx-http/src/state/lifecycle.rs @@ -309,8 +309,7 @@ impl SharedState { } pub fn next_request_id(&self) -> String { - let next = self.request_ids.fetch_add(1, Ordering::Relaxed); - format!("rginx-{next:016x}") + uuid::Uuid::now_v7().to_string() } fn mtls_status_snapshot(&self, config: &ConfigSnapshot) -> MtlsStatusSnapshot { diff --git a/crates/rginx-http/src/state/mod.rs b/crates/rginx-http/src/state/mod.rs index 48f0f09f..4d1e4c1d 100644 --- a/crates/rginx-http/src/state/mod.rs +++ b/crates/rginx-http/src/state/mod.rs @@ -72,7 +72,6 @@ pub struct SharedState { peer_health_component_versions: Arc>>, reload_history: Arc>, ocsp_statuses: Arc>>, - request_ids: Arc, config_path: Option>, } @@ -154,7 +153,6 @@ impl SharedState { peer_health_component_versions, reload_history: Arc::new(Mutex::new(ReloadHistory::default())), ocsp_statuses, - request_ids: Arc::new(AtomicU64::new(1)), config_path: config_path.map(Arc::new), }) } diff --git a/crates/rginx-http/src/state/tests.rs b/crates/rginx-http/src/state/tests.rs index 77937666..98a9a942 100644 --- a/crates/rginx-http/src/state/tests.rs +++ b/crates/rginx-http/src/state/tests.rs @@ -22,6 +22,7 @@ use super::{ fn snapshot(listen: &str) -> ConfigSnapshot { let server = Server { listen_addr: listen.parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, diff --git a/crates/rginx-http/src/state/tls_runtime/bindings.rs b/crates/rginx-http/src/state/tls_runtime/bindings.rs index 11055ffe..5e4258b0 100644 --- a/crates/rginx-http/src/state/tls_runtime/bindings.rs +++ b/crates/rginx-http/src/state/tls_runtime/bindings.rs @@ -190,6 +190,7 @@ mod tests { name: name.to_string(), server: rginx_core::Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: default_certificate.map(str::to_string), trusted_proxies: Vec::new(), keep_alive: true, diff --git a/crates/rginx-http/src/transition.rs b/crates/rginx-http/src/transition.rs index 0a258394..3e5d7422 100644 --- a/crates/rginx-http/src/transition.rs +++ b/crates/rginx-http/src/transition.rs @@ -164,6 +164,7 @@ mod tests { fn snapshot(listen: &str) -> ConfigSnapshot { let server = Server { listen_addr: listen.parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, @@ -284,6 +285,7 @@ mod tests { name: "https".to_string(), server: rginx_core::Server { listen_addr: "127.0.0.1:8443".parse().unwrap(), + server_header: rginx_core::default_server_header(), ..current.listeners[0].server.clone() }, tls_termination_enabled: false, diff --git a/crates/rginx-runtime/src/bootstrap/listeners.rs b/crates/rginx-runtime/src/bootstrap/listeners.rs index 0bb0bb3f..23a88fec 100644 --- a/crates/rginx-runtime/src/bootstrap/listeners.rs +++ b/crates/rginx-runtime/src/bootstrap/listeners.rs @@ -560,6 +560,7 @@ mod tests { name: name.to_string(), server: Server { listen_addr, + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, diff --git a/crates/rginx-runtime/src/bootstrap/shutdown.rs b/crates/rginx-runtime/src/bootstrap/shutdown.rs index 0115c08a..e3b7f6a2 100644 --- a/crates/rginx-runtime/src/bootstrap/shutdown.rs +++ b/crates/rginx-runtime/src/bootstrap/shutdown.rs @@ -140,6 +140,7 @@ mod tests { name: "default".to_string(), server: Server { listen_addr: "127.0.0.1:0".parse().expect("socket addr should parse"), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, diff --git a/crates/rginx-runtime/src/health.rs b/crates/rginx-runtime/src/health.rs index 2675d1ec..75383bc3 100644 --- a/crates/rginx-runtime/src/health.rs +++ b/crates/rginx-runtime/src/health.rs @@ -202,6 +202,7 @@ mod tests { let server = Server { listen_addr: "127.0.0.1:8080".parse().unwrap(), + server_header: rginx_core::default_server_header(), default_certificate: None, trusted_proxies: Vec::new(), keep_alive: true, diff --git a/docs/HTTP3_PHASE7_RELEASE.md b/docs/HTTP3_PHASE7_RELEASE.md index 79e1db74..5be88e48 100644 --- a/docs/HTTP3_PHASE7_RELEASE.md +++ b/docs/HTTP3_PHASE7_RELEASE.md @@ -11,10 +11,6 @@ Phase 7 的目标不是再增加协议能力,而是把 HTTP/3 变成发布流 1. dedicated HTTP/3 regression gate 2. focused HTTP/3 soak -可选第三段: - -3. nginx comparison Docker harness - ## 具体入口 ### Dedicated Gate @@ -56,7 +52,6 @@ Phase 7 的目标不是再增加协议能力,而是把 HTTP/3 变成发布流 ```bash ./scripts/run-http3-release-gate.sh --soak-iterations 1 ./scripts/run-http3-release-gate.sh --soak-iterations 3 --release -./scripts/run-http3-release-gate.sh --with-compare --soak-iterations 1 ``` ## 与仓库发布流程的关系 @@ -81,23 +76,6 @@ Phase 7 的目标不是再增加协议能力,而是把 HTTP/3 变成发布流 - 需要发布候选时,建议再跑 `--release` - 改动 QUIC、HTTP/3 listener、0-RTT、reload/drain 逻辑时,建议增加 netem 或 MTU 场景 -## Optional Compare Harness - -如果需要把本地发布候选与 nginx 做相对对比,可以加: - -```bash -./scripts/run-http3-release-gate.sh \ - --with-compare \ - --soak-iterations 1 \ - --compare-out-dir target/http3-release/nginx-compare -``` - -注意: - -- compare harness 目标是同一环境下的相对对比,不是对外宣传基准 -- 当前 harness 会运行 `rginx` 的 HTTP/3 场景 -- nginx 一侧在这套 Docker 构建里仍不提供 QUIC/HTTP/3,对应结果会标成 unsupported - ## 维护要求 - 变更 HTTP/3 gate 目标时,更新此文档、README 和 release notes。 diff --git a/docs/README.md b/docs/README.md index 87a62e95..89d35d81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ - `HTTP3_PHASE0_BASELINE.md` - HTTP/3 主线的阶段 0 基线与起始约束 - `HTTP3_PHASE7_RELEASE.md` - - HTTP/3 发布门禁、soak、compare harness 入口 + - HTTP/3 发布门禁和 soak 入口 ## 上游 HTTP/3 专项 @@ -21,4 +21,4 @@ ## 维护约定 - 如果 README、release notes 或 workflow 引用了新的架构文档,优先把文档放在 `docs/` 下并在这里登记。 -- 发布前变更了 HTTP/3 gate、soak 或 compare harness 时,至少同步更新 `HTTP3_PHASE7_RELEASE.md`。 +- 发布前变更了 HTTP/3 gate 或 soak 时,至少同步更新 `HTTP3_PHASE7_RELEASE.md`。