Skip to content
Open
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
6 changes: 1 addition & 5 deletions .build-service/config.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
[connection]
endpoint = "http://pc:8882"
local_fallback = true

[sources]
include = ["src/**", "tests/**", "acl-proxy.sample.toml", "Cargo.toml", "Cargo.lock"]
exclude = []
Expand All @@ -27,7 +23,7 @@ CARGO_HOME = "/opt/rust"
reuse = true
id = "acl-proxy"
create = true
ttl_sec = 0
ttl_sec = 86400

[output]
stdout_max_lines = 10
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ script. Then add a fresh `## [Unreleased]` section with the standard

Release archives are packaged separately after the GitHub release exists. Use
the README release section as the source of truth for archive names and
contents. The supported release platforms are currently `linux-x86_64` and
contents. Build target-platform binaries using repo-local build commands,
native platform builds, or available supplemental build instructions. The
supported release platforms are currently `linux-x86_64`, `linux-arm64`, and
`macos-arm64`, with `bin/acl-proxy` and
`bin/acl-proxy-extract-capture-body` in the archive.
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,25 @@

## [Unreleased]

_No unreleased changes._
### Breaking Changes

- Replaced the flat proxy listener settings with `proxy.listeners.*` sections for explicit HTTP, transparent HTTP, explicit HTTPS, and transparent HTTPS listeners.
- Changed `proxy.egress.default` to require an explicit parent-proxy `type` and `transport`.
- Replaced the `PROXY_PORT` and `PROXY_HOST` environment overrides with `PROXY_EXPLICIT_HTTP_BIND`.
- Replaced egress transport log value `chain` with `egress_type = "explicit_proxy"` and `egress_transport = "http"` or `"https"`.

### Added

- Add an explicit HTTPS proxy listener with an operator-managed static proxy certificate and key.
- Add HTTPS parent-proxy egress forwarding with configurable TLS trust for system roots, custom CAs, combined system-plus-custom roots, or disabled verification.
- Add `proxy.shutdown_drain_timeout_ms` to optionally bound graceful shutdown draining; the default `0` waits indefinitely for in-flight requests.

### Changed

- Split explicit HTTP and transparent HTTP handling onto separate configured sockets, and reject listener topology changes during reload.
- Graceful shutdown now closes idle keep-alive connections and drains in-flight requests across all configured listeners before exit.
- Documented `linux-arm64` release archives and release-operator supplemental
build instructions.

## [0.2.0] - 2026-06-25

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.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ chrono = { version = "0.4", features = ["serde", "clock"] }
http = "0.2"
hyper-rustls = { version = "0.24", features = ["rustls-native-certs", "webpki-roots", "http1", "http2", "tokio-runtime"] }
rustls = { version = "0.21", features = ["dangerous_configuration"] }
rustls-native-certs = "0.6"
tokio-rustls = "0.24"
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
rustls-pemfile = "1"
Expand Down
168 changes: 109 additions & 59 deletions README.md

Large diffs are not rendered by default.

38 changes: 21 additions & 17 deletions acl-proxy.sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,30 @@ schema_version = "1"
###############################################################################

[proxy]
# HTTP listener. Supports both explicit-proxy absolute-form HTTP/1.1
# requests (e.g. via curl -x) and transparent HTTP origin-form requests
# redirected from port 80.
bind_address = "0.0.0.0"
http_port = 8881

# Transparent HTTPS listener. When enabled (https_port != 0), the proxy
# terminates TLS directly and applies the same policy engine to HTTPS
# traffic. For local-only use, you may prefer "127.0.0.1" and/or
# disable this listener by setting https_port = 0.
https_bind_address = "0.0.0.0"
https_port = 8889
# Explicit HTTP proxy listener. Accepts absolute-form proxy requests and
# CONNECT tunnels.
listeners.explicit_http.bind = "0.0.0.0:8881"

# Optional transparent HTTP listener for redirected port-80 traffic.
# listeners.transparent_http.bind = "0.0.0.0:8882"

# Transparent HTTPS listener. Missing listener sections are disabled.
listeners.transparent_https.bind = "0.0.0.0:8889"

# Timeout (ms) for upstream responses (0 disables).
request_timeout_ms = 30000

# Graceful shutdown drain timeout (ms). The default 0 waits indefinitely for
# in-flight requests to finish; positive values abort remaining connection tasks
# after the timeout.
shutdown_drain_timeout_ms = 0

# Transparent HTTPS listener slow-client protections. Set a value to 0 to
# disable that limit.
https_handshake_timeout_ms = 10000
https_request_header_timeout_ms = 10000
https_max_connections = 1024
https_http2_max_concurrent_streams = 128
listeners.transparent_https.handshake_timeout_ms = 10000
listeners.transparent_https.request_header_timeout_ms = 10000
listeners.transparent_https.max_connections = 1024
listeners.transparent_https.http2_max_concurrent_streams = 128

# Base path for internal endpoints (readiness probe and external auth callbacks).
internal_base_path = "/_acl-proxy"
Expand All @@ -56,8 +58,10 @@ internal_base_path = "/_acl-proxy"
# request destination.
#
# [proxy.egress.default]
# type = "explicit_proxy"
# transport = "http"
# host = "172.17.0.1"
# port = 8889
# port = 8881

# Optional global outbound request header actions. These apply to every
# forwarded request (explicit HTTP proxy, CONNECT inner requests, and
Expand Down
192 changes: 189 additions & 3 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::auth_plugin::AuthPluginManager;
use crate::certs::CertManager;
use crate::config::{Config, ConfigError, TlsConfig};
use crate::config::{
Config, ConfigError, EgressTargetConfig, EgressTlsTrust, EgressTransport, TlsConfig,
};
use crate::external_auth::ExternalAuthManager;
use crate::logging::{LoggingError, LoggingSettings};
use crate::loop_protection::{LoopProtectionError, LoopProtectionSettings};
Expand All @@ -11,7 +13,9 @@ use arc_swap::ArcSwap;
use hyper::Client;
use hyper_rustls::{ConfigBuilderExt, HttpsConnectorBuilder};
use rustls::client::{ServerCertVerified, ServerCertVerifier};
use rustls::{Certificate, ClientConfig, Error as RustlsError, ServerName};
use rustls::{Certificate, ClientConfig, Error as RustlsError, RootCertStore, ServerName};
use std::net::IpAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::SystemTime;

Expand All @@ -37,6 +41,23 @@ pub enum AppStateError {
/// using the previous snapshot.
pub type SharedAppState = Arc<ArcSwap<AppState>>;

#[derive(Clone)]
pub struct ParentEgressTls {
pub server_name: String,
http1_config: Arc<ClientConfig>,
h2_config: Arc<ClientConfig>,
}

impl ParentEgressTls {
pub fn client_config(&self, use_http2: bool) -> Arc<ClientConfig> {
if use_http2 {
self.h2_config.clone()
} else {
self.http1_config.clone()
}
}
}

#[derive(Clone)]
pub struct AppState {
pub config: Config,
Expand All @@ -45,6 +66,7 @@ pub struct AppState {
pub egress_request_header_actions: Vec<CompiledHeaderAction>,
pub loop_protection: LoopProtectionSettings,
pub http_client: Client<hyper_rustls::HttpsConnector<hyper::client::HttpConnector>>,
pub parent_egress_tls: Option<ParentEgressTls>,
pub cert_manager: CertManager,
pub external_auth: ExternalAuthManager,
pub auth_plugins: AuthPluginManager,
Expand All @@ -67,6 +89,7 @@ impl AppState {
let loop_protection = LoopProtectionSettings::from_config(&config.loop_protection)?;

let http_client = build_http_client(&config.tls);
let parent_egress_tls = build_parent_egress_tls(&config)?;
let cert_manager = CertManager::from_config(&config.certificates)?;
let external_auth = if let Some(previous) = previous {
ExternalAuthManager::reconfigured_from(
Expand All @@ -91,6 +114,7 @@ impl AppState {
egress_request_header_actions,
loop_protection,
http_client,
parent_egress_tls,
cert_manager,
external_auth,
auth_plugins,
Expand All @@ -106,19 +130,181 @@ impl AppState {
/// Rebuild and atomically swap the shared application state from
/// the provided configuration.
///
/// On success, new connections will observe the updated state,
/// On success, new requests will observe the updated state,
/// while in-flight requests continue using their existing snapshot.
pub fn reload_shared_from_config(
shared: &SharedAppState,
config: Config,
) -> Result<(), AppStateError> {
let previous = shared.load_full();
if previous.config.proxy.listeners != config.proxy.listeners {
return Err(AppStateError::Config(ConfigError::Invalid(
"listener configuration changed; restart required for proxy.listeners changes"
.to_string(),
)));
}
let new_state = AppState::from_config_with_previous(config, Some(previous.as_ref()))?;
shared.store(Arc::new(new_state));
Ok(())
}
}

fn build_parent_egress_tls(config: &Config) -> Result<Option<ParentEgressTls>, AppStateError> {
let Some(target) = config.proxy.egress.default.as_ref() else {
return Ok(None);
};
if target.transport != EgressTransport::Https {
return Ok(None);
}

let server_name = normalized_tls_server_name(target.tls_server_name());
ServerName::try_from(server_name.as_str()).map_err(|err| {
ConfigError::Invalid(format!(
"proxy.egress.default.tls.server_name must be a valid TLS server name: {err}"
))
})?;

let http1_config = build_parent_tls_client_config(target, b"http/1.1")?;
let h2_config = build_parent_tls_client_config(target, b"h2")?;

Ok(Some(ParentEgressTls {
server_name,
http1_config: Arc::new(http1_config),
h2_config: Arc::new(h2_config),
}))
}

fn build_parent_tls_client_config(
target: &EgressTargetConfig,
alpn: &'static [u8],
) -> Result<ClientConfig, ConfigError> {
let tls = target.tls.as_ref();
let trust = target.effective_tls_trust();
let mut config = match trust {
EgressTlsTrust::System => {
let roots = root_store_with_native_roots()?;
ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(roots)
.with_no_client_auth()
}
EgressTlsTrust::CustomCa => {
let roots = root_store_from_custom_ca(
tls.and_then(|tls| tls.ca_cert_path.as_deref())
.ok_or_else(|| {
ConfigError::Invalid(
"proxy.egress.default.tls.ca_cert_path is required".to_string(),
)
})?,
)?;
ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(roots)
.with_no_client_auth()
}
EgressTlsTrust::SystemPlusCustomCa => {
let mut roots = root_store_with_native_roots()?;
let ca_path = tls
.and_then(|tls| tls.ca_cert_path.as_deref())
.ok_or_else(|| {
ConfigError::Invalid(
"proxy.egress.default.tls.ca_cert_path is required".to_string(),
)
})?;
add_custom_ca_to_root_store(&mut roots, ca_path)?;
ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(roots)
.with_no_client_auth()
}
EgressTlsTrust::Disabled => {
if alpn == b"http/1.1" {
tracing::warn!(
target: "acl_proxy::transport",
egress_host = %target.host,
egress_port = target.port,
egress_tls_server_name = %target.tls_server_name(),
"HTTPS parent egress certificate verification is disabled"
);
}
let mut config = ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(RootCertStore::empty())
.with_no_client_auth();
config
.dangerous()
.set_certificate_verifier(Arc::new(NoVerifyServerCert));
config
}
};

config.alpn_protocols = vec![alpn.to_vec()];
config.enable_sni = normalized_tls_server_name(target.tls_server_name())
.parse::<IpAddr>()
.is_err();

Ok(config)
}

fn root_store_with_native_roots() -> Result<RootCertStore, ConfigError> {
let mut roots = RootCertStore::empty();
let certs = rustls_native_certs::load_native_certs().map_err(|err| {
ConfigError::Invalid(format!("failed to load system certificate roots: {err}"))
})?;
for cert in certs {
roots.add(&Certificate(cert.0)).map_err(|err| {
ConfigError::Invalid(format!(
"failed to add system certificate root to HTTPS parent egress trust store: {err}"
))
})?;
}
Ok(roots)
}

fn root_store_from_custom_ca(path: &Path) -> Result<RootCertStore, ConfigError> {
let mut roots = RootCertStore::empty();
add_custom_ca_to_root_store(&mut roots, path)?;
Ok(roots)
}

fn add_custom_ca_to_root_store(roots: &mut RootCertStore, path: &Path) -> Result<(), ConfigError> {
let file = std::fs::File::open(path).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
let mut reader = std::io::BufReader::new(file);
let certs = rustls_pemfile::certs(&mut reader).map_err(|err| {
ConfigError::Invalid(format!(
"proxy.egress.default.tls.ca_cert_path {} must contain valid PEM certificates: {err}",
path.display()
))
})?;
if certs.is_empty() {
return Err(ConfigError::Invalid(format!(
"proxy.egress.default.tls.ca_cert_path {} must contain at least one PEM certificate",
path.display()
)));
}

for cert in certs {
roots.add(&Certificate(cert)).map_err(|err| {
ConfigError::Invalid(format!(
"failed to add proxy.egress.default.tls.ca_cert_path {} certificate: {err}",
path.display()
))
})?;
}
Ok(())
}

fn normalized_tls_server_name(value: &str) -> String {
value
.strip_prefix('[')
.and_then(|inner| inner.strip_suffix(']'))
.unwrap_or(value)
.to_string()
}

fn build_http_client(
tls: &TlsConfig,
) -> Client<hyper_rustls::HttpsConnector<hyper::client::HttpConnector>> {
Expand Down
3 changes: 1 addition & 2 deletions src/capture/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,8 +499,7 @@ mod tests {
schema_version = "1"

[proxy]
bind_address = "127.0.0.1"
http_port = 8080
listeners.explicit_http.bind = "127.0.0.1:8080"

[logging]
directory = "logs"
Expand Down
Loading