Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ target
3rdparty/
audit/
plan/
crates/.DS_Store
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# AGENTS.md

- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations.
- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
- Keep components modular and concerns clearly separated.
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
25 changes: 22 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,32 @@ binary CLI surface.
- `[security.auth]` — optional `bearer` shared-secret authentication.
- `security.require_client_binding` — hard-fail startup without origin
whitelist, bearer auth, or mTLS.
- `[target]` — host/scheme admission (`any_public` | `allowlist` | `denylist`).
- `[circuit_breaker]` — process-local per-host circuit breaker.
- `limits.redirect_policy` — `follow` | `block` | `rewrite`.
- `[target]` — host/scheme admission (`any_public` | `allowlist` | `denylist`),
enforced on **every redirect hop**.
- `[circuit_breaker]` — process-local per-host circuit breaker (`max_hosts`
soft cap, default 8192).
- `limits.redirect_policy` — `follow` | `block` | `rewrite` (rewrite stamps
proxy path-prefix `Location`).
- `limits.inflight_max` — process concurrency load-shed (moved from
`rate_limit.global`).
- `limits.max_response_body_bytes` — streaming response size cap (default
50 MiB; `0` = unlimited).
- `rate_limit.max_keys` — fail-closed cardinality cap for keyed GCRA maps.
- `cors.origins` + `cors.allow_any_origin` (unified origin list).
- CatchPanic layer; load-shed inflight RAII permit; hot-reload retains
circuit / rate-limit maps when those config sections are unchanged.

### Changed (BREAKING)

- `rate_limit.global.inflight_max` **removed** — use `limits.inflight_max`.
- `rate_limit.enabled` default is now **`true`** (GCRA on by default).
- Client error JSON `message` is kind-stable (no internal DNS/connect detail).
- `CircuitDecision` public enum removed; `CircuitBreaker::check` returns
`Result<(), ProxyError>`.
- CORS middleware sits outside auth/load-shed/header-limit so 401/503/431
include CORS headers; success path no longer double-stamps CORS in the
handler.

- Cleartext listeners honour `server.graceful_shutdown` with a force-abort
deadline after SIGTERM/Ctrl+C (previously only the TLS path used the
duration).
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions charts/corx/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ config: |
[limits]
max_request_body_bytes = 10485760
max_request_header_bytes = 32768
max_response_body_bytes = 52428800
inflight_max = 1024
request_timeout = "60s"
connect_timeout = "5s"
max_redirects = 5
Expand Down Expand Up @@ -138,6 +140,7 @@ config: |
open_duration = "30s"
half_open_max = 1
count_5xx = true
max_hosts = 8192

[ssrf]
mode = { kind = "strict" }
Expand All @@ -152,6 +155,7 @@ config: |

[rate_limit]
enabled = true
max_keys = 16384

[rate_limit.origin]
rps = 50
Expand All @@ -170,12 +174,10 @@ config: |
[rate_limit.global]
rps = 5000
burst = 10000
inflight_max = 1024

[upstream]
pool_max_idle_per_host = 32
pool_idle_timeout = "90s"
user_agent = "corx/1.0.0"

[observability]
log_format = "json"
Expand Down
66 changes: 33 additions & 33 deletions corx.example.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# corx configuration reference. All fields are required unless marked optional.
# The effective configuration layers this file, the CORX_* environment variables
# (double underscores separate nested keys, e.g. CORX_SERVER__BIND=...) and CLI
# flags, in increasing order of precedence.
# corx configuration reference.
# Layer order (increasing precedence): defaults → this file / CORX_CONFIG → CORX_* env → CLI.
# Nested env keys use `__`, e.g. CORX_SERVER__BIND=0.0.0.0:9000

[server]
bind = "0.0.0.0:8080"
Expand All @@ -15,16 +14,19 @@ http2 = true
[limits]
max_request_body_bytes = 10_485_760 # 10 MiB
max_request_header_bytes = 32_768 # 32 KiB
max_response_body_bytes = 52_428_800 # 50 MiB; 0 = unlimited
inflight_max = 1000 # process concurrency cap; 0 = off
request_timeout = "60s"
connect_timeout = "10s"
max_redirects = 5
allow_https_to_http_downgrade = false # reject https → http redirects by default
allow_https_to_http_downgrade = false
redirect_policy = "follow" # follow | block | rewrite

[cors]
policy = "reflect" # wildcard | reflect | explicit
origins = [] # gate for reflect / list for explicit
allow_any_origin = true # demo: echo any Origin; production: false + origins
origins = [] # production: list trusted origins
# Production: keep false and populate `origins`. Demo only:
allow_any_origin = false
allowed_methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
allowed_headers = [
"accept",
Expand All @@ -38,12 +40,12 @@ allowed_headers = [
exposed_headers = ["x-corx-status", "x-corx-target-url", "x-request-id"]
max_age = "600s"
allow_credentials = false
allow_private_network = false # PNA handshake (Chromium et al.)
allow_private_network = false

[forwarded]
inject = true # inject X-Forwarded-* + RFC 7239 Forwarded
trust_inbound_xff = false # do not trust client-supplied XFF chains
inject_request_id = true # generate UUID v7 when missing
inject = true
trust_inbound_xff = false
inject_request_id = true

[security]
require_header = ["origin"]
Expand All @@ -52,16 +54,15 @@ remove_request_headers = ["cookie", "cookie2"]
remove_response_headers = ["set-cookie", "set-cookie2"]
origin_blacklist = []
origin_whitelist = []
# require_client_binding = true # refuse start without whitelist / bearer / mTLS

[security.preflight]
mode = "enforce" # enforce | open (open = cors-anywhere: preflight before guards)
rate_limit = true # charge multi-dimensional rate limiter on OPTIONS
mode = "enforce" # enforce | open
rate_limit = true

[security.auth]
mode = "none" # none | bearer
bearer_tokens = [] # required when mode = bearer

# require_client_binding = false # true → must set origin_whitelist, bearer, or mTLS
bearer_tokens = []

[target]
mode = "any_public" # any_public | allowlist | denylist
Expand All @@ -76,52 +77,51 @@ window = "30s"
open_duration = "30s"
half_open_max = 1
count_5xx = true
max_hosts = 8192

[ssrf]
# mode = { kind = "strict" } # production default (fail-closed)
# mode = { kind = "permissive", allow_private = false } # only RFC 1918 + loopback admitted
mode = { kind = "strict" }
allow_ipv6 = true
extra_blocked_cidrs = [] # add to the built-in block list
extra_allowed_cidrs = [] # punch holes for trusted internal CIDRs
extra_blocked_cidrs = []
extra_allowed_cidrs = []

[rate_limit]
enabled = false # master switch
enabled = true
max_keys = 16384

[rate_limit.origin]
rps = 50
burst = 100
unlimited_patterns = [] # regex on the Origin header
unlimited_patterns = []

[rate_limit.ip]
rps = 30
burst = 60
trusted_cidrs = [] # CIDRs exempt from per-IP limits
trusted_cidrs = []

[rate_limit.target_host]
rps = 100
burst = 200

[rate_limit.global]
rps = 5000 # global RPS cap
burst = 10000
inflight_max = 1000 # 0 disables the load-shed layer
rps = 5000
burst = 10000

[upstream]
pool_max_idle_per_host = 32
pool_idle_timeout = "90s"
user_agent = "corx/1.0.0"
# Defaults to corx/<crate-version> when omitted from a minimal config.

[observability]
log_format = "json" # json | pretty
log_format = "json"
log_level = "info"
metrics_endpoint = "/metrics"

[observability.otel]
enabled = false # requires the `otel` cargo feature
enabled = false
endpoint = "http://otel-collector:4317"
protocol = "grpc" # grpc | http
protocol = "grpc"
service_name = "corx"
service_namespace = "" # optional logical grouping
resource_attributes = [] # ["deployment.environment=prod", "team=platform"]
sample_ratio = 0.1 # 1.0 keeps every span; 0.1 keeps 10%
service_namespace = ""
resource_attributes = []
sample_ratio = 0.1
10 changes: 5 additions & 5 deletions crates/corx-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ async fn serve(config_path: Option<&Path>) -> anyhow::Result<()> {
let build = ServerBuild::from_config(config.clone(), metrics)?;
let ready = std::sync::Arc::clone(&build.ready);

// Hot-reload watcher (Unix only): SIGHUP triggers a fresh load of the
// same config path. Hot-swappable fields are atomically replaced via
// `arc-swap`; immutable fields (bind address, TLS material) only log a
// warning and are otherwise ignored. The watcher exits with the
// server.
// Hot-reload watcher (Unix only): SIGHUP reloads the same config path.
// Hot-swappable policy is swapped via `arc-swap`. Attempts to change
// immutable fields (bind, TLS, body/header limits, timeouts, metrics
// path, inflight/response caps) are rejected and the previous snapshot
// stays active.
#[cfg(unix)]
{
let owned_path = config_path.map(Path::to_path_buf);
Expand Down
11 changes: 10 additions & 1 deletion crates/corx-core/src/config/circuit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use super::default_true;
///
/// State is not shared across replicas; pair with external rate limiting when
/// multi-instance consistency is required.
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CircuitBreakerConfig {
/// Master switch. Default: enabled with conservative thresholds.
Expand All @@ -31,6 +31,10 @@ pub struct CircuitBreakerConfig {
/// Count upstream HTTP 5xx as failures (in addition to connect/timeout).
#[serde(default = "default_true")]
pub count_5xx: bool,
/// Soft cap on tracked host keys. Idle closed entries are evicted when
/// the map exceeds this size (cardinality / abuse defence).
#[serde(default = "default_max_hosts")]
pub max_hosts: usize,
}

const fn default_failure_threshold() -> u32 {
Expand All @@ -49,6 +53,10 @@ const fn default_open_duration() -> Duration {
Duration::from_secs(30)
}

const fn default_max_hosts() -> usize {
8192
}

impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
Expand All @@ -58,6 +66,7 @@ impl Default for CircuitBreakerConfig {
open_duration: default_open_duration(),
half_open_max: default_half_open_max(),
count_5xx: true,
max_hosts: default_max_hosts(),
}
}
}
22 changes: 20 additions & 2 deletions crates/corx-core/src/config/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const MIB: u64 = 1024 * 1024;
#[serde(rename_all = "lowercase")]
pub enum RedirectPolicy {
/// Follow redirects in-proxy (up to [`LimitsConfig::max_redirects`]),
/// re-validating SSRF on every hop. **Default.**
/// re-validating SSRF and target policy on every hop. **Default.**
#[default]
Follow,
/// Do not follow; surface a proxy error instead of leaking Location.
Expand All @@ -22,13 +22,21 @@ pub enum RedirectPolicy {
}

/// Size- and time-based limits applied to every request.
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct LimitsConfig {
/// Maximum inbound request body size, in bytes.
pub max_request_body_bytes: u64,
/// Maximum inbound header size, in bytes.
pub max_request_header_bytes: u32,
/// Maximum upstream response body size, in bytes. `0` disables the cap.
/// When exceeded the stream is aborted mid-transfer.
#[serde(default = "default_max_response_body_bytes")]
pub max_response_body_bytes: u64,
/// Maximum concurrent in-flight requests process-wide. `0` disables
/// load-shed. Independent of GCRA rate limiting.
#[serde(default = "default_inflight_max")]
pub inflight_max: u32,
/// Total allowable duration of a single proxied request, end-to-end.
#[serde(with = "humantime_serde")]
pub request_timeout: Duration,
Expand All @@ -45,11 +53,21 @@ pub struct LimitsConfig {
pub redirect_policy: RedirectPolicy,
}

const fn default_max_response_body_bytes() -> u64 {
50 * MIB
}

const fn default_inflight_max() -> u32 {
1_000
}

impl Default for LimitsConfig {
fn default() -> Self {
Self {
max_request_body_bytes: 10 * MIB,
max_request_header_bytes: 32 * 1024,
max_response_body_bytes: default_max_response_body_bytes(),
inflight_max: default_inflight_max(),
request_timeout: Duration::from_mins(1),
connect_timeout: Duration::from_secs(10),
max_redirects: 5,
Expand Down
Loading
Loading