v0.25.6
This commit is contained in:
1 parent
c819f0a335
commit
e1a59f6e56
53 files changed
+817
-380
No files matched your search
+23
-2
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.25.5",
|
||||
"version": "0.25.6",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
@@ -3153,6 +3153,27 @@
|
||||
"minimum": 1,
|
||||
"maximum": 3600,
|
||||
"default": 30
|
||||
},
|
||||
"pool_recycle_requests": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10000000,
|
||||
"default": 25000,
|
||||
"description": "Maximum requests handled by one backend Hyper client generation before its connection pool is recycled. 0 disables request-count recycling; nonzero values below 1000 are rejected by configuration validation."
|
||||
},
|
||||
"pool_recycle_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 604800,
|
||||
"default": 3600,
|
||||
"description": "Maximum age in seconds of one backend Hyper client generation. 0 disables age-based recycling; nonzero values below 60 are rejected by configuration validation."
|
||||
},
|
||||
"http1_max_buffer_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 1048576,
|
||||
"default": 131072,
|
||||
"description": "Maximum adaptive read-buffer size per upstream HTTP/1 connection."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9376,4 +9397,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ function renderSettings() {
|
||||
if (!settingsDirty.has("response-form")) {
|
||||
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
|
||||
$("expose-request-id").checked = !!diagnostics.request_id;
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.5";
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.6";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
|
||||
@@ -541,6 +541,10 @@
|
||||
<th data-i18n="vhErrors">Errors</th>
|
||||
<th data-i18n="vhLatency">Latency</th>
|
||||
<th data-i18n="vhTraffic">Traffic/s</th>
|
||||
<th data-i18n="vhClientGeneration">Client gen.</th>
|
||||
<th data-i18n="vhGenerationRequests">Gen. requests</th>
|
||||
<th data-i18n="vhGenerationAge">Gen. age</th>
|
||||
<th data-i18n="vhClientRotations">Rotations</th>
|
||||
<th data-i18n="vhLastFailure">Last failure</th>
|
||||
<th data-i18n="vhLastRecovery">Last recovery</th>
|
||||
</tr>
|
||||
@@ -1594,7 +1598,7 @@
|
||||
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
|
||||
</select>
|
||||
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.5"
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.6"
|
||||
spellcheck="false" />
|
||||
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
|
||||
<div class="switch-grid">
|
||||
@@ -2382,7 +2386,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.25.5</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>0.25.6</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3093,7 +3097,7 @@ max_execution_time = 30</code></pre>
|
||||
<article class="card about-card">
|
||||
<div class="about-product">
|
||||
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.25.5</strong></div>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.25.6</strong></div>
|
||||
</div>
|
||||
<dl class="about-meta">
|
||||
<div>
|
||||
|
||||
@@ -112,6 +112,10 @@
|
||||
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
|
||||
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "pool-recycle-requests", "vhPoolRecycleRequests", keepalive.pool_recycle_requests ?? 25000, "number", { required: true, min: 0, max: 10000000 });
|
||||
field(g, "pool-recycle-seconds", "vhPoolRecycleSeconds", keepalive.pool_recycle_seconds ?? 3600, "number", { required: true, min: 0, max: 604800 });
|
||||
field(g, "http1-max-buffer", "vhHttp1MaxBuffer", keepalive.http1_max_buffer_bytes ?? 131072, "number", { required: true, min: 8192, max: 1048576 });
|
||||
note(g, "vhPoolRecycleHelp");
|
||||
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
|
||||
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
|
||||
@@ -317,7 +321,7 @@
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
load_balancing: {
|
||||
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive") },
|
||||
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive"), pool_recycle_requests: num("pool-recycle-requests"), pool_recycle_seconds: num("pool-recycle-seconds"), http1_max_buffer_bytes: num("http1-max-buffer") },
|
||||
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
|
||||
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
|
||||
passive_health: {
|
||||
|
||||
@@ -142,9 +142,12 @@
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-";
|
||||
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
|
||||
const generationAgeRaw = member.upstream_client_generation_age_seconds;
|
||||
const generationAge = Math.max(0, Number(generationAgeRaw));
|
||||
const generationAgeText = generationAgeRaw == null || !Number.isFinite(generationAge) ? "-" : generationAge >= 3600 ? `${Math.floor(generationAge / 3600)}h ${Math.floor((generationAge % 3600) / 60)}m` : generationAge >= 60 ? `${Math.floor(generationAge / 60)}m ${Math.floor(generationAge % 60)}s` : `${Math.floor(generationAge)}s`;
|
||||
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, lastFailure, lastRecovery];
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 8 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 8 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, member.upstream_client_generation ?? "-", member.upstream_client_generation_requests ?? "-", generationAgeText, member.upstream_client_rotations_total ?? "-", lastFailure, lastRecovery];
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
||||
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
||||
}
|
||||
|
||||
+19
-1
@@ -54,6 +54,16 @@ pub struct Keepalive {
|
||||
pub idle_timeout_seconds: u64,
|
||||
pub max_idle_per_host: usize,
|
||||
pub tcp_keepalive_seconds: u64,
|
||||
/// Rebuild a backend Hyper client after this many requests. The counter is
|
||||
/// per backend/client generation, not per TCP connection. Zero disables
|
||||
/// request-count based recycling.
|
||||
pub pool_recycle_requests: u64,
|
||||
/// Maximum age of one backend Hyper client generation. Zero disables
|
||||
/// time-based recycling.
|
||||
pub pool_recycle_seconds: u64,
|
||||
/// Bound Hyper's adaptive HTTP/1 read buffer per connection. Hyper requires
|
||||
/// at least 8192 bytes. This affects upstream HTTP/1 only.
|
||||
pub http1_max_buffer_bytes: usize,
|
||||
}
|
||||
impl Default for Keepalive {
|
||||
fn default() -> Self {
|
||||
@@ -62,6 +72,9 @@ impl Default for Keepalive {
|
||||
idle_timeout_seconds: 30,
|
||||
max_idle_per_host: 4,
|
||||
tcp_keepalive_seconds: 30,
|
||||
pool_recycle_requests: 25_000,
|
||||
pool_recycle_seconds: 3_600,
|
||||
http1_max_buffer_bytes: 131_072,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,7 +268,12 @@ impl Settings {
|
||||
ensure!(
|
||||
(1..=3600).contains(&k.idle_timeout_seconds)
|
||||
&& (1..=3600).contains(&k.tcp_keepalive_seconds)
|
||||
&& (1..=64).contains(&k.max_idle_per_host),
|
||||
&& (1..=64).contains(&k.max_idle_per_host)
|
||||
&& (k.pool_recycle_requests == 0
|
||||
|| (1_000..=10_000_000).contains(&k.pool_recycle_requests))
|
||||
&& (k.pool_recycle_seconds == 0
|
||||
|| (60..=604_800).contains(&k.pool_recycle_seconds))
|
||||
&& (8_192..=1_048_576).contains(&k.http1_max_buffer_bytes),
|
||||
"invalid keepalive limits"
|
||||
);
|
||||
ensure!(
|
||||
|
||||
@@ -61,6 +61,23 @@ impl Pool {
|
||||
"traffic_bytes_per_second".into(),
|
||||
json!(m.state.traffic_rate()),
|
||||
);
|
||||
let client_rotation = m.backend.client.rotation_stats();
|
||||
item.insert(
|
||||
"upstream_client_generation".into(),
|
||||
json!(client_rotation.generation),
|
||||
);
|
||||
item.insert(
|
||||
"upstream_client_generation_requests".into(),
|
||||
json!(client_rotation.generation_requests),
|
||||
);
|
||||
item.insert(
|
||||
"upstream_client_generation_age_seconds".into(),
|
||||
json!(client_rotation.generation_age_seconds),
|
||||
);
|
||||
item.insert(
|
||||
"upstream_client_rotations_total".into(),
|
||||
json!(client_rotation.rotations),
|
||||
);
|
||||
item.insert("latency_ms".into(), json!(h.latency_us as f64 / 1000.0));
|
||||
item.insert("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0));
|
||||
item.insert("last_probe_ms".into(), json!(h.last_probe_ms));
|
||||
|
||||
@@ -72,6 +72,30 @@ fn legacy_settings_without_passive_health_default_to_off() {
|
||||
assert!(parsed.retry.retry_connect_errors);
|
||||
assert!(parsed.retry.retry_timeouts);
|
||||
assert_eq!(parsed.retry.retry_statuses, vec![502, 503, 504]);
|
||||
assert_eq!(parsed.keepalive.pool_recycle_requests, 25_000);
|
||||
assert_eq!(parsed.keepalive.pool_recycle_seconds, 3_600);
|
||||
assert_eq!(parsed.keepalive.http1_max_buffer_bytes, 131_072);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_pool_recycle_limits_are_bounded_and_can_be_disabled() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.validate().is_ok());
|
||||
|
||||
settings.keepalive.pool_recycle_requests = 0;
|
||||
settings.keepalive.pool_recycle_seconds = 0;
|
||||
assert!(settings.validate().is_ok());
|
||||
|
||||
settings.keepalive.pool_recycle_requests = 999;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.keepalive.pool_recycle_requests = 1_000;
|
||||
settings.keepalive.pool_recycle_seconds = 59;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.keepalive.pool_recycle_seconds = 60;
|
||||
settings.keepalive.http1_max_buffer_bytes = 8_191;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.keepalive.http1_max_buffer_bytes = 8_192;
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.5"
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.6"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
|
||||
+1
-1
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
|
||||
println!("RustProxy live logs 0.25.5 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs 0.25.6 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
if interactive { print!("> "); io::stdout().flush()?; }
|
||||
let mut filters = LiveLogFilters::default();
|
||||
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
|
||||
|
||||
+9
-126
@@ -1,23 +1,16 @@
|
||||
//! Per-backend connection pools and connect-time DNS validation.
|
||||
//! No HTTP caller can supply a destination. DNS answers are validated once and
|
||||
//! passed directly to HttpConnector; there is no second unvalidated lookup.
|
||||
use crate::config::{ip_policy, Backend, UpstreamProtocol};
|
||||
use crate::config::{ip_policy, Backend};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use axum::body::Body;
|
||||
use http::Uri;
|
||||
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
|
||||
use hyper_util::{
|
||||
client::legacy::{
|
||||
connect::{dns::Name, HttpConnector},
|
||||
Client,
|
||||
},
|
||||
rt::{TokioExecutor, TokioTimer},
|
||||
};
|
||||
use hyper_rustls::HttpsConnector;
|
||||
use hyper_util::client::legacy::connect::{dns::Name, HttpConnector};
|
||||
use ipnet::IpNet;
|
||||
use rustls::{
|
||||
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
|
||||
DigitallySignedStruct, RootCertStore, SignatureScheme,
|
||||
};
|
||||
use std::{
|
||||
future::Future,
|
||||
@@ -36,7 +29,11 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_DNS_ANSWERS: usize = 32;
|
||||
static DNS_SLOTS: OnceLock<Arc<Semaphore>> = OnceLock::new();
|
||||
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type ProxyClient = Client<TimedConnector, Body>;
|
||||
|
||||
mod client;
|
||||
pub use client::{
|
||||
client, client_with_keepalive, client_with_timeout, ClientRotationStats, ProxyClient,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AddressPolicy {
|
||||
@@ -297,120 +294,6 @@ impl ServerCertVerifier for BackendCertificateVerificationDisabled {
|
||||
self.supported_schemes.clone()
|
||||
}
|
||||
}
|
||||
pub fn client(backend: &Backend, policy: AddressPolicy, websocket: bool) -> Result<ProxyClient> {
|
||||
client_with_keepalive(
|
||||
backend,
|
||||
policy,
|
||||
websocket,
|
||||
&crate::balance::Keepalive::default(),
|
||||
)
|
||||
}
|
||||
pub fn client_with_keepalive(
|
||||
backend: &Backend,
|
||||
policy: AddressPolicy,
|
||||
websocket: bool,
|
||||
keepalive: &crate::balance::Keepalive,
|
||||
) -> Result<ProxyClient> {
|
||||
client_with_timeout(backend, policy, websocket, keepalive, CONNECT_TIMEOUT)
|
||||
}
|
||||
pub fn client_with_timeout(
|
||||
backend: &Backend,
|
||||
policy: AddressPolicy,
|
||||
websocket: bool,
|
||||
keepalive: &crate::balance::Keepalive,
|
||||
timeout: Duration,
|
||||
) -> Result<ProxyClient> {
|
||||
// HTTPS "http2" prefers h2 but always offers HTTP/1.1. Cleartext http2 remains
|
||||
// explicit h2c prior knowledge; never replay a failed TLS request to downgrade.
|
||||
let is_https = url::Url::parse(&backend.url)?.scheme() == "https";
|
||||
let protocol = if websocket {
|
||||
UpstreamProtocol::Http1
|
||||
} else if backend.protocol == UpstreamProtocol::Http2 && is_https {
|
||||
UpstreamProtocol::Auto
|
||||
} else {
|
||||
backend.protocol
|
||||
};
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let trust_roots = if backend.tls_verify {
|
||||
roots(backend)?
|
||||
} else {
|
||||
// tls_ca_pem is intentionally ignored when verification is disabled.
|
||||
public_roots()
|
||||
};
|
||||
let mut config = ClientConfig::builder_with_provider(provider.clone())
|
||||
.with_safe_default_protocol_versions()?
|
||||
.with_root_certificates(trust_roots)
|
||||
.with_no_client_auth();
|
||||
if !backend.tls_verify {
|
||||
let supported_schemes = provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes();
|
||||
config.dangerous().set_certificate_verifier(Arc::new(
|
||||
BackendCertificateVerificationDisabled { supported_schemes },
|
||||
));
|
||||
}
|
||||
let connect_host = if backend.tls_server_name.is_some() {
|
||||
Some(
|
||||
url::Url::parse(&backend.url)?
|
||||
.host_str()
|
||||
.context("backend host missing")?
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.to_owned(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut http = HttpConnector::new_with_resolver(GuardedResolver {
|
||||
policy: policy.clone(),
|
||||
connect_host,
|
||||
});
|
||||
http.enforce_http(false);
|
||||
http.set_connect_timeout(Some(timeout));
|
||||
http.set_nodelay(true);
|
||||
http.set_keepalive(
|
||||
keepalive
|
||||
.enabled
|
||||
.then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)),
|
||||
);
|
||||
let builder = HttpsConnectorBuilder::new()
|
||||
.with_tls_config(config)
|
||||
.https_or_http();
|
||||
let inner = match protocol {
|
||||
UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http),
|
||||
UpstreamProtocol::Http2 => builder.enable_http2().wrap_connector(http),
|
||||
UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http),
|
||||
};
|
||||
let mut builder = Client::builder(TokioExecutor::new());
|
||||
builder
|
||||
// Header field names are case-insensitive by HTTP specification, but some
|
||||
// legacy/custom origins parse HTTP/1 request headers case-sensitively.
|
||||
// Hyper writes lowercase names by default; title-case them on the wire for
|
||||
// broad origin compatibility. This setting does not affect HTTP/2.
|
||||
.http1_title_case_headers(true)
|
||||
.pool_timer(TokioTimer::new())
|
||||
.pool_idle_timeout(Duration::from_secs(keepalive.idle_timeout_seconds))
|
||||
.pool_max_idle_per_host(if keepalive.enabled {
|
||||
keepalive.max_idle_per_host
|
||||
} else {
|
||||
0
|
||||
})
|
||||
.retry_canceled_requests(false)
|
||||
.http2_only(protocol == UpstreamProtocol::Http2)
|
||||
.timer(TokioTimer::new())
|
||||
.http2_keep_alive_interval(
|
||||
keepalive
|
||||
.enabled
|
||||
.then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)),
|
||||
)
|
||||
.http2_keep_alive_timeout(Duration::from_secs(10))
|
||||
.http2_keep_alive_while_idle(false);
|
||||
Ok(builder.build(TimedConnector {
|
||||
inner,
|
||||
policy,
|
||||
timeout,
|
||||
}))
|
||||
}
|
||||
/// A bounded raw TCP liveness check, using the same configured-origin address
|
||||
/// policy as normal requests. Every DNS answer is checked before any connection.
|
||||
pub async fn tcp_probe(origin: &str, policy: &AddressPolicy) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
use super::{
|
||||
public_roots, roots, AddressPolicy, BackendCertificateVerificationDisabled, TimedConnector,
|
||||
CONNECT_TIMEOUT,
|
||||
};
|
||||
use crate::config::{Backend, UpstreamProtocol};
|
||||
use anyhow::{Context, Result};
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::body::Body;
|
||||
use http::{Request, Response};
|
||||
use hyper::body::Incoming;
|
||||
use hyper_rustls::HttpsConnectorBuilder;
|
||||
use hyper_util::{
|
||||
client::legacy::{Client, Error as ClientError},
|
||||
rt::{TokioExecutor, TokioTimer},
|
||||
};
|
||||
use rustls::ClientConfig;
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc, OnceLock,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
static MONOTONIC_START: OnceLock<Instant> = OnceLock::new();
|
||||
type RawProxyClient = Client<super::TimedConnector, Body>;
|
||||
|
||||
fn monotonic_millis() -> u64 {
|
||||
MONOTONIC_START
|
||||
.get_or_init(Instant::now)
|
||||
.elapsed()
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ClientFactory {
|
||||
backend: Backend,
|
||||
policy: AddressPolicy,
|
||||
websocket: bool,
|
||||
keepalive: crate::balance::Keepalive,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
struct RotatingClient {
|
||||
current: ArcSwap<RawProxyClient>,
|
||||
factory: ClientFactory,
|
||||
generation: AtomicU64,
|
||||
generation_requests: AtomicU64,
|
||||
generation_started_ms: AtomicU64,
|
||||
rotations: AtomicU64,
|
||||
rotating: AtomicBool,
|
||||
}
|
||||
|
||||
/// Backend HTTP client with bounded generation lifetime.
|
||||
///
|
||||
/// Hyper reuses a client/pool for efficiency. Under sustained traffic that also
|
||||
/// makes connection/TLS/buffer high-water allocations live as long as the
|
||||
/// compiled vhost snapshot. Recycling only this client generation bounds that
|
||||
/// lifetime without reloading the vhost or touching cache/configuration state.
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyClient {
|
||||
inner: Arc<RotatingClient>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ClientRotationStats {
|
||||
pub generation: u64,
|
||||
pub generation_requests: u64,
|
||||
pub generation_age_seconds: u64,
|
||||
pub rotations: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProxyClient {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProxyClient")
|
||||
.field("rotation", &self.rotation_stats())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyClient {
|
||||
fn new(current: RawProxyClient, factory: ClientFactory) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RotatingClient {
|
||||
current: ArcSwap::from_pointee(current),
|
||||
factory,
|
||||
generation: AtomicU64::new(0),
|
||||
generation_requests: AtomicU64::new(0),
|
||||
generation_started_ms: AtomicU64::new(monotonic_millis()),
|
||||
rotations: AtomicU64::new(0),
|
||||
rotating: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request(&self, request: Request<Body>) -> Result<Response<Incoming>, ClientError> {
|
||||
let client = self.inner.client_for_request();
|
||||
client.request(request).await
|
||||
}
|
||||
|
||||
pub fn rotation_stats(&self) -> ClientRotationStats {
|
||||
self.inner.stats()
|
||||
}
|
||||
}
|
||||
|
||||
impl RotatingClient {
|
||||
fn stats(&self) -> ClientRotationStats {
|
||||
let now = monotonic_millis();
|
||||
ClientRotationStats {
|
||||
generation: self.generation.load(Ordering::Relaxed),
|
||||
generation_requests: self.generation_requests.load(Ordering::Relaxed),
|
||||
generation_age_seconds: now
|
||||
.saturating_sub(self.generation_started_ms.load(Ordering::Relaxed))
|
||||
/ 1_000,
|
||||
rotations: self.rotations.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
fn client_for_request(&self) -> Arc<RawProxyClient> {
|
||||
let observed_generation = self.generation.load(Ordering::Acquire);
|
||||
let requests = self
|
||||
.generation_requests
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.saturating_add(1);
|
||||
let now = monotonic_millis();
|
||||
let age_ms = now.saturating_sub(self.generation_started_ms.load(Ordering::Relaxed));
|
||||
let request_due = self.factory.keepalive.pool_recycle_requests > 0
|
||||
&& requests > self.factory.keepalive.pool_recycle_requests;
|
||||
let age_due = self.factory.keepalive.pool_recycle_seconds > 0
|
||||
&& age_ms
|
||||
>= self
|
||||
.factory
|
||||
.keepalive
|
||||
.pool_recycle_seconds
|
||||
.saturating_mul(1_000);
|
||||
if request_due || age_due {
|
||||
self.rotate(observed_generation, if request_due { "requests" } else { "age" });
|
||||
}
|
||||
self.current.load_full()
|
||||
}
|
||||
|
||||
fn rotate(&self, observed_generation: u64, reason: &'static str) {
|
||||
if self.generation.load(Ordering::Acquire) != observed_generation
|
||||
|| self
|
||||
.rotating
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.generation.load(Ordering::Acquire) != observed_generation {
|
||||
self.rotating.store(false, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
|
||||
match build_raw_client(&self.factory) {
|
||||
Ok(client) => {
|
||||
let next_generation = observed_generation.saturating_add(1);
|
||||
self.current.store(Arc::new(client));
|
||||
// The request that triggered the swap is sent through the new
|
||||
// generation after rotate() returns, so count it there.
|
||||
self.generation_requests.store(1, Ordering::Relaxed);
|
||||
self.generation_started_ms
|
||||
.store(monotonic_millis(), Ordering::Relaxed);
|
||||
self.generation.store(next_generation, Ordering::Release);
|
||||
self.rotations.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::debug!(
|
||||
backend = %self.factory.backend.id,
|
||||
generation = next_generation,
|
||||
reason,
|
||||
"recycled upstream client pool"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
// The same immutable settings built the current client. If a
|
||||
// future dependency makes rebuilding fail, keep serving through
|
||||
// the current generation and retry only after another interval.
|
||||
self.generation_requests.store(0, Ordering::Relaxed);
|
||||
self.generation_started_ms
|
||||
.store(monotonic_millis(), Ordering::Relaxed);
|
||||
tracing::warn!(
|
||||
backend = %self.factory.backend.id,
|
||||
error = %error,
|
||||
"could not recycle upstream client pool; keeping current generation"
|
||||
);
|
||||
}
|
||||
}
|
||||
self.rotating.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client(backend: &Backend, policy: AddressPolicy, websocket: bool) -> Result<ProxyClient> {
|
||||
client_with_keepalive(
|
||||
backend,
|
||||
policy,
|
||||
websocket,
|
||||
&crate::balance::Keepalive::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn client_with_keepalive(
|
||||
backend: &Backend,
|
||||
policy: AddressPolicy,
|
||||
websocket: bool,
|
||||
keepalive: &crate::balance::Keepalive,
|
||||
) -> Result<ProxyClient> {
|
||||
client_with_timeout(backend, policy, websocket, keepalive, CONNECT_TIMEOUT)
|
||||
}
|
||||
|
||||
pub fn client_with_timeout(
|
||||
backend: &Backend,
|
||||
policy: AddressPolicy,
|
||||
websocket: bool,
|
||||
keepalive: &crate::balance::Keepalive,
|
||||
timeout: Duration,
|
||||
) -> Result<ProxyClient> {
|
||||
let factory = ClientFactory {
|
||||
backend: backend.clone(),
|
||||
policy,
|
||||
websocket,
|
||||
keepalive: keepalive.clone(),
|
||||
timeout,
|
||||
};
|
||||
let current = build_raw_client(&factory)?;
|
||||
Ok(ProxyClient::new(current, factory))
|
||||
}
|
||||
|
||||
fn build_raw_client(factory: &ClientFactory) -> Result<RawProxyClient> {
|
||||
let backend = &factory.backend;
|
||||
let policy = &factory.policy;
|
||||
let websocket = factory.websocket;
|
||||
let keepalive = &factory.keepalive;
|
||||
let timeout = factory.timeout;
|
||||
let is_https = url::Url::parse(&backend.url)?.scheme() == "https";
|
||||
let protocol = if websocket {
|
||||
UpstreamProtocol::Http1
|
||||
} else if backend.protocol == UpstreamProtocol::Http2 && is_https {
|
||||
UpstreamProtocol::Auto
|
||||
} else {
|
||||
backend.protocol
|
||||
};
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let trust_roots = if backend.tls_verify {
|
||||
roots(backend)?
|
||||
} else {
|
||||
public_roots()
|
||||
};
|
||||
let mut config = ClientConfig::builder_with_provider(provider.clone())
|
||||
.with_safe_default_protocol_versions()?
|
||||
.with_root_certificates(trust_roots)
|
||||
.with_no_client_auth();
|
||||
if !backend.tls_verify {
|
||||
let supported_schemes = provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes();
|
||||
config.dangerous().set_certificate_verifier(Arc::new(
|
||||
BackendCertificateVerificationDisabled { supported_schemes },
|
||||
));
|
||||
}
|
||||
let connect_host = if backend.tls_server_name.is_some() {
|
||||
Some(
|
||||
url::Url::parse(&backend.url)?
|
||||
.host_str()
|
||||
.context("backend host missing")?
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.to_owned(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut http = hyper_util::client::legacy::connect::HttpConnector::new_with_resolver(
|
||||
super::GuardedResolver {
|
||||
policy: policy.clone(),
|
||||
connect_host,
|
||||
},
|
||||
);
|
||||
http.enforce_http(false);
|
||||
http.set_connect_timeout(Some(timeout));
|
||||
http.set_nodelay(true);
|
||||
http.set_keepalive(
|
||||
keepalive
|
||||
.enabled
|
||||
.then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)),
|
||||
);
|
||||
let builder = HttpsConnectorBuilder::new()
|
||||
.with_tls_config(config)
|
||||
.https_or_http();
|
||||
let inner = match protocol {
|
||||
UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http),
|
||||
UpstreamProtocol::Http2 => builder.enable_http2().wrap_connector(http),
|
||||
UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http),
|
||||
};
|
||||
let mut builder = Client::builder(TokioExecutor::new());
|
||||
builder
|
||||
.http1_title_case_headers(true)
|
||||
.http1_max_buf_size(keepalive.http1_max_buffer_bytes)
|
||||
.pool_timer(TokioTimer::new())
|
||||
.pool_idle_timeout(Duration::from_secs(keepalive.idle_timeout_seconds))
|
||||
.pool_max_idle_per_host(if keepalive.enabled {
|
||||
keepalive.max_idle_per_host
|
||||
} else {
|
||||
0
|
||||
})
|
||||
.retry_canceled_requests(false)
|
||||
.http2_only(protocol == UpstreamProtocol::Http2)
|
||||
.timer(TokioTimer::new())
|
||||
.http2_keep_alive_interval(
|
||||
keepalive
|
||||
.enabled
|
||||
.then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)),
|
||||
)
|
||||
.http2_keep_alive_timeout(Duration::from_secs(10))
|
||||
.http2_keep_alive_while_idle(false);
|
||||
Ok(builder.build(TimedConnector {
|
||||
inner,
|
||||
policy: policy.clone(),
|
||||
timeout,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("client_tests.rs");
|
||||
@@ -0,0 +1,56 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn backend(id: &str, port: u16) -> Backend {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": id,
|
||||
"url": format!("http://127.0.0.1:{port}"),
|
||||
"protocol": "http1",
|
||||
"allow_private": true
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_recycles_after_request_budget() {
|
||||
let backend = backend("rotation-test", 18081);
|
||||
let mut keepalive = crate::balance::Keepalive::default();
|
||||
keepalive.pool_recycle_requests = 2;
|
||||
keepalive.pool_recycle_seconds = 0;
|
||||
let client = client_with_keepalive(
|
||||
&backend,
|
||||
AddressPolicy::new(&backend).unwrap(),
|
||||
false,
|
||||
&keepalive,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
client.inner.client_for_request();
|
||||
client.inner.client_for_request();
|
||||
assert_eq!(client.rotation_stats().rotations, 0);
|
||||
client.inner.client_for_request();
|
||||
let stats = client.rotation_stats();
|
||||
assert_eq!(stats.rotations, 1);
|
||||
assert_eq!(stats.generation, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_recycling_can_be_disabled() {
|
||||
let backend = backend("rotation-off", 18082);
|
||||
let mut keepalive = crate::balance::Keepalive::default();
|
||||
keepalive.pool_recycle_requests = 0;
|
||||
keepalive.pool_recycle_seconds = 0;
|
||||
let client = client_with_keepalive(
|
||||
&backend,
|
||||
AddressPolicy::new(&backend).unwrap(),
|
||||
false,
|
||||
&keepalive,
|
||||
)
|
||||
.unwrap();
|
||||
for _ in 0..10 {
|
||||
client.inner.client_for_request();
|
||||
}
|
||||
assert_eq!(client.rotation_stats().rotations, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod tests {
|
||||
use http::{Request, Response, Version};
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::service::service_fn;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
use std::convert::Infallible;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -156,7 +156,7 @@ pub(super) fn fastcgi_params(
|
||||
}
|
||||
params.insert("HTTPS".into(), if context.tls { "on" } else { "off" }.into());
|
||||
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.5".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.6".into());
|
||||
params.insert("REDIRECT_STATUS".into(), "200".into());
|
||||
if let Some(value) = headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()) {
|
||||
params.insert("CONTENT_TYPE".into(), value.into());
|
||||
|
||||
@@ -180,7 +180,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
|
||||
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
|
||||
));
|
||||
}
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.25.5</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.25.6</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.into_bytes()
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("file.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
assert!(body.contains("RustProxy <b>0.25.5</b>"));
|
||||
assert!(body.contains("RustProxy <b>0.25.6</b>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in new issue
Block a user