diff --git a/.build-service/config.toml b/.build-service/config.toml
index 71c5a79..37709fa 100644
--- a/.build-service/config.toml
+++ b/.build-service/config.toml
@@ -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 = []
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 2896b19..afbee5e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cfd542b..57ed934 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Cargo.lock b/Cargo.lock
index a48131d..e123ea5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -28,6 +28,7 @@ dependencies = [
"rcgen",
"regex",
"rustls",
+ "rustls-native-certs",
"rustls-pemfile",
"serde",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 5126d7f..2b56457 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index f26966e..1abeb27 100644
--- a/README.md
+++ b/README.md
@@ -23,15 +23,17 @@ A Rust-based ACL-aware HTTP/HTTPS proxy with a TOML configuration file and a fle
## How It Works
-acl-proxy sits between clients and upstream services, intercepting HTTP and HTTPS traffic across four request paths. Every request is evaluated by the same policy engine regardless of how it enters the proxy.
+acl-proxy sits between clients and upstream services, intercepting HTTP and HTTPS traffic across separate listener-backed modes. Every request is evaluated by the same policy engine regardless of how it enters the proxy.
```mermaid
graph TB
Client["Client"]
subgraph acl-proxy
- HTTP["HTTP Listener :8881"]
- HTTPS["Transparent HTTPS Listener :8889"]
+ ExplicitHTTP["Explicit HTTP Proxy :8881"]
+ TransparentHTTP["Transparent HTTP :8882"]
+ ExplicitHTTPS["Explicit HTTPS Proxy :8890"]
+ TransparentHTTPS["Transparent HTTPS :8889"]
LP["Loop Protection"]
PE["Policy Engine pattern · method · subnet · headers"]
EA["External Auth (delegate rules)"]
@@ -41,10 +43,14 @@ graph TB
Upstream["Upstream Services"]
- Client -->|"explicit proxy transparent HTTP CONNECT MITM"| HTTP
- Client -->|"transparent HTTPS HTTP/2 · WebSocket"| HTTPS
- HTTP --> LP
- HTTPS --> LP
+ Client -->|"absolute-form HTTP CONNECT MITM"| ExplicitHTTP
+ Client -->|"origin-form redirected HTTP"| TransparentHTTP
+ Client -->|"TLS to proxy absolute-form HTTP CONNECT MITM"| ExplicitHTTPS
+ Client -->|"target-shaped TLS HTTP/2 · WebSocket"| TransparentHTTPS
+ ExplicitHTTP --> LP
+ TransparentHTTP --> LP
+ ExplicitHTTPS --> LP
+ TransparentHTTPS --> LP
LP --> PE
PE -->|allow| HA
PE -->|delegate| EA
@@ -69,6 +75,7 @@ https://github.com/kcosr/acl-proxy/releases
Supported release platforms are currently:
- `linux-x86_64`
+- `linux-arm64`
- `macos-arm64`
Extract the archive on the host that will run `acl-proxy`. The archive contains
@@ -124,8 +131,8 @@ acl-proxy --config /etc/acl-proxy/acl-proxy.toml
```
The proxy starts:
-- The HTTP listener on `proxy.bind_address:proxy.http_port` (explicit proxy and transparent HTTP interception).
-- The transparent HTTPS listener on `proxy.https_bind_address:proxy.https_port` if `https_port` is non-zero.
+- Any listener configured under `proxy.listeners.*`. Missing listener sections are disabled.
+- The sample enables `explicit_http` on `0.0.0.0:8881` and `transparent_https` on `0.0.0.0:8889`.
### 3. Send Traffic
@@ -138,12 +145,17 @@ curl -x http://127.0.0.1:8881 http://example.com/
# Transparent HTTP interception (local testing)
curl http://example.com/path \
- --connect-to example.com:80:127.0.0.1:8881
+ --connect-to example.com:80:127.0.0.1:8882
# HTTPS proxy (CONNECT MITM)
curl -x http://127.0.0.1:8881 https://example.com/ \
--proxy-cacert certs/ca-cert.pem
+# Explicit HTTPS proxy (static proxy certificate)
+# Requires proxy.listeners.explicit_https to be configured.
+curl -x https://work-proxy.internal:8890 http://example.com/ \
+ --proxy-cacert /etc/acl-proxy/proxy-ca.pem
+
# Transparent HTTPS listener
curl https://upstream.internal/resource \
--connect-to upstream.internal:443:127.0.0.1:8889 \
@@ -162,10 +174,11 @@ acl-proxy supports four request paths. All modes apply the same policy engine, l
| Mode | Listener | Protocol | Description |
|------|----------|----------|-------------|
-| **Explicit HTTP proxy** | `http_port` | HTTP/1.1 | Absolute-form requests via `curl -x` |
-| **Transparent HTTP** | `http_port` | HTTP/1.1 | Origin-form requests with `Host` header (e.g., iptables `REDIRECT`/DNAT from port 80) |
-| **CONNECT MITM** | `http_port` | HTTPS | TLS terminated inside tunnel with per-host CA-signed certs; inner requests processed as HTTP/1.1 |
-| **Transparent HTTPS** | `https_port` | HTTPS | Direct TLS termination; HTTP/1.1 and HTTP/2 via ALPN negotiation |
+| **Explicit HTTP proxy** | `proxy.listeners.explicit_http` | HTTP/1.1, optional h2c | Absolute-form requests via `curl -x`; HTTP/1.1 CONNECT |
+| **Transparent HTTP** | `proxy.listeners.transparent_http` | HTTP/1.1, optional h2c | Origin-form requests with `Host` header (e.g. iptables `REDIRECT`/DNAT from port 80) |
+| **Explicit HTTPS proxy** | `proxy.listeners.explicit_https` | TLS to proxy, HTTP/1.1 by default | Static proxy certificate; absolute-form proxy requests inside TLS |
+| **CONNECT MITM** | `explicit_http` / `explicit_https` | HTTPS inside CONNECT | TLS terminated inside tunnel with per-host CA-signed certs; inner requests processed as HTTP/1.1 |
+| **Transparent HTTPS** | `proxy.listeners.transparent_https` | Target-shaped TLS | Direct TLS termination; HTTP/1.1 and HTTP/2 via ALPN negotiation |
### Request Lifecycle
@@ -217,6 +230,10 @@ Forwarded requests and responses strip hop-by-hop and proxy-control headers, inc
In transparent HTTP mode, upstream target selection is based on the inbound `Host` header (`:80` when no port is present). Use restrictive policy rules for the destinations you intend to allow.
+### Explicit HTTPS Proxy
+
+`proxy.listeners.explicit_https` presents a static certificate for the proxy service itself. It does not generate certificates dynamically; configure `cert_chain_path` and `key_path` with an operator-managed proxy certificate whose SAN matches the hostname or IP clients use for the proxy.
+
### HTTPS CONNECT (MITM)
- Policy is evaluated on each decrypted request; the CONNECT request itself is only used to establish the tunnel.
@@ -228,7 +245,7 @@ In transparent HTTP mode, upstream target selection is based on the inbound `Hos
### Transparent HTTPS
-- Set `https_port = 0` to disable this listener.
+- Omit `proxy.listeners.transparent_https` to disable this listener.
- URL construction is based on the Host header or the request URI authority and uses the same canonicalization as policy matching.
- TLS SNI is used only to select the inbound certificate presented to the client. It is not treated as a policy or routing authority and is not required to match the decrypted HTTP `Host` header.
- Inbound HTTP/2 is supported via ALPN negotiation.
@@ -260,10 +277,10 @@ When enabled, ALPN is used per origin; the proxy will use HTTP/2 where supported
When `[proxy.egress.default]` is configured, allowed proxied requests from all modes are sent to the configured egress host:port instead of the original target.
-- The forwarding leg remains cleartext TCP — deploy on a trusted/local network path.
-- Forwarding is protocol-aware: HTTP/2 requests use h2c, HTTP/1.1 (including WebSocket) stays HTTP/1.1.
+- The forwarding leg targets a parent explicit proxy over `transport = "http"` or `transport = "https"`.
+- Forwarding is protocol-aware: HTTP/2 requests use h2c for HTTP parent hops or ALPN `h2` for HTTPS parent hops; HTTP/1.1 (including WebSocket) stays HTTP/1.1.
- The forwarded request keeps the original target URI and `Host` header, so the outer proxy still evaluates policy against the real target.
-- The recommended egress target is the outer proxy's HTTP listener (`proxy.http_port`), not the transparent HTTPS listener.
+- The recommended egress target is the outer proxy's `explicit_http` or `explicit_https` listener, not the transparent HTTPS listener.
**Loop prevention**: If both hops use loop protection, either configure different `loop_protection.header_name` values per hop, or disable outbound injection on the inner proxy with `loop_protection.add_header = false`.
@@ -562,8 +579,7 @@ After parsing the config file, these overrides are applied:
| Variable | Config Field | Default |
|----------|-------------|---------|
-| `PROXY_PORT` | `proxy.http_port` (valid `u16`) | `8881` |
-| `PROXY_HOST` | `proxy.bind_address` | `0.0.0.0` |
+| `PROXY_EXPLICIT_HTTP_BIND` | `proxy.listeners.explicit_http.bind` | `0.0.0.0:8881` |
| `LOG_LEVEL` | `logging.level` | `info` |
### Top-Level Sections
@@ -585,36 +601,66 @@ schema_version = "1" # required; only "1" is supported
All sections except `schema_version` and `[policy].default` are optional with sensible defaults. Unknown keys in structured config sections, rules, ruleset templates, and profiles are rejected so typos such as `method` instead of `methods` do not silently weaken policy.
-### `[proxy]` — Listeners and Ports
+### `[proxy]` — Listeners
```toml
[proxy]
-bind_address = "0.0.0.0" # IP for the HTTP listener
-http_port = 8881 # port for HTTP listener (0 = ephemeral)
-https_bind_address = "0.0.0.0" # IP for the transparent HTTPS listener
-https_port = 8889 # port for HTTPS listener (0 = disabled)
request_timeout_ms = 30000 # upstream timeout; 0 = disabled
-https_handshake_timeout_ms = 10000 # transparent HTTPS TLS handshake timeout; 0 = disabled
-https_request_header_timeout_ms = 10000 # transparent HTTPS HTTP/1 header timeout and HTTP/2 keepalive interval/timeout; 0 = disabled
-https_max_connections = 1024 # active transparent HTTPS connections; 0 = unlimited
-https_http2_max_concurrent_streams = 128 # per-connection HTTP/2 stream cap; 0 = unlimited
+shutdown_drain_timeout_ms = 0 # graceful shutdown drain; 0 = wait indefinitely
internal_base_path = "/_acl-proxy" # base path for internal endpoints
+
+[proxy.listeners.explicit_http]
+bind = "0.0.0.0:8881"
+enable_http2 = true
+
+[proxy.listeners.transparent_http]
+bind = "0.0.0.0:8882"
+enable_http2 = true
+
+[proxy.listeners.explicit_https]
+bind = "0.0.0.0:8890"
+cert_chain_path = "/etc/acl-proxy/proxy.crt"
+key_path = "/etc/acl-proxy/proxy.key"
+handshake_timeout_ms = 10000
+request_header_timeout_ms = 10000
+max_connections = 1024
+enable_http2 = false
+http2_max_concurrent_streams = 128
+
+[proxy.listeners.transparent_https]
+bind = "0.0.0.0:8889"
+handshake_timeout_ms = 10000
+request_header_timeout_ms = 10000
+max_connections = 1024
+http2_max_concurrent_streams = 128
```
- `internal_base_path` must start with `/` and must not end with `/` (except root `/`). Internal endpoints are only matched for origin-form (direct) requests, not proxy-style absolute-form requests.
-- Listener bind addresses must be IP literals. If both HTTP and transparent HTTPS listeners use fixed non-zero ports, overlapping bind addresses cannot use the same port.
-- The default bind addresses listen on all interfaces. Set `bind_address` and `https_bind_address` to loopback addresses, or restrict access with firewall rules, when the proxy should be local-only.
-- The transparent HTTPS timeout and connection-cap settings apply at the listener boundary before request policy is evaluated.
+- `shutdown_drain_timeout_ms` controls how long shutdown waits for active connection tasks after listeners stop accepting. The default `0` waits indefinitely for in-flight requests to finish; a positive value aborts remaining connection tasks after that many milliseconds.
+- Listener `bind` values must be socket addresses with IP literal hosts and nonzero ports. Overlapping bind addresses cannot use the same port.
+- Missing listener sections are disabled, and at least one listener must be configured.
+- `explicit_http` and `transparent_http` are separate sockets. Explicit listeners reject non-internal origin-form requests; transparent HTTP rejects explicit absolute-form requests.
+- The default sample bind addresses listen on all interfaces. Use loopback binds or firewall rules when the proxy should be local-only.
+- Listener timeout and connection-cap settings apply at the listener boundary before request policy is evaluated.
### `[proxy.egress.default]` — Optional Forwarding Destination
```toml
[proxy.egress.default]
-host = "172.17.0.1" # DNS hostname or IP; IPv6 bare (::1) or bracketed ([::1])
-port = 8889 # TCP port (1–65535)
+type = "explicit_proxy"
+transport = "https"
+host = "work-proxy.internal" # DNS hostname or IP; IPv6 bare (::1) or bracketed ([::1])
+port = 8890 # TCP port (1–65535)
+
+[proxy.egress.default.tls]
+trust = "system_plus_custom_ca" # system | custom_ca | system_plus_custom_ca | disabled
+server_name = "work-proxy.internal" # optional SNI/cert identity override
+ca_cert_path = "/etc/acl-proxy/work-proxy-ca.pem"
```
-When present, allowed request-forwarding paths use this egress destination as the outbound TCP dial target while policy matching, logging, and the forwarded `Host` header remain bound to the original request target. When absent, the proxy connects directly to each request's original target.
+When present, allowed request-forwarding paths use this parent explicit proxy while policy matching, logging, and the forwarded `Host` header remain bound to the original request target. When absent, the proxy connects directly to each request's original target.
+
+Use `transport = "https"` to TLS-wrap the parent-proxy hop. Parent TLS trust is independent from direct-origin `[tls]` settings and from the MITM CA. For private parent certificates, set `trust = "custom_ca"` or `system_plus_custom_ca` with `ca_cert_path`; use `server_name` when the TCP dial host differs from the certificate identity. `trust = "disabled"` disables parent certificate and hostname verification and should only be used in controlled deployments. For cleartext parent-proxy forwarding, set `transport = "http"` and omit `[proxy.egress.default.tls]`.
### `[logging]` — Logging
@@ -748,7 +794,7 @@ enable_http2_upstream = false # HTTP/1.1-only by default
- `verify_upstream = false` accepts all upstream certificates regardless of host or issuer. Use only in controlled test environments.
- `enable_http2_upstream = true` lets ALPN negotiation choose HTTP/2 per origin; the proxy falls back to HTTP/1.1 when the origin doesn't advertise `h2`.
-These settings affect only outbound TLS from proxy to upstream. Incoming TLS is always terminated using the proxy's CA.
+These settings affect only outbound TLS from proxy to upstream origins. They do not affect explicit HTTPS listener certificates or parent-proxy TLS verification. CONNECT MITM and transparent HTTPS use the proxy CA; explicit HTTPS proxying uses the static certificate configured under `proxy.listeners.explicit_https`.
### `[external_auth]` — External Auth Settings
@@ -872,17 +918,17 @@ env = { KEY = "value" } # env vars for the plugin process
schema_version = "1"
[proxy]
-bind_address = "0.0.0.0"
-http_port = 8881
-https_bind_address = "0.0.0.0"
-https_port = 8889
+listeners.explicit_http.bind = "0.0.0.0:8881"
request_timeout_ms = 30000
-https_handshake_timeout_ms = 10000
-https_request_header_timeout_ms = 10000
-https_max_connections = 1024
-https_http2_max_concurrent_streams = 128
internal_base_path = "/_acl-proxy"
+[proxy.listeners.transparent_https]
+bind = "0.0.0.0:8889"
+handshake_timeout_ms = 10000
+request_header_timeout_ms = 10000
+max_connections = 1024
+http2_max_concurrent_streams = 128
+
[logging]
level = "info"
@@ -1050,11 +1096,11 @@ kill -HUP $(pidof acl-proxy)
- Pending external-auth approvals survive reload; callbacks after reload can still resolve requests that became pending before reload.
- If reload fails, the previous state remains active.
- `${NAME}` env placeholders are resolved again during reload. Changing a required env var takes effect on the next successful reload; removing one causes the reload to fail.
-- Listener bind addresses/ports, transparent HTTPS connection caps (`https_max_connections`, `https_http2_max_concurrent_streams`), and the enabled/disabled listener set are fixed at startup; changing those fields requires restart. Base logging sink settings (`logging.level`, `directory`, `max_bytes`, `max_files`, `console`) are also fixed at startup, while policy-decision logging flags and levels are read from the reloaded config.
+- Listener topology and listener fields under `proxy.listeners` are fixed at startup; changing enabled listeners, bind addresses, HTTP/2 settings, timeouts, connection caps, stream caps, or explicit HTTPS cert/key paths requires restart. Base logging sink settings (`logging.level`, `directory`, `max_bytes`, `max_files`, `console`) are also fixed at startup, while policy-decision logging flags and levels are read from the reloaded config.
### Graceful Shutdown
-`SIGTERM` or `Ctrl+C` stops accepting new connections. In-flight requests continue using existing state until they finish.
+`SIGTERM` or `Ctrl+C` stops accepting new connections. Idle keep-alive connections are closed gracefully, and in-flight requests continue using existing state until they finish. Set `proxy.shutdown_drain_timeout_ms` to a positive value to bound this drain; when that timeout expires, remaining connection tasks are aborted. The default `0` waits indefinitely.
### Loop Protection
@@ -1070,26 +1116,27 @@ Capture logging for loop-detected requests follows the `[capture]` flags for den
- Rules can override with `request_timeout_ms`.
- `0` disables the timeout.
- When the timeout expires, the proxy responds with `504 Gateway Timeout`.
-- For the transparent HTTPS listener, `proxy.https_handshake_timeout_ms` bounds idle TLS handshakes, `proxy.https_request_header_timeout_ms` bounds HTTP/1 header reads after TLS and HTTP/2 idle/half-open connections via keepalive pings, `proxy.https_max_connections` caps active accepted TLS connections, and `proxy.https_http2_max_concurrent_streams` caps active HTTP/2 streams per connection. Set any of these to `0` to disable that limit.
+- For TLS listeners, `handshake_timeout_ms` bounds idle TLS handshakes, `request_header_timeout_ms` bounds HTTP/1 header reads after TLS and HTTP/2 idle/half-open connections via keepalive pings, `max_connections` caps active accepted TLS connections, and `http2_max_concurrent_streams` caps active HTTP/2 streams per connection. Set any of these to `0` to disable that limit.
### Egress Forwarding
```toml
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = "172.17.0.1"
-port = 8889
+port = 8881
```
Operational details:
-- The inter-proxy forwarding leg remains cleartext TCP — deploy on trusted/local network.
-- Request bodies and sensitive headers injected by inner-proxy header actions travel in cleartext on that hop.
-- HTTP/2 requests do not silently downgrade; if h2c cannot be established, the request fails.
-- Exempt the outer proxy's listener from any iptables redirect rules to avoid loop-back.
+- `transport = "http"` uses cleartext TCP to the parent explicit proxy; `transport = "https"` uses TLS to the parent explicit proxy.
+- HTTP/2 requests do not silently downgrade; h2c is required for HTTP parent hops and ALPN `h2` is required for HTTPS parent hops.
+- Exempt the outer proxy's explicit listener from any iptables redirect rules to avoid loop-back.
- A valid config reload atomically swaps in the updated egress destination.
## TLS & Certificates
-acl-proxy acts as a TLS man-in-the-middle for CONNECT and transparent HTTPS modes, issuing per-host certificates signed by a local CA.
+acl-proxy uses separate TLS identities for separate trust domains. CONNECT MITM and transparent HTTPS issue per-host certificates signed by the configured local CA. The explicit HTTPS proxy listener uses the static `proxy.listeners.explicit_https.cert_chain_path` and `key_path` certificate instead; that certificate represents the proxy service itself.
```mermaid
graph LR
@@ -1200,7 +1247,7 @@ Decodes the base64 body payload from a capture JSON file. Reports errors for inv
| Status | Cause | Fix |
|--------|-------|-----|
-| **400 Bad Request** | Origin-form request without valid `Host` header; invalid request-target; transparent HTTPS missing `Host`; CONNECT missing `host:port` | Check client request format and headers |
+| **400 Bad Request** | Explicit listener received non-internal origin-form; transparent HTTP received absolute-form; origin-form request lacks valid `Host`; transparent HTTPS missing `Host`; CONNECT missing `host:port` | Check client request format, listener selection, and headers |
| **403 Forbidden** | Policy denied the request; external auth returned deny | Check `policy.rules` ordering and patterns |
| **502 Bad Gateway** | Proxy could not connect to upstream; upstream closed connection early | Confirm upstream reachability and DNS resolution |
| **503 Service Unavailable** | External auth webhook failed (`on_webhook_failure = "error"`); internal auth error | Check external auth service availability |
@@ -1210,8 +1257,9 @@ Decodes the base64 body payload from a capture JSON file. Reports errors for inv
### TLS Errors
- Clients must trust the proxy CA for CONNECT and transparent HTTPS modes.
+- Clients using `proxy.listeners.explicit_https` must trust the static proxy server certificate chain configured for that listener.
- Use `--proxy-cacert certs/ca-cert.pem` (CONNECT) or `--cacert certs/ca-cert.pem` (transparent HTTPS).
-- If upstream TLS verification fails, check `tls.verify_upstream` and confirm upstream certificates are valid.
+- If direct-origin upstream TLS verification fails, check `tls.verify_upstream` and confirm upstream certificates are valid. If HTTPS parent egress fails, check `proxy.egress.default.tls.trust`, `server_name`, and `ca_cert_path`.
### Missing Configuration File
@@ -1308,12 +1356,14 @@ script. Then add a fresh `## [Unreleased]` section with the standard
`Prepare for next release`, and push `main`.
Release binaries are packaged separately after the target-platform binaries
-have been built by the release operator. Build Linux x86_64 on Linux, and build
-macOS ARM64 natively on Apple Silicon. Supported release archives currently use
-these names:
+have been built by the release operator. Build platform binaries using the
+repository build flow, native platform builds, or environment-specific
+supplemental build instructions when available. Supported release archives
+currently use these names:
```text
acl-proxy-VERSION-linux-x86_64.tar.gz
+acl-proxy-VERSION-linux-arm64.tar.gz
acl-proxy-VERSION-macos-arm64.tar.gz
```
@@ -1331,7 +1381,7 @@ Example packaging flow:
```bash
VERSION=$(sed -n '/^\[package\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' Cargo.toml | head -n 1)
-PLATFORM=linux-x86_64 # or macos-arm64
+PLATFORM=linux-x86_64 # or linux-arm64 or macos-arm64
OUT=/tmp/acl-proxy-release-${VERSION}
ROOT="acl-proxy-${VERSION}-${PLATFORM}"
diff --git a/acl-proxy.sample.toml b/acl-proxy.sample.toml
index a581482..cc4d2c9 100644
--- a/acl-proxy.sample.toml
+++ b/acl-proxy.sample.toml
@@ -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"
@@ -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
diff --git a/src/app.rs b/src/app.rs
index 4027609..1041b4a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -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};
@@ -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;
@@ -37,6 +41,23 @@ pub enum AppStateError {
/// using the previous snapshot.
pub type SharedAppState = Arc>;
+#[derive(Clone)]
+pub struct ParentEgressTls {
+ pub server_name: String,
+ http1_config: Arc,
+ h2_config: Arc,
+}
+
+impl ParentEgressTls {
+ pub fn client_config(&self, use_http2: bool) -> Arc {
+ if use_http2 {
+ self.h2_config.clone()
+ } else {
+ self.http1_config.clone()
+ }
+ }
+}
+
#[derive(Clone)]
pub struct AppState {
pub config: Config,
@@ -45,6 +66,7 @@ pub struct AppState {
pub egress_request_header_actions: Vec,
pub loop_protection: LoopProtectionSettings,
pub http_client: Client>,
+ pub parent_egress_tls: Option,
pub cert_manager: CertManager,
pub external_auth: ExternalAuthManager,
pub auth_plugins: AuthPluginManager,
@@ -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(
@@ -91,6 +114,7 @@ impl AppState {
egress_request_header_actions,
loop_protection,
http_client,
+ parent_egress_tls,
cert_manager,
external_auth,
auth_plugins,
@@ -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, 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 {
+ 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::()
+ .is_err();
+
+ Ok(config)
+}
+
+fn root_store_with_native_roots() -> Result {
+ 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 {
+ 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> {
diff --git a/src/capture/mod.rs b/src/capture/mod.rs
index 328de41..a1a98f1 100644
--- a/src/capture/mod.rs
+++ b/src/capture/mod.rs
@@ -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"
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 9fae734..52b9d2a 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -1,13 +1,13 @@
use std::io::IsTerminal;
-use std::net::IpAddr;
+use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
-use std::sync::Arc;
use clap::{Parser, Subcommand, ValueEnum};
use crate::app::{AppState, SharedAppState};
use crate::config::{Config, ConfigError, ConfigPathKind};
+use crate::proxy::explicit_https::ExplicitHttpsError;
use crate::proxy::http::HttpProxyError;
use crate::proxy::https_transparent::HttpsTransparentError;
@@ -28,6 +28,9 @@ pub enum CliError {
#[error(transparent)]
Proxy(#[from] HttpProxyError),
+ #[error(transparent)]
+ ExplicitHttps(#[from] ExplicitHttpsError),
+
#[error(transparent)]
HttpsTransparent(#[from] HttpsTransparentError),
}
@@ -288,61 +291,123 @@ async fn run_proxy_with_signals(
state: SharedAppState,
config_path: Option<&Path>,
) -> Result<(), CliError> {
- use tokio::sync::Notify;
+ use tokio::sync::watch;
+ use tokio::task::JoinSet;
- let shutdown = Arc::new(Notify::new());
- let shutdown_http = shutdown.clone();
- let shutdown_https = shutdown.clone();
-
- let initial = state.load();
- let https_port = initial.config.proxy.https_port;
+ let (shutdown_tx, shutdown_rx) = watch::channel(false);
// Spawn signal handlers: SIGHUP for reload (on Unix) and
// Ctrl+C/SIGTERM for graceful shutdown.
- spawn_signal_handlers(state.clone(), config_path, shutdown.clone());
+ spawn_signal_handlers(state.clone(), config_path, shutdown_tx.clone());
+
+ let listeners = state.load().config.proxy.listeners.clone();
+ let mut tasks = JoinSet::new();
+
+ if listeners.explicit_http.is_some() {
+ let state = state.clone();
+ let shutdown = shutdown_rx.clone();
+ tasks.spawn(async move {
+ crate::proxy::http::run_explicit_http_proxy(state, wait_for_shutdown(shutdown))
+ .await
+ .map_err(CliError::Proxy)
+ });
+ }
- let http_fut = crate::proxy::http::run_http_proxy(state.clone(), async move {
- shutdown_http.notified().await;
- });
+ if listeners.transparent_http.is_some() {
+ let state = state.clone();
+ let shutdown = shutdown_rx.clone();
+ tasks.spawn(async move {
+ crate::proxy::http::run_transparent_http_proxy(state, wait_for_shutdown(shutdown))
+ .await
+ .map_err(CliError::Proxy)
+ });
+ }
- if https_port == 0 {
- http_fut.await.map_err(CliError::Proxy)?;
- tracing::info!("HTTP proxy listener shut down gracefully");
- Ok(())
- } else {
- let https_fut = crate::proxy::https_transparent::run_https_transparent_proxy(
- state.clone(),
- async move {
- shutdown_https.notified().await;
- },
- );
+ if listeners.explicit_https.is_some() {
+ let state = state.clone();
+ let shutdown = shutdown_rx.clone();
+ tasks.spawn(async move {
+ crate::proxy::explicit_https::run_explicit_https_proxy(
+ state,
+ wait_for_shutdown(shutdown),
+ )
+ .await
+ .map_err(CliError::ExplicitHttps)
+ });
+ }
+
+ if listeners.transparent_https.is_some() {
+ let state = state.clone();
+ let shutdown = shutdown_rx.clone();
+ tasks.spawn(async move {
+ crate::proxy::https_transparent::run_https_transparent_proxy(
+ state,
+ wait_for_shutdown(shutdown),
+ )
+ .await
+ .map_err(CliError::HttpsTransparent)
+ });
+ }
+
+ if tasks.is_empty() {
+ return Err(CliError::Config(ConfigError::Invalid(
+ "proxy.listeners must enable at least one listener".to_string(),
+ )));
+ }
- tokio::select! {
- res = http_fut => {
- res.map_err(CliError::Proxy)?;
- tracing::info!("HTTP proxy listener shut down gracefully");
- Ok(())
+ let mut first_error = None;
+ while let Some(result) = tasks.join_next().await {
+ match result {
+ Ok(Ok(())) => {}
+ Ok(Err(err)) => {
+ signal_shutdown(&shutdown_tx);
+ if first_error.is_none() {
+ first_error = Some(err);
+ }
}
- res = https_fut => {
- res.map_err(CliError::HttpsTransparent)?;
- tracing::info!("HTTPS transparent listener shut down gracefully");
- Ok(())
+ Err(err) => {
+ signal_shutdown(&shutdown_tx);
+ if first_error.is_none() {
+ first_error = Some(CliError::Runtime(format!(
+ "proxy listener task failed: {err}"
+ )));
+ }
}
}
}
+
+ match first_error {
+ Some(err) => Err(err),
+ None => Ok(()),
+ }
+}
+
+async fn wait_for_shutdown(mut shutdown: tokio::sync::watch::Receiver) {
+ if *shutdown.borrow() {
+ return;
+ }
+ while shutdown.changed().await.is_ok() {
+ if *shutdown.borrow() {
+ return;
+ }
+ }
+}
+
+fn signal_shutdown(shutdown: &tokio::sync::watch::Sender) {
+ let _ = shutdown.send(true);
}
fn spawn_signal_handlers(
state: SharedAppState,
config_path: Option<&Path>,
- shutdown: Arc,
+ shutdown: tokio::sync::watch::Sender,
) {
// Graceful shutdown on Ctrl+C (cross-platform).
let shutdown_clone = shutdown.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
tracing::info!("received interrupt (Ctrl+C); initiating graceful shutdown");
- shutdown_clone.notify_waiters();
+ signal_shutdown(&shutdown_clone);
}
});
@@ -397,7 +462,7 @@ fn spawn_signal_handlers(
if term_stream.recv().await.is_some() {
tracing::info!("received SIGTERM; initiating graceful shutdown");
- shutdown_clone.notify_waiters();
+ signal_shutdown(&shutdown_clone);
}
});
}
@@ -406,6 +471,13 @@ fn spawn_signal_handlers(
fn reload_from_sources(state: &SharedAppState, config_path: Option<&Path>) -> Result<(), String> {
let config = Config::load_from_sources(config_path).map_err(|e| e.to_string())?;
let load_warnings = config.load_warnings.clone();
+ let active_listeners = state.load().config.proxy.listeners.clone();
+ if active_listeners != config.proxy.listeners {
+ return Err(
+ "listener configuration changed; restart required for proxy.listeners changes"
+ .to_string(),
+ );
+ }
AppState::reload_shared_from_config(state, config).map_err(|e| e.to_string())?;
log_config_load_warning_messages(&load_warnings);
Ok(())
@@ -430,12 +502,56 @@ fn print_config_load_warnings(config: &Config) {
fn log_startup(state: &AppState) {
let cfg = &state.config;
- let http_bind = format!("{}:{}", cfg.proxy.bind_address, cfg.proxy.http_port);
- let https_bind = if cfg.proxy.https_port == 0 {
- "disabled".to_string()
- } else {
- format!("{}:{}", cfg.proxy.https_bind_address, cfg.proxy.https_port)
- };
+ let explicit_http_bind = cfg
+ .proxy
+ .listeners
+ .explicit_http
+ .as_ref()
+ .map(|listener| listener.bind.as_str())
+ .unwrap_or("disabled");
+ let transparent_http_bind = cfg
+ .proxy
+ .listeners
+ .transparent_http
+ .as_ref()
+ .map(|listener| listener.bind.as_str())
+ .unwrap_or("disabled");
+ let explicit_https_bind = cfg
+ .proxy
+ .listeners
+ .explicit_https
+ .as_ref()
+ .map(|listener| listener.bind.as_str())
+ .unwrap_or("disabled");
+ let transparent_https_bind = cfg
+ .proxy
+ .listeners
+ .transparent_https
+ .as_ref()
+ .map(|listener| listener.bind.as_str())
+ .unwrap_or("disabled");
+ let explicit_http_http2_enabled = cfg
+ .proxy
+ .listeners
+ .explicit_http
+ .as_ref()
+ .map(|listener| listener.enable_http2)
+ .unwrap_or(false);
+ let transparent_http_http2_enabled = cfg
+ .proxy
+ .listeners
+ .transparent_http
+ .as_ref()
+ .map(|listener| listener.enable_http2)
+ .unwrap_or(false);
+ let explicit_https_http2_enabled = cfg
+ .proxy
+ .listeners
+ .explicit_https
+ .as_ref()
+ .map(|listener| listener.enable_http2)
+ .unwrap_or(false);
+ let transparent_https_http2_enabled = cfg.proxy.listeners.transparent_https.is_some();
let certs = &cfg.certificates;
let has_explicit_ca = certs
@@ -454,19 +570,21 @@ fn log_startup(state: &AppState) {
let loop_cfg = &cfg.loop_protection;
let capture_cfg = &cfg.capture;
- warn_on_unspecified_bind("http", &cfg.proxy.bind_address, cfg.proxy.http_port);
- if cfg.proxy.https_port != 0 {
- warn_on_unspecified_bind(
- "transparent_https",
- &cfg.proxy.https_bind_address,
- cfg.proxy.https_port,
- );
+ for (name, bind) in [
+ ("explicit_http", explicit_http_bind),
+ ("transparent_http", transparent_http_bind),
+ ("explicit_https", explicit_https_bind),
+ ("transparent_https", transparent_https_bind),
+ ] {
+ warn_on_unspecified_bind(name, bind);
}
tracing::info!(
target: "acl_proxy::startup",
- http_bind = %http_bind,
- https_bind = %https_bind,
+ explicit_http_bind = %explicit_http_bind,
+ transparent_http_bind = %transparent_http_bind,
+ explicit_https_bind = %explicit_https_bind,
+ transparent_https_bind = %transparent_https_bind,
loop_protection_enabled = loop_cfg.enabled,
loop_protection_add_header = loop_cfg.add_header,
loop_protection_header = %loop_cfg.header_name,
@@ -475,24 +593,27 @@ fn log_startup(state: &AppState) {
capture_denied_request = capture_cfg.denied_request,
capture_denied_response = capture_cfg.denied_response,
capture_directory = %capture_cfg.directory,
- http2_inbound_enabled = true,
+ explicit_http_http2_enabled,
+ transparent_http_http2_enabled,
+ explicit_https_http2_enabled,
+ transparent_https_http2_enabled,
ca_mode = %if has_explicit_ca { "configured" } else { "generated" },
ca_certs_dir = %certs.certs_dir,
"acl-proxy starting"
);
}
-fn warn_on_unspecified_bind(listener: &str, bind_address: &str, port: u16) {
- let Ok(ip) = bind_address.parse::() else {
+fn warn_on_unspecified_bind(listener: &str, bind: &str) {
+ let Ok(addr) = bind.parse::() else {
return;
};
- if ip.is_unspecified() {
+ if addr.ip().is_unspecified() {
tracing::warn!(
target: "acl_proxy::startup",
listener,
- bind_address,
- port,
+ bind_address = %addr.ip(),
+ port = addr.port(),
"proxy listener is bound to all interfaces; restrict bind_address or firewall access when exposure is not intended"
);
}
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 9249af7..d88c7b2 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -1,7 +1,9 @@
use std::env;
use std::fs;
-use std::net::IpAddr;
+use std::io::BufReader;
+use std::net::{IpAddr, SocketAddr};
use std::path::{Component, Path, PathBuf};
+use std::time::Duration;
use http::header::{HeaderName, HeaderValue};
use ipnet::IpNet;
@@ -114,36 +116,18 @@ fn default_schema_version() -> String {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxyConfig {
- #[serde(default = "default_bind_address")]
- pub bind_address: String,
-
- #[serde(default = "default_http_port")]
- pub http_port: u16,
-
- #[serde(default = "default_https_bind_address")]
- pub https_bind_address: String,
-
- #[serde(default = "default_https_port")]
- pub https_port: u16,
-
#[serde(default = "default_request_timeout_ms")]
pub request_timeout_ms: u64,
- #[serde(default = "default_https_handshake_timeout_ms")]
- pub https_handshake_timeout_ms: u64,
-
- #[serde(default = "default_https_request_header_timeout_ms")]
- pub https_request_header_timeout_ms: u64,
-
- #[serde(default = "default_https_max_connections")]
- pub https_max_connections: usize,
-
- #[serde(default = "default_https_http2_max_concurrent_streams")]
- pub https_http2_max_concurrent_streams: u32,
+ #[serde(default)]
+ pub shutdown_drain_timeout_ms: u64,
#[serde(default = "default_internal_base_path")]
pub internal_base_path: String,
+ #[serde(default)]
+ pub listeners: ProxyListenersConfig,
+
#[serde(default)]
pub egress: ProxyEgressConfig,
}
@@ -151,54 +135,42 @@ pub struct ProxyConfig {
impl Default for ProxyConfig {
fn default() -> Self {
Self {
- bind_address: default_bind_address(),
- http_port: default_http_port(),
- https_bind_address: default_https_bind_address(),
- https_port: default_https_port(),
request_timeout_ms: default_request_timeout_ms(),
- https_handshake_timeout_ms: default_https_handshake_timeout_ms(),
- https_request_header_timeout_ms: default_https_request_header_timeout_ms(),
- https_max_connections: default_https_max_connections(),
- https_http2_max_concurrent_streams: default_https_http2_max_concurrent_streams(),
+ shutdown_drain_timeout_ms: 0,
internal_base_path: default_internal_base_path(),
+ listeners: ProxyListenersConfig::default(),
egress: ProxyEgressConfig::default(),
}
}
}
-fn default_bind_address() -> String {
- "0.0.0.0".to_string()
-}
-
-fn default_http_port() -> u16 {
- 8881
-}
-
-fn default_https_bind_address() -> String {
- "0.0.0.0".to_string()
-}
-
-fn default_https_port() -> u16 {
- 8889
+impl ProxyConfig {
+ pub fn shutdown_drain_timeout(&self) -> Option {
+ if self.shutdown_drain_timeout_ms == 0 {
+ None
+ } else {
+ Some(Duration::from_millis(self.shutdown_drain_timeout_ms))
+ }
+ }
}
fn default_request_timeout_ms() -> u64 {
30_000
}
-fn default_https_handshake_timeout_ms() -> u64 {
+fn default_tls_handshake_timeout_ms() -> u64 {
10_000
}
-fn default_https_request_header_timeout_ms() -> u64 {
+fn default_tls_request_header_timeout_ms() -> u64 {
10_000
}
-fn default_https_max_connections() -> usize {
+fn default_tls_max_connections() -> usize {
1024
}
-fn default_https_http2_max_concurrent_streams() -> u32 {
+fn default_tls_http2_max_concurrent_streams() -> u32 {
128
}
@@ -206,6 +178,93 @@ fn default_internal_base_path() -> String {
"/_acl-proxy".to_string()
}
+#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ProxyListenersConfig {
+ #[serde(default)]
+ pub explicit_http: Option,
+
+ #[serde(default)]
+ pub transparent_http: Option,
+
+ #[serde(default)]
+ pub explicit_https: Option,
+
+ #[serde(default)]
+ pub transparent_https: Option,
+}
+
+impl ProxyListenersConfig {
+ pub fn enabled_count(&self) -> usize {
+ self.explicit_http.iter().count()
+ + self.transparent_http.iter().count()
+ + self.explicit_https.iter().count()
+ + self.transparent_https.iter().count()
+ }
+
+ pub fn has_connect_capable_listener(&self) -> bool {
+ self.explicit_http.is_some() || self.explicit_https.is_some()
+ }
+
+ pub fn uses_mitm_certificates(&self) -> bool {
+ self.has_connect_capable_listener() || self.transparent_https.is_some()
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct CleartextHttpListenerConfig {
+ pub bind: String,
+
+ #[serde(default = "default_cleartext_enable_http2")]
+ pub enable_http2: bool,
+}
+
+fn default_cleartext_enable_http2() -> bool {
+ true
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ExplicitHttpsListenerConfig {
+ pub bind: String,
+ pub cert_chain_path: PathBuf,
+ pub key_path: PathBuf,
+
+ #[serde(default = "default_tls_handshake_timeout_ms")]
+ pub handshake_timeout_ms: u64,
+
+ #[serde(default = "default_tls_request_header_timeout_ms")]
+ pub request_header_timeout_ms: u64,
+
+ #[serde(default = "default_tls_max_connections")]
+ pub max_connections: usize,
+
+ #[serde(default)]
+ pub enable_http2: bool,
+
+ #[serde(default = "default_tls_http2_max_concurrent_streams")]
+ pub http2_max_concurrent_streams: u32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct TransparentHttpsListenerConfig {
+ pub bind: String,
+
+ #[serde(default = "default_tls_handshake_timeout_ms")]
+ pub handshake_timeout_ms: u64,
+
+ #[serde(default = "default_tls_request_header_timeout_ms")]
+ pub request_header_timeout_ms: u64,
+
+ #[serde(default = "default_tls_max_connections")]
+ pub max_connections: usize,
+
+ #[serde(default = "default_tls_http2_max_concurrent_streams")]
+ pub http2_max_concurrent_streams: u32,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ProxyEgressConfig {
@@ -219,8 +278,81 @@ pub struct ProxyEgressConfig {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct EgressTargetConfig {
+ #[serde(rename = "type")]
+ pub target_type: EgressTargetType,
+ pub transport: EgressTransport,
pub host: String,
pub port: u16,
+
+ #[serde(default)]
+ pub tls: Option,
+}
+
+impl EgressTargetConfig {
+ pub fn explicit_http_proxy(host: String, port: u16) -> Self {
+ Self {
+ target_type: EgressTargetType::ExplicitProxy,
+ transport: EgressTransport::Http,
+ host,
+ port,
+ tls: None,
+ }
+ }
+
+ pub fn effective_tls_trust(&self) -> EgressTlsTrust {
+ let Some(tls) = self.tls.as_ref() else {
+ return EgressTlsTrust::System;
+ };
+ tls.trust.unwrap_or_else(|| {
+ if tls.ca_cert_path.is_some() {
+ EgressTlsTrust::SystemPlusCustomCa
+ } else {
+ EgressTlsTrust::System
+ }
+ })
+ }
+
+ pub fn tls_server_name(&self) -> &str {
+ self.tls
+ .as_ref()
+ .and_then(|tls| tls.server_name.as_deref())
+ .unwrap_or(&self.host)
+ }
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum EgressTargetType {
+ ExplicitProxy,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum EgressTransport {
+ Http,
+ Https,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct EgressTlsConfig {
+ #[serde(default)]
+ pub trust: Option,
+
+ #[serde(default)]
+ pub server_name: Option,
+
+ #[serde(default)]
+ pub ca_cert_path: Option,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum EgressTlsTrust {
+ System,
+ CustomCa,
+ SystemPlusCustomCa,
+ Disabled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1111,14 +1243,18 @@ impl Config {
}
fn apply_env_overrides(&mut self) {
- if let Ok(port) = env::var("PROXY_PORT") {
- if let Ok(port) = port.parse::() {
- self.proxy.http_port = port;
- }
- }
- if let Ok(host) = env::var("PROXY_HOST") {
- if !host.trim().is_empty() {
- self.proxy.bind_address = host;
+ if let Ok(bind) = env::var("PROXY_EXPLICIT_HTTP_BIND") {
+ let bind = bind.trim();
+ if !bind.is_empty() {
+ match self.proxy.listeners.explicit_http.as_mut() {
+ Some(listener) => listener.bind = bind.to_string(),
+ None => {
+ self.proxy.listeners.explicit_http = Some(CleartextHttpListenerConfig {
+ bind: bind.to_string(),
+ enable_http2: default_cleartext_enable_http2(),
+ });
+ }
+ }
}
}
if let Ok(level) = env::var("LOG_LEVEL") {
@@ -1206,33 +1342,7 @@ impl Config {
)));
}
}
- // Ensure certificate paths are configured consistently.
- let ca_key = self
- .certificates
- .ca_key_path
- .as_deref()
- .map(str::trim)
- .filter(|s| !s.is_empty());
- let ca_cert = self
- .certificates
- .ca_cert_path
- .as_deref()
- .map(str::trim)
- .filter(|s| !s.is_empty());
-
- match (ca_key, ca_cert) {
- (Some(_), Some(_)) | (None, None) => Ok(()),
- _ => Err(ConfigError::Invalid(
- "certificates.ca_key_path and certificates.ca_cert_path must both be set or both omitted"
- .to_string(),
- )),
- }?;
-
- if self.certificates.max_cached_certs == 0 {
- return Err(ConfigError::Invalid(
- "certificates.max_cached_certs must be at least 1".to_string(),
- ));
- }
+ validate_certificates_config(&self.certificates)?;
validate_proxy_config(&self.proxy)?;
validate_loop_protection_config(&self.loop_protection)?;
@@ -1456,17 +1566,20 @@ pub fn write_default_config(path: &Path) -> Result<(), ConfigError> {
let contents = r#"schema_version = "1"
[proxy]
-bind_address = "0.0.0.0"
-http_port = 8881
-https_bind_address = "0.0.0.0"
-https_port = 8889
request_timeout_ms = 30000
-https_handshake_timeout_ms = 10000
-https_request_header_timeout_ms = 10000
-https_max_connections = 1024
-https_http2_max_concurrent_streams = 128
internal_base_path = "/_acl-proxy"
+[proxy.listeners.explicit_http]
+bind = "0.0.0.0:8881"
+enable_http2 = true
+
+[proxy.listeners.transparent_https]
+bind = "0.0.0.0:8889"
+handshake_timeout_ms = 10000
+request_header_timeout_ms = 10000
+max_connections = 1024
+http2_max_concurrent_streams = 128
+
[logging]
level = "info"
@@ -1487,21 +1600,78 @@ fn validate_logging_config(logging: &LoggingConfig) -> Result<(), ConfigError> {
Ok(())
}
-fn validate_proxy_config(proxy: &ProxyConfig) -> Result<(), ConfigError> {
- let bind_ip = parse_bind_address("proxy.bind_address", &proxy.bind_address)?;
- let https_bind_ip = parse_bind_address("proxy.https_bind_address", &proxy.https_bind_address)?;
+fn validate_certificates_config(certificates: &CertificatesConfig) -> Result<(), ConfigError> {
+ let ca_key = certificates
+ .ca_key_path
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty());
+ let ca_cert = certificates
+ .ca_cert_path
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty());
+
+ match (ca_key, ca_cert) {
+ (Some(key_path), Some(cert_path)) => {
+ validate_mitm_ca_material(Path::new(key_path), Path::new(cert_path))?;
+ }
+ (None, None) => {}
+ _ => {
+ return Err(ConfigError::Invalid(
+ "certificates.ca_key_path and certificates.ca_cert_path must both be set or both omitted"
+ .to_string(),
+ ));
+ }
+ }
- if proxy.http_port != 0
- && proxy.https_port != 0
- && proxy.http_port == proxy.https_port
- && listener_bind_addresses_overlap(bind_ip, https_bind_ip)
- {
- return Err(ConfigError::Invalid(format!(
- "proxy.http_port and proxy.https_port must not both be {} when bind addresses overlap",
- proxy.http_port
- )));
+ if certificates.max_cached_certs == 0 {
+ return Err(ConfigError::Invalid(
+ "certificates.max_cached_certs must be at least 1".to_string(),
+ ));
}
+ Ok(())
+}
+
+fn validate_mitm_ca_material(ca_key_path: &Path, ca_cert_path: &Path) -> Result<(), ConfigError> {
+ let key_pem = fs::read_to_string(ca_key_path).map_err(|source| ConfigError::Io {
+ path: ca_key_path.to_path_buf(),
+ source,
+ })?;
+ rcgen::KeyPair::from_pem(&key_pem).map_err(|err| {
+ ConfigError::Invalid(format!(
+ "certificates.ca_key_path must contain a valid PEM private key: {err}"
+ ))
+ })?;
+
+ let cert_pem = fs::read_to_string(ca_cert_path).map_err(|source| ConfigError::Io {
+ path: ca_cert_path.to_path_buf(),
+ source,
+ })?;
+ let mut reader = BufReader::new(cert_pem.as_bytes());
+ let certs = rustls_pemfile::certs(&mut reader).map_err(|err| {
+ ConfigError::Invalid(format!(
+ "certificates.ca_cert_path must contain valid PEM certificates: {err}"
+ ))
+ })?;
+ let Some(cert_der) = certs.first() else {
+ return Err(ConfigError::Invalid(
+ "certificates.ca_cert_path must contain at least one PEM certificate".to_string(),
+ ));
+ };
+ rcgen::CertificateParams::from_ca_cert_der(&cert_der.as_slice().into()).map_err(|err| {
+ ConfigError::Invalid(format!(
+ "certificates.ca_cert_path must contain a valid CA certificate: {err}"
+ ))
+ })?;
+
+ Ok(())
+}
+
+fn validate_proxy_config(proxy: &ProxyConfig) -> Result<(), ConfigError> {
+ validate_listener_config(&proxy.listeners)?;
+
validate_internal_base_path(&proxy.internal_base_path)?;
if let Some(target) = proxy.egress.default.as_ref() {
@@ -1513,10 +1683,75 @@ fn validate_proxy_config(proxy: &ProxyConfig) -> Result<(), ConfigError> {
Ok(())
}
-fn parse_bind_address(location: &str, value: &str) -> Result {
- value.parse::().map_err(|err| {
- ConfigError::Invalid(format!("{location} must be a valid IP address: {err}"))
- })
+fn validate_listener_config(listeners: &ProxyListenersConfig) -> Result<(), ConfigError> {
+ if listeners.enabled_count() == 0 {
+ return Err(ConfigError::Invalid(
+ "proxy.listeners must enable at least one listener".to_string(),
+ ));
+ }
+
+ let mut binds: Vec<(&'static str, SocketAddr)> = Vec::new();
+ if let Some(listener) = listeners.explicit_http.as_ref() {
+ binds.push((
+ "proxy.listeners.explicit_http.bind",
+ parse_listener_bind("proxy.listeners.explicit_http.bind", &listener.bind)?,
+ ));
+ }
+ if let Some(listener) = listeners.transparent_http.as_ref() {
+ binds.push((
+ "proxy.listeners.transparent_http.bind",
+ parse_listener_bind("proxy.listeners.transparent_http.bind", &listener.bind)?,
+ ));
+ }
+ if let Some(listener) = listeners.explicit_https.as_ref() {
+ binds.push((
+ "proxy.listeners.explicit_https.bind",
+ parse_listener_bind("proxy.listeners.explicit_https.bind", &listener.bind)?,
+ ));
+ validate_static_tls_identity(
+ "proxy.listeners.explicit_https",
+ &listener.cert_chain_path,
+ &listener.key_path,
+ )?;
+ }
+ if let Some(listener) = listeners.transparent_https.as_ref() {
+ binds.push((
+ "proxy.listeners.transparent_https.bind",
+ parse_listener_bind("proxy.listeners.transparent_https.bind", &listener.bind)?,
+ ));
+ }
+
+ for left_idx in 0..binds.len() {
+ for right_idx in (left_idx + 1)..binds.len() {
+ let (left_location, left) = binds[left_idx];
+ let (right_location, right) = binds[right_idx];
+ if left.port() == right.port() && listener_bind_addresses_overlap(left.ip(), right.ip())
+ {
+ return Err(ConfigError::Invalid(format!(
+ "{left_location} and {right_location} must not both bind port {} when bind addresses overlap",
+ left.port()
+ )));
+ }
+ }
+ }
+
+ Ok(())
+}
+
+pub fn parse_listener_bind(location: &str, value: &str) -> Result {
+ let addr = value.parse::().map_err(|err| {
+ ConfigError::Invalid(format!(
+ "{location} must be a valid socket address in host:port form: {err}"
+ ))
+ })?;
+
+ if addr.port() == 0 {
+ return Err(ConfigError::Invalid(format!(
+ "{location} port must be in the inclusive range 1..65535"
+ )));
+ }
+
+ Ok(addr)
}
fn listener_bind_addresses_overlap(left: IpAddr, right: IpAddr) -> bool {
@@ -1527,7 +1762,7 @@ fn listener_bind_addresses_overlap(left: IpAddr, right: IpAddr) -> bool {
(IpAddr::V6(left), IpAddr::V6(right)) => {
left == right || left.is_unspecified() || right.is_unspecified()
}
- _ => false,
+ _ => left.is_unspecified() || right.is_unspecified(),
}
}
@@ -1559,6 +1794,21 @@ fn validate_egress_target(target: &EgressTargetConfig) -> Result<(), ConfigError
));
}
+ match target.target_type {
+ EgressTargetType::ExplicitProxy => {}
+ }
+
+ match target.transport {
+ EgressTransport::Http => {
+ if target.tls.is_some() {
+ return Err(ConfigError::Invalid(
+ "proxy.egress.default.tls is only valid when transport = \"https\"".to_string(),
+ ));
+ }
+ }
+ EgressTransport::Https => validate_egress_tls_config(target)?,
+ }
+
Ok(())
}
@@ -1612,6 +1862,189 @@ fn validate_egress_host(host: &str) -> Result<(), ConfigError> {
})
}
+fn validate_egress_tls_config(target: &EgressTargetConfig) -> Result<(), ConfigError> {
+ let Some(tls) = target.tls.as_ref() else {
+ return Ok(());
+ };
+
+ if let Some(server_name) = tls.server_name.as_deref() {
+ validate_name_without_port(
+ "proxy.egress.default.tls.server_name",
+ server_name,
+ "proxy.egress.default.tls.server_name must be a valid DNS hostname or IP literal without a port suffix",
+ )?;
+ }
+
+ let has_ca_cert_path = tls.ca_cert_path.is_some();
+ let effective_trust = target.effective_tls_trust();
+ match effective_trust {
+ EgressTlsTrust::CustomCa | EgressTlsTrust::SystemPlusCustomCa => {
+ let Some(path) = tls.ca_cert_path.as_ref() else {
+ return Err(ConfigError::Invalid(format!(
+ "proxy.egress.default.tls.ca_cert_path is required when trust = \"{}\"",
+ egress_tls_trust_value(effective_trust)
+ )));
+ };
+ validate_ca_cert_bundle("proxy.egress.default.tls.ca_cert_path", path)?;
+ }
+ EgressTlsTrust::System | EgressTlsTrust::Disabled => {
+ if has_ca_cert_path && tls.trust.is_some() {
+ return Err(ConfigError::Invalid(format!(
+ "proxy.egress.default.tls.ca_cert_path must not be set when trust = \"{}\"",
+ egress_tls_trust_value(effective_trust)
+ )));
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn egress_tls_trust_value(trust: EgressTlsTrust) -> &'static str {
+ match trust {
+ EgressTlsTrust::System => "system",
+ EgressTlsTrust::CustomCa => "custom_ca",
+ EgressTlsTrust::SystemPlusCustomCa => "system_plus_custom_ca",
+ EgressTlsTrust::Disabled => "disabled",
+ }
+}
+
+fn validate_name_without_port(
+ location: &str,
+ value: &str,
+ message: &'static str,
+) -> Result<(), ConfigError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(ConfigError::Invalid(format!(
+ "{location} must not be empty"
+ )));
+ }
+ if trimmed != value {
+ return Err(ConfigError::Invalid(format!(
+ "{location} must not include leading or trailing whitespace"
+ )));
+ }
+
+ if trimmed.starts_with('[') || trimmed.ends_with(']') {
+ let inner = trimmed
+ .strip_prefix('[')
+ .and_then(|value| value.strip_suffix(']'))
+ .ok_or_else(|| ConfigError::Invalid(message.to_string()))?;
+ return inner
+ .parse::()
+ .map(|_| ())
+ .map_err(|_| ConfigError::Invalid(message.to_string()));
+ }
+
+ if trimmed.parse::().is_ok() {
+ return Ok(());
+ }
+
+ if trimmed.contains(':') {
+ return Err(ConfigError::Invalid(format!(
+ "{location} must not include a port suffix"
+ )));
+ }
+
+ url::Host::parse(trimmed)
+ .map(|_| ())
+ .map_err(|_| ConfigError::Invalid(message.to_string()))
+}
+
+fn validate_ca_cert_bundle(location: &str, path: &Path) -> Result<(), ConfigError> {
+ let file = fs::File::open(path).map_err(|source| ConfigError::Io {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ let mut reader = BufReader::new(file);
+ let certs = rustls_pemfile::certs(&mut reader).map_err(|err| {
+ ConfigError::Invalid(format!(
+ "{location} must contain valid PEM certificates: {err}"
+ ))
+ })?;
+ if certs.is_empty() {
+ return Err(ConfigError::Invalid(format!(
+ "{location} must contain at least one PEM certificate"
+ )));
+ }
+ Ok(())
+}
+
+fn validate_static_tls_identity(
+ location: &str,
+ cert_chain_path: &Path,
+ key_path: &Path,
+) -> Result<(), ConfigError> {
+ let certs = load_pem_certs(
+ format!("{location}.cert_chain_path").as_str(),
+ cert_chain_path,
+ )?;
+ let key = load_private_key(format!("{location}.key_path").as_str(), key_path)?;
+
+ rustls::ServerConfig::builder()
+ .with_safe_defaults()
+ .with_no_client_auth()
+ .with_single_cert(certs, key)
+ .map_err(|err| {
+ ConfigError::Invalid(format!(
+ "{location} cert_chain_path and key_path must form a valid TLS identity: {err}"
+ ))
+ })?;
+
+ Ok(())
+}
+
+fn load_pem_certs(location: &str, path: &Path) -> Result, ConfigError> {
+ let file = fs::File::open(path).map_err(|source| ConfigError::Io {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ let mut reader = BufReader::new(file);
+ let certs = rustls_pemfile::certs(&mut reader).map_err(|err| {
+ ConfigError::Invalid(format!(
+ "{location} must contain valid PEM certificates: {err}"
+ ))
+ })?;
+
+ if certs.is_empty() {
+ return Err(ConfigError::Invalid(format!(
+ "{location} must contain at least one PEM certificate"
+ )));
+ }
+
+ Ok(certs.into_iter().map(rustls::Certificate).collect())
+}
+
+fn load_private_key(location: &str, path: &Path) -> Result {
+ for loader in [
+ rustls_pemfile::pkcs8_private_keys
+ as fn(&mut dyn std::io::BufRead) -> Result>, std::io::Error>,
+ rustls_pemfile::rsa_private_keys
+ as fn(&mut dyn std::io::BufRead) -> Result>, std::io::Error>,
+ rustls_pemfile::ec_private_keys
+ as fn(&mut dyn std::io::BufRead) -> Result>, std::io::Error>,
+ ] {
+ let file = fs::File::open(path).map_err(|source| ConfigError::Io {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ let mut reader = BufReader::new(file);
+ let mut keys = loader(&mut reader).map_err(|err| {
+ ConfigError::Invalid(format!(
+ "{location} must contain a valid PEM private key: {err}"
+ ))
+ })?;
+ if !keys.is_empty() {
+ return Ok(rustls::PrivateKey(keys.remove(0)));
+ }
+ }
+
+ Err(ConfigError::Invalid(format!(
+ "{location} must contain a supported PEM private key"
+ )))
+}
+
fn validate_egress_request_header_action_list(
actions: &[EgressRequestHeaderActionConfig],
) -> Result<(), ConfigError> {
@@ -2066,8 +2499,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"
@@ -2079,9 +2511,44 @@ default = "deny"
let config: Config = toml::from_str(toml).expect("parse minimal config");
assert_eq!(config.schema_version, "1");
- assert_eq!(config.proxy.http_port, 8080);
+ assert_eq!(
+ config
+ .proxy
+ .listeners
+ .explicit_http
+ .as_ref()
+ .map(|listener| listener.bind.as_str()),
+ Some("127.0.0.1:8080")
+ );
assert_eq!(config.logging.level, "debug");
assert!(matches!(config.policy.default, PolicyDefaultAction::Deny));
+ assert_eq!(config.proxy.shutdown_drain_timeout_ms, 0);
+ assert_eq!(config.proxy.shutdown_drain_timeout(), None);
+ }
+
+ #[test]
+ fn proxy_shutdown_drain_timeout_parses_milliseconds() {
+ let toml = r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+shutdown_drain_timeout_ms = 1500
+
+[logging]
+directory = "logs"
+level = "debug"
+
+[policy]
+default = "deny"
+ "#;
+
+ let config: Config = toml::from_str(toml).expect("parse config");
+ assert_eq!(config.proxy.shutdown_drain_timeout_ms, 1500);
+ assert_eq!(
+ config.proxy.shutdown_drain_timeout(),
+ Some(std::time::Duration::from_millis(1500))
+ );
}
#[test]
@@ -2090,7 +2557,7 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
+listeners.explicit_http.bind = "127.0.0.1:8080"
http_ports = 8080
[policy]
@@ -2110,6 +2577,9 @@ default = "deny"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -2132,6 +2602,9 @@ method = "GET"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -2158,8 +2631,7 @@ subnet = "10.0.0.0/8"
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"
@@ -2209,8 +2681,7 @@ redaction_profile = "secrets"
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"
@@ -2240,8 +2711,7 @@ redaction_profile = "missing"
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"
@@ -2277,8 +2747,7 @@ redaction_profile = "secrets"
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"
@@ -2308,8 +2777,7 @@ level = "debug"
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"
@@ -2340,8 +2808,7 @@ default = "deny"
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"
@@ -2378,8 +2845,7 @@ default = "deny"
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"
@@ -2424,8 +2890,7 @@ redaction_profile = "secrets"
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"
@@ -2459,8 +2924,7 @@ default = "deny"
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"
@@ -2508,8 +2972,7 @@ default = "deny"
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"
@@ -2548,8 +3011,7 @@ redaction_profile = "secrets"
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"
@@ -2590,8 +3052,7 @@ redaction_profile = "secrets"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
internal_base_path = "/internal"
[logging]
@@ -2614,32 +3075,52 @@ default = "deny"
#[test]
fn proxy_transparent_https_slow_client_limits_default_and_parse() {
- let default = ProxyConfig::default();
- assert_eq!(default.https_handshake_timeout_ms, 10_000);
- assert_eq!(default.https_request_header_timeout_ms, 10_000);
- assert_eq!(default.https_max_connections, 1024);
- assert_eq!(default.https_http2_max_concurrent_streams, 128);
+ let toml = r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+listeners.transparent_https.bind = "127.0.0.1:8443"
+
+[policy]
+default = "deny"
+ "#;
+
+ let config: Config = toml::from_str(toml).expect("parse config");
+ let listener = config
+ .proxy
+ .listeners
+ .transparent_https
+ .as_ref()
+ .expect("transparent https listener");
+ assert_eq!(listener.handshake_timeout_ms, 10_000);
+ assert_eq!(listener.request_header_timeout_ms, 10_000);
+ assert_eq!(listener.max_connections, 1024);
+ assert_eq!(listener.http2_max_concurrent_streams, 128);
+ config.validate_basic().expect("config should validate");
let toml = r#"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
-https_handshake_timeout_ms = 250
-https_request_header_timeout_ms = 500
-https_max_connections = 8
-https_http2_max_concurrent_streams = 16
+listeners.explicit_http.bind = "127.0.0.1:8080"
+listeners.transparent_https = { bind = "127.0.0.1:8443", handshake_timeout_ms = 250, request_header_timeout_ms = 500, max_connections = 8, http2_max_concurrent_streams = 16 }
[policy]
default = "deny"
"#;
let config: Config = toml::from_str(toml).expect("parse config");
- assert_eq!(config.proxy.https_handshake_timeout_ms, 250);
- assert_eq!(config.proxy.https_request_header_timeout_ms, 500);
- assert_eq!(config.proxy.https_max_connections, 8);
- assert_eq!(config.proxy.https_http2_max_concurrent_streams, 16);
+ let listener = config
+ .proxy
+ .listeners
+ .transparent_https
+ .as_ref()
+ .expect("transparent https listener");
+ assert_eq!(listener.handshake_timeout_ms, 250);
+ assert_eq!(listener.request_header_timeout_ms, 500);
+ assert_eq!(listener.max_connections, 8);
+ assert_eq!(listener.http2_max_concurrent_streams, 16);
config.validate_basic().expect("config should validate");
}
@@ -2649,8 +3130,7 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "localhost"
-http_port = 8080
+listeners.explicit_http.bind = "localhost:8080"
[policy]
default = "deny"
@@ -2660,7 +3140,7 @@ default = "deny"
let err = config.validate_basic().expect_err("validation should fail");
let msg = format!("{err}");
assert!(
- msg.contains("proxy.bind_address must be a valid IP address"),
+ msg.contains("proxy.listeners.explicit_http.bind must be a valid socket address"),
"unexpected error: {msg}"
);
@@ -2668,10 +3148,8 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
-https_bind_address = "not-an-ip"
-https_port = 8443
+listeners.explicit_http.bind = "127.0.0.1:8080"
+listeners.transparent_http.bind = "not-an-ip:8443"
[policy]
default = "deny"
@@ -2681,7 +3159,7 @@ default = "deny"
let err = config.validate_basic().expect_err("validation should fail");
let msg = format!("{err}");
assert!(
- msg.contains("proxy.https_bind_address must be a valid IP address"),
+ msg.contains("proxy.listeners.transparent_http.bind must be a valid socket address"),
"unexpected error: {msg}"
);
}
@@ -2692,10 +3170,8 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "0.0.0.0"
-http_port = 8443
-https_bind_address = "127.0.0.1"
-https_port = 8443
+listeners.explicit_http.bind = "0.0.0.0:8443"
+listeners.transparent_http.bind = "127.0.0.1:8443"
[policy]
default = "deny"
@@ -2705,7 +3181,9 @@ default = "deny"
let err = config.validate_basic().expect_err("validation should fail");
let msg = format!("{err}");
assert!(
- msg.contains("proxy.http_port and proxy.https_port must not both be 8443"),
+ msg.contains("proxy.listeners.explicit_http.bind")
+ && msg.contains("proxy.listeners.transparent_http.bind")
+ && msg.contains("8443"),
"unexpected error: {msg}"
);
@@ -2713,10 +3191,8 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8443
-https_bind_address = "127.0.0.2"
-https_port = 8443
+listeners.explicit_http.bind = "127.0.0.1:8443"
+listeners.transparent_http.bind = "127.0.0.2:8443"
[policy]
default = "deny"
@@ -2726,6 +3202,27 @@ default = "deny"
config
.validate_basic()
.expect("distinct listener bind addresses may share a port");
+
+ let toml = r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "0.0.0.0:8443"
+listeners.transparent_https.bind = "[::]:8443"
+
+[policy]
+default = "deny"
+ "#;
+
+ let config: Config = toml::from_str(toml).expect("parse config");
+ let err = config.validate_basic().expect_err("validation should fail");
+ let msg = format!("{err}");
+ assert!(
+ msg.contains("proxy.listeners.explicit_http.bind")
+ && msg.contains("proxy.listeners.transparent_https.bind")
+ && msg.contains("8443"),
+ "unexpected error: {msg}"
+ );
}
#[test]
@@ -2733,6 +3230,9 @@ default = "deny"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[loop_protection]
header_name = "invalid header"
@@ -2755,10 +3255,11 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = "proxy.internal"
port = 9443
@@ -2784,6 +3285,196 @@ default = "deny"
.expect("valid egress target should pass validation");
}
+ #[test]
+ fn proxy_egress_default_https_without_tls_table_uses_system_trust() {
+ let toml = r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
+[proxy.egress.default]
+type = "explicit_proxy"
+transport = "https"
+host = "proxy.internal"
+port = 9443
+
+[policy]
+default = "deny"
+ "#;
+
+ let config: Config = toml::from_str(toml).expect("parse config");
+ let target = config
+ .proxy
+ .egress
+ .default
+ .as_ref()
+ .expect("egress target should exist");
+ assert_eq!(target.transport, EgressTransport::Https);
+ assert_eq!(target.effective_tls_trust(), EgressTlsTrust::System);
+ config
+ .validate_basic()
+ .expect("HTTPS egress without tls table should use system trust");
+ }
+
+ #[test]
+ fn proxy_egress_default_rejects_tls_table_for_http_transport() {
+ let toml = r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
+[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
+host = "proxy.internal"
+port = 9443
+
+[proxy.egress.default.tls]
+trust = "disabled"
+
+[policy]
+default = "deny"
+ "#;
+
+ let config: Config = toml::from_str(toml).expect("parse config");
+ let err = config.validate_basic().expect_err("validation should fail");
+ let msg = format!("{err}");
+ assert!(
+ msg.contains("proxy.egress.default.tls is only valid when transport = \"https\""),
+ "unexpected error message: {msg}"
+ );
+ }
+
+ #[test]
+ fn proxy_egress_default_custom_ca_requires_ca_cert_path() {
+ let toml = r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
+[proxy.egress.default]
+type = "explicit_proxy"
+transport = "https"
+host = "proxy.internal"
+port = 9443
+
+[proxy.egress.default.tls]
+trust = "custom_ca"
+
+[policy]
+default = "deny"
+ "#;
+
+ let config: Config = toml::from_str(toml).expect("parse config");
+ let err = config.validate_basic().expect_err("validation should fail");
+ let msg = format!("{err}");
+ assert!(
+ msg.contains(
+ "proxy.egress.default.tls.ca_cert_path is required when trust = \"custom_ca\""
+ ),
+ "unexpected error message: {msg}"
+ );
+ }
+
+ #[test]
+ fn proxy_egress_default_rejects_ca_cert_path_with_system_trust() {
+ let toml = r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
+[proxy.egress.default]
+type = "explicit_proxy"
+transport = "https"
+host = "proxy.internal"
+port = 9443
+
+[proxy.egress.default.tls]
+trust = "system"
+ca_cert_path = "ignored-ca.pem"
+
+[policy]
+default = "deny"
+ "#;
+
+ let config: Config = toml::from_str(toml).expect("parse config");
+ let err = config.validate_basic().expect_err("validation should fail");
+ let msg = format!("{err}");
+ assert!(
+ msg.contains(
+ "proxy.egress.default.tls.ca_cert_path must not be set when trust = \"system\""
+ ),
+ "unexpected error message: {msg}"
+ );
+ }
+
+ #[test]
+ fn proxy_egress_default_rejects_ca_cert_path_with_disabled_trust() {
+ let toml = r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
+[proxy.egress.default]
+type = "explicit_proxy"
+transport = "https"
+host = "proxy.internal"
+port = 9443
+
+[proxy.egress.default.tls]
+trust = "disabled"
+ca_cert_path = "ignored-ca.pem"
+
+[policy]
+default = "deny"
+ "#;
+
+ let config: Config = toml::from_str(toml).expect("parse config");
+ let err = config.validate_basic().expect_err("validation should fail");
+ let msg = format!("{err}");
+ assert!(
+ msg.contains(
+ "proxy.egress.default.tls.ca_cert_path must not be set when trust = \"disabled\""
+ ),
+ "unexpected error message: {msg}"
+ );
+ }
+
+ #[test]
+ fn proxy_egress_default_rejects_tls_server_name_with_port_suffix() {
+ let toml = r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
+[proxy.egress.default]
+type = "explicit_proxy"
+transport = "https"
+host = "proxy.internal"
+port = 9443
+
+[proxy.egress.default.tls]
+server_name = "proxy.internal:9443"
+
+[policy]
+default = "deny"
+ "#;
+
+ let config: Config = toml::from_str(toml).expect("parse config");
+ let err = config.validate_basic().expect_err("validation should fail");
+ let msg = format!("{err}");
+ assert!(
+ msg.contains("proxy.egress.default.tls.server_name must not include a port suffix"),
+ "unexpected error message: {msg}"
+ );
+ }
+
#[test]
fn proxy_egress_request_header_actions_default_to_empty() {
let config = Config::default();
@@ -2796,8 +3487,7 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[[proxy.egress.request_header_actions]]
action = "remove"
@@ -2830,8 +3520,7 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[[proxy.egress.request_header_actions]]
action = "set"
@@ -2864,8 +3553,7 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
internal_base_path = "internal"
[logging]
@@ -2891,8 +3579,7 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
internal_base_path = "/internal/"
[logging]
@@ -2918,10 +3605,11 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = " "
port = 8889
@@ -2948,10 +3636,11 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = " proxy.internal "
port = 8889
@@ -2980,10 +3669,11 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = "proxy.internal:8889"
port = 8889
@@ -3010,10 +3700,11 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = "::1"
port = 8889
@@ -3037,10 +3728,11 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = "[::1]"
port = 8889
@@ -3064,10 +3756,11 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = "[not-an-ip]"
port = 8889
@@ -3096,10 +3789,11 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = "proxy.internal"
port = 0
@@ -3126,8 +3820,7 @@ default = "deny"
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"
@@ -3155,8 +3848,7 @@ include = ""
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"
@@ -3188,7 +3880,7 @@ ca_cert_path = "ca-cert.pem"
let config: Config = toml::from_str(&toml_cert_only).expect("parse cert-only config");
assert!(config.validate_basic().is_err());
- // Both set is ok.
+ // Both set must point at readable, valid CA material.
let toml_both = format!(
r#"{base}
@@ -3198,7 +3890,11 @@ ca_cert_path = "ca-cert.pem"
"#
);
let config: Config = toml::from_str(&toml_both).expect("parse both config");
- assert!(config.validate_basic().is_ok());
+ let err = config.validate_basic().expect_err("validation should fail");
+ assert!(
+ format!("{err}").contains("ca-key.pem"),
+ "unexpected error: {err}"
+ );
}
#[test]
@@ -3207,8 +3903,7 @@ ca_cert_path = "ca-cert.pem"
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"
@@ -3238,8 +3933,7 @@ max_cached_certs = 0
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"
@@ -3263,8 +3957,7 @@ persist_dynamic_certs = true
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"
@@ -3292,8 +3985,7 @@ action = "allow"
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"
@@ -3320,8 +4012,7 @@ headers_absent = ["x-workload-id"]
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"
@@ -3348,8 +4039,7 @@ headers_match = { "x-workload-id" = "worker-123" }
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"
@@ -3376,8 +4066,7 @@ headers_not_match = { "x-aw-policy-context" = "internal" }
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"
@@ -3406,8 +4095,7 @@ headers_absent = []
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"
@@ -3436,8 +4124,7 @@ headers_match = {}
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"
@@ -3466,8 +4153,7 @@ headers_match = { "bad header" = "worker-123" }
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"
@@ -3496,8 +4182,7 @@ headers_match = { "X-Workload-Id" = "worker-123", "x-workload-id" = "worker-456"
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"
@@ -3526,8 +4211,7 @@ headers_match = { "x-workload-id" = "" }
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"
@@ -3556,8 +4240,7 @@ headers_not_match = {}
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"
@@ -3586,8 +4269,7 @@ headers_not_match = { "bad header" = "internal" }
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"
@@ -3616,8 +4298,7 @@ headers_not_match = { "X-AW-Policy-Context" = "internal", "x-aw-policy-context"
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"
@@ -3646,8 +4327,7 @@ headers_not_match = { "x-aw-policy-context" = "" }
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"
@@ -3672,8 +4352,7 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[logging]
directory = ""
@@ -3693,8 +4372,7 @@ default = "deny"
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"
@@ -3722,8 +4400,7 @@ default = "deny"
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"
@@ -3758,8 +4435,7 @@ level = "info"
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"
@@ -3792,8 +4468,7 @@ default = "deny"
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"
@@ -3816,8 +4491,7 @@ default = "deny"
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"
@@ -3844,8 +4518,7 @@ default = "deny"
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"
@@ -3878,8 +4551,7 @@ default = "deny"
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"
@@ -3909,8 +4581,7 @@ callback_url = "/relative/path"
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"
@@ -3958,8 +4629,7 @@ callback_url = "https://proxy.example.com/_acl-proxy/external-auth/callback"
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"
@@ -3988,6 +4658,9 @@ external_auth_profile = "missing_profile"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4018,8 +4691,7 @@ external_auth_profile = "example"
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"
@@ -4052,6 +4724,9 @@ external_auth_profile = "example"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4079,6 +4754,9 @@ external_auth_profile = "example"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4113,6 +4791,9 @@ external_auth_profile = "body_guard"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4142,6 +4823,9 @@ external_auth_profile = "body_guard"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4161,6 +4845,9 @@ timeout_ms = 0
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4184,6 +4871,9 @@ webhook_timeout_ms = 0
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4211,6 +4901,9 @@ external_auth_profile = "body_guard"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4241,6 +4934,9 @@ external_auth_profile = "body_guard"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4263,6 +4959,9 @@ pattern = "https://example.com/**"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "deny"
@@ -4288,6 +4987,9 @@ pattern = "https://example.com/**"
let toml = r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[policy]
default = "delegate"
"#;
@@ -4304,8 +5006,7 @@ default = "delegate"
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"
@@ -4353,8 +5054,7 @@ external_auth_profile = "example"
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"
@@ -4430,8 +5130,7 @@ external_auth_profile = "example"
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"
@@ -4473,8 +5172,7 @@ value = "Bearer ${TOKEN}"
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"
@@ -4522,8 +5220,7 @@ include = "api_headers"
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"
@@ -4564,8 +5261,7 @@ value = "Bearer ${{TOKEN}}"
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"
@@ -4611,8 +5307,7 @@ value = "${ACL_PROXY_TEST_API_TOKEN}"
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"
@@ -4663,8 +5358,7 @@ values = ["static", "${ACL_PROXY_TEST_DEPLOYMENT}", "${ACL_PROXY_TEST_REGION}",
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"
@@ -4713,8 +5407,7 @@ include = "api_headers"
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"
@@ -4759,8 +5452,7 @@ value = "${ACL_PROXY_TEST_MISSING}"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[[proxy.egress.request_header_actions]]
action = "set"
@@ -4797,8 +5489,7 @@ default = "deny"
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[[proxy.egress.request_header_actions]]
action = "set"
@@ -4835,8 +5526,7 @@ default = "deny"
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"
@@ -4886,8 +5576,7 @@ value = "{{github_token}}"
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"
@@ -4932,8 +5621,7 @@ replace = "${ACL_PROXY_TEST_REPLACE}"
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"
@@ -4977,8 +5665,7 @@ name = "authorization"
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"
@@ -5021,8 +5708,7 @@ value = "${ACL_PROXY_TEST_EMPTY}"
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"
@@ -5070,8 +5756,7 @@ value = "${{ACL_PROXY_TEST_LOAD_TOKEN}}"
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"
@@ -5128,8 +5813,7 @@ values = ["static", "${{ACL_PROXY_TEST_DUMP_REGION}}"]
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"
diff --git a/src/proxy/explicit_https.rs b/src/proxy/explicit_https.rs
new file mode 100644
index 0000000..d718675
--- /dev/null
+++ b/src/proxy/explicit_https.rs
@@ -0,0 +1,382 @@
+use std::io::BufReader;
+use std::net::{SocketAddr, TcpListener as StdTcpListener};
+use std::path::Path;
+use std::sync::Arc;
+use std::time::Duration;
+
+use hyper::body::Body;
+use hyper::server::conn::Http;
+use hyper::service::service_fn;
+use hyper::Request;
+use rustls::{Certificate, PrivateKey, ServerConfig};
+use tokio::net::TcpListener;
+use tokio::sync::Semaphore;
+use tokio::task::JoinSet;
+use tokio_rustls::TlsAcceptor;
+
+use crate::app::SharedAppState;
+use crate::config::{parse_listener_bind, ExplicitHttpsListenerConfig};
+use crate::proxy::http::{handle_http_request, HttpListenerMode};
+
+#[derive(Debug, thiserror::Error)]
+pub enum ExplicitHttpsError {
+ #[error("invalid explicit HTTPS bind address {address}: {message}")]
+ BindAddress { address: String, message: String },
+
+ #[error("failed to bind explicit HTTPS listener on {addr}: {source}")]
+ BindListener {
+ addr: SocketAddr,
+ #[source]
+ source: std::io::Error,
+ },
+
+ #[error("failed to create tokio listener from std listener: {0}")]
+ FromStd(#[source] std::io::Error),
+
+ #[error("explicit HTTPS TLS configuration error: {0}")]
+ TlsConfig(String),
+
+ #[error("accept error on explicit HTTPS listener: {0}")]
+ Accept(#[source] std::io::Error),
+
+ #[error("TLS handshake failed: {0}")]
+ TlsHandshake(#[source] std::io::Error),
+
+ #[error("TLS handshake timed out after {timeout_ms}ms")]
+ TlsHandshakeTimeout { timeout_ms: u64 },
+
+ #[error("hyper server error: {0}")]
+ Hyper(#[from] hyper::Error),
+}
+
+#[derive(Clone, Copy, Debug)]
+struct ExplicitHttpsConnectionLimits {
+ handshake_timeout: Option,
+ handshake_timeout_ms: u64,
+ request_header_timeout: Option,
+ http2_max_concurrent_streams: Option,
+ enable_http2: bool,
+}
+
+impl ExplicitHttpsConnectionLimits {
+ fn from_config(config: &ExplicitHttpsListenerConfig) -> Self {
+ Self {
+ handshake_timeout: duration_from_millis(config.handshake_timeout_ms),
+ handshake_timeout_ms: config.handshake_timeout_ms,
+ request_header_timeout: duration_from_millis(config.request_header_timeout_ms),
+ http2_max_concurrent_streams: nonzero_u32(config.http2_max_concurrent_streams),
+ enable_http2: config.enable_http2,
+ }
+ }
+}
+
+fn duration_from_millis(value: u64) -> Option {
+ if value == 0 {
+ None
+ } else {
+ Some(Duration::from_millis(value))
+ }
+}
+
+fn nonzero_u32(value: u32) -> Option {
+ if value == 0 {
+ None
+ } else {
+ Some(value)
+ }
+}
+
+pub async fn run_explicit_https_proxy(
+ state: SharedAppState,
+ shutdown: F,
+) -> Result<(), ExplicitHttpsError>
+where
+ F: std::future::Future + Send + 'static,
+{
+ let initial = state.load();
+ let listener_cfg = initial
+ .config
+ .proxy
+ .listeners
+ .explicit_https
+ .as_ref()
+ .expect("explicit_https listener must be configured before startup")
+ .clone();
+
+ let addr = parse_listener_bind("proxy.listeners.explicit_https.bind", &listener_cfg.bind)
+ .map_err(|err| ExplicitHttpsError::BindAddress {
+ address: listener_cfg.bind.clone(),
+ message: err.to_string(),
+ })?;
+
+ let tls_acceptor = build_static_tls_acceptor(&listener_cfg)?;
+ let listener = StdTcpListener::bind(addr)
+ .map_err(|e| ExplicitHttpsError::BindListener { addr, source: e })?;
+ listener
+ .set_nonblocking(true)
+ .map_err(ExplicitHttpsError::FromStd)?;
+
+ run_explicit_https_proxy_on_listener(state, listener, tls_acceptor, listener_cfg, shutdown)
+ .await
+}
+
+pub async fn run_explicit_https_proxy_on_listener(
+ state: SharedAppState,
+ listener: StdTcpListener,
+ tls_acceptor: TlsAcceptor,
+ listener_cfg: ExplicitHttpsListenerConfig,
+ shutdown: F,
+) -> Result<(), ExplicitHttpsError>
+where
+ F: std::future::Future + Send + 'static,
+{
+ let listener = TcpListener::from_std(listener).map_err(ExplicitHttpsError::FromStd)?;
+ let connection_limit = listener_cfg.max_connections;
+ let connection_semaphore = if connection_limit == 0 {
+ None
+ } else {
+ Some(Arc::new(Semaphore::new(connection_limit)))
+ };
+ let limits = ExplicitHttpsConnectionLimits::from_config(&listener_cfg);
+ let mut connections = JoinSet::new();
+ let (connection_shutdown, _) = crate::proxy::listener::connection_shutdown_channel();
+
+ tokio::pin!(shutdown);
+
+ 'accepting: loop {
+ let connection_permit = match connection_semaphore.as_ref() {
+ Some(semaphore) => {
+ let semaphore = semaphore.clone();
+ loop {
+ match tokio::select! {
+ _ = &mut shutdown => {
+ crate::proxy::listener::signal_connection_shutdown(&connection_shutdown);
+ break 'accepting;
+ }
+ Some(join_result) = connections.join_next(), if !connections.is_empty() => {
+ crate::proxy::listener::log_connection_task_result("explicit_https", join_result);
+ continue;
+ }
+ permit = semaphore.clone().acquire_owned() => permit,
+ } {
+ Ok(permit) => break Some(permit),
+ Err(_) => break 'accepting,
+ }
+ }
+ }
+ None => None,
+ };
+
+ tokio::select! {
+ _ = &mut shutdown => {
+ crate::proxy::listener::signal_connection_shutdown(&connection_shutdown);
+ break;
+ }
+ Some(join_result) = connections.join_next(), if !connections.is_empty() => {
+ drop(connection_permit);
+ crate::proxy::listener::log_connection_task_result("explicit_https", join_result);
+ }
+ accept_res = listener.accept() => {
+ let (socket, remote_addr) = match accept_res {
+ Ok(v) => v,
+ Err(e) => {
+ crate::proxy::listener::handle_accept_error("explicit_https", e).await;
+ continue;
+ }
+ };
+
+ let state = state.clone();
+ let tls_acceptor = tls_acceptor.clone();
+ let connection_shutdown = connection_shutdown.subscribe();
+ connections.spawn(async move {
+ if let Err(err) =
+ handle_tls_connection(
+ state,
+ tls_acceptor,
+ socket,
+ remote_addr,
+ limits,
+ connection_shutdown,
+ )
+ .await
+ {
+ tracing::debug!(
+ "explicit HTTPS connection error from {}: {err}",
+ remote_addr
+ );
+ }
+ drop(connection_permit);
+ });
+ }
+ }
+ }
+
+ let drain_timeout = state.load().config.proxy.shutdown_drain_timeout();
+ crate::proxy::listener::drain_connections("explicit_https", &mut connections, drain_timeout)
+ .await;
+
+ Ok(())
+}
+
+async fn handle_tls_connection(
+ state: SharedAppState,
+ tls_acceptor: TlsAcceptor,
+ socket: tokio::net::TcpStream,
+ remote_addr: SocketAddr,
+ limits: ExplicitHttpsConnectionLimits,
+ mut connection_shutdown: tokio::sync::watch::Receiver,
+) -> Result<(), ExplicitHttpsError> {
+ let accept = tls_acceptor.accept(socket);
+ let tls_result = match limits.handshake_timeout {
+ Some(timeout) => match tokio::time::timeout(timeout, accept).await {
+ Ok(result) => result,
+ Err(_) => {
+ tracing::debug!(
+ target: "acl_proxy::tls",
+ peer_addr = %remote_addr,
+ listener = "explicit_https",
+ timeout_ms = limits.handshake_timeout_ms,
+ "TLS handshake timed out"
+ );
+ return Err(ExplicitHttpsError::TlsHandshakeTimeout {
+ timeout_ms: limits.handshake_timeout_ms,
+ });
+ }
+ },
+ None => accept.await,
+ };
+
+ let tls = match tls_result {
+ Ok(tls) => tls,
+ Err(e) => {
+ tracing::debug!(
+ target: "acl_proxy::tls",
+ peer_addr = %remote_addr,
+ listener = "explicit_https",
+ error = %e,
+ "TLS handshake failed"
+ );
+ return Err(ExplicitHttpsError::TlsHandshake(e));
+ }
+ };
+
+ let (_, conn) = tls.get_ref();
+ let alpn = conn
+ .alpn_protocol()
+ .map(|p| String::from_utf8_lossy(p).to_string());
+ tracing::debug!(
+ target: "acl_proxy::tls",
+ peer_addr = %remote_addr,
+ listener = "explicit_https",
+ alpn = %alpn.as_deref().unwrap_or(""),
+ "accepted TLS connection on explicit HTTPS listener"
+ );
+
+ let service = service_fn(move |req: Request| {
+ let state = state.load_full();
+ handle_http_request(state, remote_addr, HttpListenerMode::Explicit, req)
+ });
+
+ let mut http = Http::new();
+ http.http1_keep_alive(true);
+ if limits.enable_http2 {
+ http.http2_max_concurrent_streams(limits.http2_max_concurrent_streams);
+ } else {
+ http.http1_only(true);
+ }
+ if let Some(timeout) = limits.request_header_timeout {
+ http.http1_header_read_timeout(timeout);
+ if limits.enable_http2 {
+ http.http2_keep_alive_interval(timeout);
+ http.http2_keep_alive_timeout(timeout);
+ }
+ }
+
+ let conn = http.serve_connection(tls, service).with_upgrades();
+ tokio::pin!(conn);
+ let result = tokio::select! {
+ result = &mut conn => result,
+ _ = crate::proxy::listener::wait_for_connection_shutdown(&mut connection_shutdown) => {
+ conn.as_mut().graceful_shutdown();
+ conn.await
+ }
+ };
+
+ result.map_err(ExplicitHttpsError::Hyper)
+}
+
+pub fn build_static_tls_acceptor(
+ config: &ExplicitHttpsListenerConfig,
+) -> Result {
+ let certs = load_pem_certs(&config.cert_chain_path)?;
+ let key = load_private_key(&config.key_path)?;
+ let mut server = ServerConfig::builder()
+ .with_safe_defaults()
+ .with_no_client_auth()
+ .with_single_cert(certs, key)
+ .map_err(|err| ExplicitHttpsError::TlsConfig(err.to_string()))?;
+
+ server.alpn_protocols = if config.enable_http2 {
+ vec![b"h2".to_vec(), b"http/1.1".to_vec()]
+ } else {
+ vec![b"http/1.1".to_vec()]
+ };
+
+ Ok(TlsAcceptor::from(Arc::new(server)))
+}
+
+fn load_pem_certs(path: &Path) -> Result, ExplicitHttpsError> {
+ let file = std::fs::File::open(path).map_err(|err| {
+ ExplicitHttpsError::TlsConfig(format!(
+ "failed to read cert_chain_path {}: {err}",
+ path.display()
+ ))
+ })?;
+ let mut reader = BufReader::new(file);
+ let certs = rustls_pemfile::certs(&mut reader).map_err(|err| {
+ ExplicitHttpsError::TlsConfig(format!(
+ "cert_chain_path {} must contain valid PEM certificates: {err}",
+ path.display()
+ ))
+ })?;
+ if certs.is_empty() {
+ return Err(ExplicitHttpsError::TlsConfig(format!(
+ "cert_chain_path {} must contain at least one PEM certificate",
+ path.display()
+ )));
+ }
+ Ok(certs.into_iter().map(Certificate).collect())
+}
+
+fn load_private_key(path: &Path) -> Result {
+ for loader in [
+ rustls_pemfile::pkcs8_private_keys
+ as fn(&mut dyn std::io::BufRead) -> Result>, std::io::Error>,
+ rustls_pemfile::rsa_private_keys
+ as fn(&mut dyn std::io::BufRead) -> Result>, std::io::Error>,
+ rustls_pemfile::ec_private_keys
+ as fn(&mut dyn std::io::BufRead) -> Result>, std::io::Error>,
+ ] {
+ let file = std::fs::File::open(path).map_err(|err| {
+ ExplicitHttpsError::TlsConfig(format!(
+ "failed to read key_path {}: {err}",
+ path.display()
+ ))
+ })?;
+ let mut reader = BufReader::new(file);
+ let mut keys = loader(&mut reader).map_err(|err| {
+ ExplicitHttpsError::TlsConfig(format!(
+ "key_path {} must contain a valid PEM private key: {err}",
+ path.display()
+ ))
+ })?;
+ if !keys.is_empty() {
+ return Ok(PrivateKey(keys.remove(0)));
+ }
+ }
+
+ Err(ExplicitHttpsError::TlsConfig(format!(
+ "key_path {} must contain a supported PEM private key",
+ path.display()
+ )))
+}
diff --git a/src/proxy/http.rs b/src/proxy/http.rs
index 339e14f..30217fd 100644
--- a/src/proxy/http.rs
+++ b/src/proxy/http.rs
@@ -18,25 +18,28 @@ use http::header::{
use http::{Method, StatusCode, Uri, Version};
use hyper::body::{Bytes, HttpBody};
use hyper::client::conn;
-use hyper::server::conn::AddrStream;
-use hyper::service::{make_service_fn, service_fn};
-use hyper::{Body, Client, Request, Response, Server};
-use tokio::io::copy_bidirectional;
-use tokio::net::TcpStream;
+use hyper::server::conn::Http;
+use hyper::service::service_fn;
+use hyper::{Body, Client, Request, Response};
+use rustls::ServerName;
+use tokio::io::{copy_bidirectional, AsyncRead, AsyncWrite};
+use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};
+use tokio::task::JoinSet;
+use tokio_rustls::TlsConnector;
use tokio_stream::wrappers::ReceiverStream;
use serde::Deserialize;
-use crate::app::{AppState, SharedAppState};
+use crate::app::{AppState, ParentEgressTls, SharedAppState};
use crate::auth_plugin::{AuthPluginHandle, PluginBodyInput, PluginBodyMutation, PluginDecision};
use crate::capture::{
build_capture_record, should_capture, BodyCaptureBuffer, BodyCaptureResult, CaptureDecision,
CaptureEndpoint, CaptureKind, CaptureMode, CaptureRecord, CaptureRecordOptions, HeaderMap,
};
use crate::config::{
- Config, EgressTargetConfig, ExternalAuthProfileType, HeaderActionKind, HeaderDirection,
- HeaderWhen, PolicyRuleAction,
+ parse_listener_bind, Config, EgressTargetConfig, EgressTransport, ExternalAuthProfileType,
+ HeaderActionKind, HeaderDirection, HeaderWhen, PolicyRuleAction,
};
use crate::external_auth::{ApprovalMacroDescriptor, ExternalAuthProfile, ExternalDecision};
use crate::logging::PolicyDecisionLogContext;
@@ -49,12 +52,8 @@ use crate::url_canonical::{canonicalize_http_url, CanonicalUrl};
#[derive(Debug, thiserror::Error)]
pub enum HttpProxyError {
- #[error("invalid bind address {address}: {source}")]
- BindAddress {
- address: String,
- #[source]
- source: std::net::AddrParseError,
- },
+ #[error("invalid bind address {address}: {message}")]
+ BindAddress { address: String, message: String },
#[error("failed to bind HTTP proxy listener on {addr}: {source}")]
BindListener {
@@ -76,6 +75,10 @@ enum UpstreamRequestError {
Hyper(#[from] hyper::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
+ #[error("egress TLS configuration error: {0}")]
+ TlsConfig(String),
+ #[error("egress protocol negotiation error: {0}")]
+ Protocol(String),
}
#[derive(Debug, thiserror::Error)]
@@ -142,28 +145,46 @@ pub(crate) enum ExternalAuthGateResult {
Pass(Request),
}
-/// Run the HTTP/1.1 proxy listener using the configured bind address/port.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum HttpListenerMode {
+ Explicit,
+ Transparent,
+}
+
+/// Run the explicit HTTP proxy listener using the configured bind address.
///
/// The listener uses the shared, reloadable application state; new
-/// connections observe the latest configuration snapshot, while
+/// requests observe the latest configuration snapshot, while
/// in-flight requests continue using the state captured for that
/// request.
pub async fn run_http_proxy(state: SharedAppState, shutdown: F) -> Result<(), HttpProxyError>
+where
+ F: std::future::Future + Send + 'static,
+{
+ run_explicit_http_proxy(state, shutdown).await
+}
+
+pub async fn run_explicit_http_proxy(
+ state: SharedAppState,
+ shutdown: F,
+) -> Result<(), HttpProxyError>
where
F: std::future::Future + Send + 'static,
{
let initial = state.load();
- let bind_ip =
- initial
- .config
- .proxy
- .bind_address
- .parse()
- .map_err(|e| HttpProxyError::BindAddress {
- address: initial.config.proxy.bind_address.clone(),
- source: e,
- })?;
- let addr = SocketAddr::new(bind_ip, initial.config.proxy.http_port);
+ let listener_cfg = initial
+ .config
+ .proxy
+ .listeners
+ .explicit_http
+ .as_ref()
+ .expect("explicit_http listener must be configured before startup");
+ let addr = parse_listener_bind("proxy.listeners.explicit_http.bind", &listener_cfg.bind)
+ .map_err(|err| HttpProxyError::BindAddress {
+ address: listener_cfg.bind.clone(),
+ message: err.to_string(),
+ })?;
+ let enable_http2 = listener_cfg.enable_http2;
let listener =
StdTcpListener::bind(addr).map_err(|e| HttpProxyError::BindListener { addr, source: e })?;
@@ -171,10 +192,55 @@ where
.set_nonblocking(true)
.map_err(HttpProxyError::FromTcp)?;
- run_http_proxy_on_listener(state, listener, shutdown).await
+ run_http_proxy_on_listener_with_mode(
+ state,
+ listener,
+ HttpListenerMode::Explicit,
+ enable_http2,
+ shutdown,
+ )
+ .await
}
-/// Run the HTTP/1.1 proxy on an existing listener (useful for tests).
+pub async fn run_transparent_http_proxy(
+ state: SharedAppState,
+ shutdown: F,
+) -> Result<(), HttpProxyError>
+where
+ F: std::future::Future + Send + 'static,
+{
+ let initial = state.load();
+ let listener_cfg = initial
+ .config
+ .proxy
+ .listeners
+ .transparent_http
+ .as_ref()
+ .expect("transparent_http listener must be configured before startup");
+ let addr = parse_listener_bind("proxy.listeners.transparent_http.bind", &listener_cfg.bind)
+ .map_err(|err| HttpProxyError::BindAddress {
+ address: listener_cfg.bind.clone(),
+ message: err.to_string(),
+ })?;
+ let enable_http2 = listener_cfg.enable_http2;
+
+ let listener =
+ StdTcpListener::bind(addr).map_err(|e| HttpProxyError::BindListener { addr, source: e })?;
+ listener
+ .set_nonblocking(true)
+ .map_err(HttpProxyError::FromTcp)?;
+
+ run_http_proxy_on_listener_with_mode(
+ state,
+ listener,
+ HttpListenerMode::Transparent,
+ enable_http2,
+ shutdown,
+ )
+ .await
+}
+
+/// Run the explicit HTTP proxy on an existing listener (useful for tests).
pub async fn run_http_proxy_on_listener(
state: SharedAppState,
listener: StdTcpListener,
@@ -183,56 +249,144 @@ pub async fn run_http_proxy_on_listener(
where
F: std::future::Future + Send + 'static,
{
- let make_svc = make_service_fn(move |conn: &AddrStream| {
- let state = state.clone();
- let remote_addr = conn.remote_addr();
- async move {
- Ok::<_, Infallible>(service_fn(move |req| {
+ run_http_proxy_on_listener_with_mode(
+ state,
+ listener,
+ HttpListenerMode::Explicit,
+ true,
+ shutdown,
+ )
+ .await
+}
+
+pub async fn run_http_proxy_on_listener_with_mode(
+ state: SharedAppState,
+ listener: StdTcpListener,
+ mode: HttpListenerMode,
+ enable_http2: bool,
+ shutdown: F,
+) -> Result<(), HttpProxyError>
+where
+ F: std::future::Future + Send + 'static,
+{
+ let listener = TcpListener::from_std(listener).map_err(HttpProxyError::FromTcp)?;
+ let mut connections = JoinSet::new();
+ let (connection_shutdown, _) = crate::proxy::listener::connection_shutdown_channel();
+ tokio::pin!(shutdown);
+
+ loop {
+ tokio::select! {
+ _ = &mut shutdown => {
+ crate::proxy::listener::signal_connection_shutdown(&connection_shutdown);
+ break;
+ }
+ Some(join_result) = connections.join_next(), if !connections.is_empty() => {
+ crate::proxy::listener::log_connection_task_result(http_listener_name(mode), join_result);
+ }
+ accept_res = listener.accept() => {
+ let (socket, remote_addr) = match accept_res {
+ Ok(v) => v,
+ Err(e) => {
+ crate::proxy::listener::handle_accept_error(http_listener_name(mode), e).await;
+ continue;
+ }
+ };
+
let state = state.clone();
- let state_snapshot = state.load_full();
- handle_http_request(state_snapshot, remote_addr, req)
- }))
+ let mut connection_shutdown = connection_shutdown.subscribe();
+ connections.spawn(async move {
+ let service = service_fn(move |req| {
+ let state_snapshot = state.load_full();
+ handle_http_request(state_snapshot.clone(), remote_addr, mode, req)
+ });
+
+ let mut http = Http::new();
+ http.http1_keep_alive(true);
+ if !enable_http2 {
+ http.http1_only(true);
+ }
+
+ let conn = http.serve_connection(socket, service).with_upgrades();
+ tokio::pin!(conn);
+ let result = tokio::select! {
+ result = &mut conn => result,
+ _ = crate::proxy::listener::wait_for_connection_shutdown(&mut connection_shutdown) => {
+ conn.as_mut().graceful_shutdown();
+ conn.await
+ }
+ };
+
+ if let Err(err) = result {
+ tracing::debug!(
+ listener = ?mode,
+ peer_addr = %remote_addr,
+ "HTTP listener connection error: {err}"
+ );
+ }
+ });
+ }
}
- });
+ }
- let server = Server::from_tcp(listener)
- .map_err(HttpProxyError::Hyper)?
- .serve(make_svc)
- .with_graceful_shutdown(shutdown);
+ let drain_timeout = state.load().config.proxy.shutdown_drain_timeout();
+ crate::proxy::listener::drain_connections(
+ http_listener_name(mode),
+ &mut connections,
+ drain_timeout,
+ )
+ .await;
+
+ Ok(())
+}
- server.await.map_err(HttpProxyError::Hyper)
+fn http_listener_name(mode: HttpListenerMode) -> &'static str {
+ match mode {
+ HttpListenerMode::Explicit => "explicit_http",
+ HttpListenerMode::Transparent => "transparent_http",
+ }
}
-async fn handle_http_request(
+pub(crate) async fn handle_http_request(
state: Arc,
remote_addr: SocketAddr,
+ mode: HttpListenerMode,
mut req: Request,
) -> Result, Infallible> {
- // Special-case HTTPS CONNECT requests, which are handled via a dedicated
- // MITM path in `https_connect`.
if req.method() == Method::CONNECT {
- let resp = https_connect::handle_connect_request(state, remote_addr, req).await;
+ let resp = match mode {
+ HttpListenerMode::Explicit if req.version() == Version::HTTP_2 => {
+ not_implemented("HTTP/2 CONNECT is not supported")
+ }
+ HttpListenerMode::Explicit => {
+ https_connect::handle_connect_request(state, remote_addr, req).await
+ }
+ HttpListenerMode::Transparent => {
+ bad_request("Bad Request: CONNECT is not valid on transparent HTTP listener")
+ }
+ };
return Ok(resp);
}
- if is_internal_endpoint(
- req.uri(),
- &state.config.proxy.internal_base_path,
- "external-auth/callback",
- ) {
- let resp = handle_external_auth_callback_request(state, req).await;
- return Ok(resp);
- }
+ if mode == HttpListenerMode::Explicit {
+ if is_internal_endpoint(
+ req.uri(),
+ &state.config.proxy.internal_base_path,
+ "external-auth/callback",
+ ) {
+ let resp = handle_external_auth_callback_request(state, req).await;
+ return Ok(resp);
+ }
- if is_internal_endpoint(req.uri(), &state.config.proxy.internal_base_path, "ready") {
- let resp = handle_ready_request(req);
- return Ok(resp);
+ if is_internal_endpoint(req.uri(), &state.config.proxy.internal_base_path, "ready") {
+ let resp = handle_ready_request(req);
+ return Ok(resp);
+ }
}
let request_id = generate_request_id();
let method = req.method().clone();
let version = req.version();
- let (full_url, target) = match build_full_url(&req) {
+ let (full_url, target) = match build_full_url(&req, mode) {
Ok(v) => v,
Err(resp) => return Ok(resp),
};
@@ -1713,10 +1867,19 @@ fn set_body_headers(
fn build_full_url(
req: &Request,
+ mode: HttpListenerMode,
+) -> Result<(String, Option), Response> {
+ match mode {
+ HttpListenerMode::Explicit => build_explicit_proxy_url(req),
+ HttpListenerMode::Transparent => build_transparent_http_url(req),
+ }
+}
+
+fn build_explicit_proxy_url(
+ req: &Request,
) -> Result<(String, Option), Response> {
let uri = req.uri();
- // Absolute-form URLs (standard HTTP proxy mode).
if let (Some(scheme), Some(authority)) = (uri.scheme_str(), uri.authority()) {
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
@@ -1729,8 +1892,20 @@ fn build_full_url(
return canonicalize_proxy_url(&raw_url);
}
- // Origin-form requests are accepted on the HTTP listener to support
- // transparent HTTP interception (for example, port-80 redirects).
+ Err(bad_request(
+ "Bad Request: explicit proxy requests must use absolute-form request targets",
+ ))
+}
+
+fn build_transparent_http_url(
+ req: &Request,
+) -> Result<(String, Option), Response> {
+ if req.version() != Version::HTTP_2 && req.uri().scheme().is_some() {
+ return Err(bad_request(
+ "Bad Request: transparent HTTP requests must use origin-form request targets",
+ ));
+ }
+
build_http_url_from_origin_form(req)
}
@@ -1802,6 +1977,12 @@ pub(crate) fn bad_request(message: &'static str) -> Response {
resp
}
+pub(crate) fn not_implemented(message: &'static str) -> Response {
+ let mut resp = Response::new(Body::from(message));
+ *resp.status_mut() = StatusCode::NOT_IMPLEMENTED;
+ resp
+}
+
pub(crate) fn has_loop_header(headers: &HttpHeaderMap, name: &HeaderName) -> bool {
headers.contains_key(name)
}
@@ -2511,6 +2692,7 @@ pub(crate) async fn proxy_allowed_request(
let client_http: Client<_> = state.http_client.clone();
let forwarding_target = state.config.proxy.egress.default.as_ref();
+ let parent_egress_tls = state.parent_egress_tls.clone();
let request_timeout =
resolve_request_timeout_ms(request_timeout_ms, state.config.proxy.request_timeout_ms);
tracing::debug!(
@@ -2531,6 +2713,7 @@ pub(crate) async fn proxy_allowed_request(
send_upstream_request(
client_http,
forwarding_target,
+ parent_egress_tls.as_ref(),
version,
request_is_upgrade,
&request_id,
@@ -2570,6 +2753,7 @@ pub(crate) async fn proxy_allowed_request(
send_upstream_request(
client_http,
forwarding_target,
+ parent_egress_tls.as_ref(),
version,
request_is_upgrade,
&request_id,
@@ -2860,6 +3044,7 @@ pub(crate) async fn proxy_allowed_request(
async fn send_upstream_request(
client_http: Client>,
forwarding_target: Option<&EgressTargetConfig>,
+ parent_egress_tls: Option<&ParentEgressTls>,
request_version: Version,
request_is_upgrade: bool,
request_id: &str,
@@ -2870,6 +3055,7 @@ async fn send_upstream_request(
Some(target) => {
send_request_via_egress_target(
target,
+ parent_egress_tls,
request_version,
request_is_upgrade,
request_id,
@@ -2899,14 +3085,20 @@ async fn send_upstream_request(
async fn send_request_via_egress_target(
target: &EgressTargetConfig,
+ parent_egress_tls: Option<&ParentEgressTls>,
request_version: Version,
request_is_upgrade: bool,
request_id: &str,
full_url: &str,
- mut upstream_req: Request,
+ upstream_req: Request,
) -> Result, UpstreamRequestError> {
let use_http2 = request_version == Version::HTTP_2 && !request_is_upgrade;
- let chain_protocol = if use_http2 { "h2c" } else { "http/1.1" };
+ let chain_protocol = match (target.transport, use_http2) {
+ (EgressTransport::Http, true) => "h2c",
+ (EgressTransport::Http, false) => "http/1.1",
+ (EgressTransport::Https, true) => "h2",
+ (EgressTransport::Https, false) => "http/1.1",
+ };
let ingress_http_version = version_to_string(request_version);
tracing::debug!(
@@ -2914,14 +3106,69 @@ async fn send_request_via_egress_target(
request_id = %request_id,
url = %redact_url_for_sink(full_url),
stage = "egress_attempt",
- egress_transport = "chain",
+ egress_type = "explicit_proxy",
+ egress_transport = %egress_transport_label(target.transport),
egress_host = %target.host,
egress_port = target.port,
+ egress_tls_server_name = %if target.transport == EgressTransport::Https { target.tls_server_name() } else { "" },
chain_protocol,
"forwarding allowed request via configured egress destination"
);
let stream = TcpStream::connect((egress_dial_host(&target.host), target.port)).await?;
+ match target.transport {
+ EgressTransport::Http => {
+ send_request_via_egress_stream(
+ stream,
+ target,
+ request_version,
+ request_is_upgrade,
+ request_id,
+ full_url,
+ upstream_req,
+ chain_protocol,
+ ingress_http_version,
+ )
+ .await
+ }
+ EgressTransport::Https => {
+ let parent_egress_tls = parent_egress_tls.ok_or_else(|| {
+ UpstreamRequestError::TlsConfig(
+ "HTTPS parent egress TLS state is unavailable".to_string(),
+ )
+ })?;
+ let tls = connect_https_parent(parent_egress_tls, stream, use_http2).await?;
+ send_request_via_egress_stream(
+ tls,
+ target,
+ request_version,
+ request_is_upgrade,
+ request_id,
+ full_url,
+ upstream_req,
+ chain_protocol,
+ ingress_http_version,
+ )
+ .await
+ }
+ }
+}
+
+async fn send_request_via_egress_stream(
+ stream: S,
+ target: &EgressTargetConfig,
+ request_version: Version,
+ request_is_upgrade: bool,
+ request_id: &str,
+ full_url: &str,
+ mut upstream_req: Request,
+ chain_protocol: &'static str,
+ ingress_http_version: String,
+) -> Result, UpstreamRequestError>
+where
+ S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
+{
+ let use_http2 = request_version == Version::HTTP_2 && !request_is_upgrade;
let mut conn_builder = conn::Builder::new();
if use_http2 {
conn_builder.http2_only(true);
@@ -2937,13 +3184,15 @@ async fn send_request_via_egress_target(
request_id = %request_id,
url = %redact_url_for_sink(full_url),
stage = "egress",
- egress_transport = "chain",
+ egress_type = "explicit_proxy",
+ egress_transport = %egress_transport_label(target.transport),
chain_protocol,
ingress_http_version = %ingress_http_version,
outbound_http_version = %outbound_http_version,
upgrade_requested = request_is_upgrade,
egress_host = %target.host,
egress_port = target.port,
+ egress_tls_server_name = %if target.transport == EgressTransport::Https { target.tls_server_name() } else { "" },
"sending request to configured egress destination"
);
@@ -2964,6 +3213,44 @@ async fn send_request_via_egress_target(
.map_err(UpstreamRequestError::from)
}
+async fn connect_https_parent(
+ parent_egress_tls: &ParentEgressTls,
+ stream: TcpStream,
+ use_http2: bool,
+) -> Result, UpstreamRequestError> {
+ let expected_alpn = if use_http2 {
+ b"h2".to_vec()
+ } else {
+ b"http/1.1".to_vec()
+ };
+
+ let server_name =
+ ServerName::try_from(parent_egress_tls.server_name.as_str()).map_err(|err| {
+ UpstreamRequestError::TlsConfig(format!(
+ "proxy.egress.default.tls.server_name must be a valid TLS server name: {err}"
+ ))
+ })?;
+
+ let connector = TlsConnector::from(parent_egress_tls.client_config(use_http2));
+ let tls = connector.connect(server_name, stream).await?;
+ let negotiated = tls.get_ref().1.alpn_protocol().map(|value| value.to_vec());
+ if negotiated.as_deref() != Some(expected_alpn.as_slice()) {
+ return Err(UpstreamRequestError::Protocol(format!(
+ "parent proxy did not negotiate required ALPN protocol {}",
+ String::from_utf8_lossy(&expected_alpn)
+ )));
+ }
+
+ Ok(tls)
+}
+
+fn egress_transport_label(transport: EgressTransport) -> &'static str {
+ match transport {
+ EgressTransport::Http => "http",
+ EgressTransport::Https => "https",
+ }
+}
+
fn egress_dial_host(host: &str) -> &str {
host.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
@@ -3423,7 +3710,7 @@ mod tests {
build_full_url, copy_end_to_end_headers, copy_required_upgrade_headers,
generate_request_id, headers_to_capture_map, interpolate_header_actions,
is_internal_endpoint, is_upgrade_request, strip_hop_by_hop_headers,
- strip_hop_by_hop_headers_preserving_upgrade, tee_body,
+ strip_hop_by_hop_headers_preserving_upgrade, tee_body, HttpListenerMode,
};
use crate::config::{HeaderActionKind, HeaderDirection, HeaderWhen};
use crate::policy::CompiledHeaderAction;
@@ -3665,7 +3952,7 @@ mod tests {
.body(Body::empty())
.expect("request");
- let (url, target) = build_full_url(&req).expect("full url");
+ let (url, target) = build_full_url(&req, HttpListenerMode::Explicit).expect("full url");
assert_eq!(url, "http://example.com/path?q=1");
let target = target.expect("target");
assert_eq!(target.address.as_deref(), Some("example.com"));
@@ -3679,7 +3966,8 @@ mod tests {
.body(Body::empty())
.expect("request");
- let resp = build_full_url(&req).expect_err("userinfo should fail");
+ let resp =
+ build_full_url(&req, HttpListenerMode::Explicit).expect_err("userinfo should fail");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -3691,7 +3979,7 @@ mod tests {
.body(Body::empty())
.expect("request");
- let (url, target) = build_full_url(&req).expect("full url");
+ let (url, target) = build_full_url(&req, HttpListenerMode::Transparent).expect("full url");
assert_eq!(url, "http://example.com:8080/path?q=1");
let target = target.expect("target");
assert_eq!(target.address.as_deref(), Some("example.com"));
@@ -3706,7 +3994,7 @@ mod tests {
.body(Body::empty())
.expect("request");
- let (url, target) = build_full_url(&req).expect("full url");
+ let (url, target) = build_full_url(&req, HttpListenerMode::Transparent).expect("full url");
assert_eq!(url, "http://[::1]:8080/relative/path");
let target = target.expect("target");
assert_eq!(target.address.as_deref(), Some("[::1]"));
@@ -3721,7 +4009,7 @@ mod tests {
.body(Body::empty())
.expect("request");
- let (url, target) = build_full_url(&req).expect("full url");
+ let (url, target) = build_full_url(&req, HttpListenerMode::Transparent).expect("full url");
assert_eq!(url, "http://example.com/relative/path");
let target = target.expect("target");
assert_eq!(target.address.as_deref(), Some("example.com"));
@@ -3735,7 +4023,8 @@ mod tests {
.body(Body::empty())
.expect("request");
- let resp = build_full_url(&req).expect_err("missing host should fail");
+ let resp = build_full_url(&req, HttpListenerMode::Transparent)
+ .expect_err("missing host should fail");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -3747,7 +4036,8 @@ mod tests {
.body(Body::empty())
.expect("request");
- let resp = build_full_url(&req).expect_err("empty host should fail");
+ let resp = build_full_url(&req, HttpListenerMode::Transparent)
+ .expect_err("empty host should fail");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -3762,7 +4052,8 @@ mod tests {
.body(Body::empty())
.expect("request");
- let resp = build_full_url(&req).expect_err("invalid host should fail");
+ let resp = build_full_url(&req, HttpListenerMode::Transparent)
+ .expect_err("invalid host should fail");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
diff --git a/src/proxy/https_transparent.rs b/src/proxy/https_transparent.rs
index 41d9310..6ddca0f 100644
--- a/src/proxy/https_transparent.rs
+++ b/src/proxy/https_transparent.rs
@@ -12,11 +12,12 @@ use hyper::service::service_fn;
use hyper::{Request, Response};
use tokio::net::TcpListener;
use tokio::sync::Semaphore;
+use tokio::task::JoinSet;
use tokio_rustls::TlsAcceptor;
use crate::app::{AppState, SharedAppState};
use crate::capture::{CaptureEndpoint, CaptureMode};
-use crate::config::{ExternalAuthProfileType, PolicyRuleAction};
+use crate::config::{parse_listener_bind, ExternalAuthProfileType, PolicyRuleAction};
use crate::logging::PolicyDecisionLogContext;
use crate::proxy::http::{
build_external_auth_error_response, build_loop_detected_response,
@@ -27,12 +28,8 @@ use crate::proxy::http::{
#[derive(Debug, thiserror::Error)]
pub enum HttpsTransparentError {
- #[error("invalid HTTPS bind address {address}: {source}")]
- BindAddress {
- address: String,
- #[source]
- source: std::net::AddrParseError,
- },
+ #[error("invalid HTTPS bind address {address}: {message}")]
+ BindAddress { address: String, message: String },
#[error("failed to bind HTTPS transparent listener on {addr}: {source}")]
BindListener {
@@ -70,12 +67,18 @@ struct HttpsTransparentConnectionLimits {
impl HttpsTransparentConnectionLimits {
fn from_state(state: &AppState) -> Self {
- let proxy = &state.config.proxy;
+ let listener = state
+ .config
+ .proxy
+ .listeners
+ .transparent_https
+ .as_ref()
+ .expect("transparent_https listener must be configured before startup");
Self {
- handshake_timeout: duration_from_millis(proxy.https_handshake_timeout_ms),
- handshake_timeout_ms: proxy.https_handshake_timeout_ms,
- request_header_timeout: duration_from_millis(proxy.https_request_header_timeout_ms),
- http2_max_concurrent_streams: nonzero_u32(proxy.https_http2_max_concurrent_streams),
+ handshake_timeout: duration_from_millis(listener.handshake_timeout_ms),
+ handshake_timeout_ms: listener.handshake_timeout_ms,
+ request_header_timeout: duration_from_millis(listener.request_header_timeout_ms),
+ http2_max_concurrent_streams: nonzero_u32(listener.http2_max_concurrent_streams),
}
}
}
@@ -109,16 +112,18 @@ where
F: std::future::Future + Send + 'static,
{
let initial = state.load();
- let bind_ip = initial
+ let listener_cfg = initial
.config
.proxy
- .https_bind_address
- .parse()
- .map_err(|e| HttpsTransparentError::BindAddress {
- address: initial.config.proxy.https_bind_address.clone(),
- source: e,
+ .listeners
+ .transparent_https
+ .as_ref()
+ .expect("transparent_https listener must be configured before startup");
+ let addr = parse_listener_bind("proxy.listeners.transparent_https.bind", &listener_cfg.bind)
+ .map_err(|err| HttpsTransparentError::BindAddress {
+ address: listener_cfg.bind.clone(),
+ message: err.to_string(),
})?;
- let addr = SocketAddr::new(bind_ip, initial.config.proxy.https_port);
let listener = StdTcpListener::bind(addr)
.map_err(|e| HttpsTransparentError::BindListener { addr, source: e })?;
@@ -139,27 +144,44 @@ where
F: std::future::Future + Send + 'static,
{
let listener = TcpListener::from_std(listener).map_err(HttpsTransparentError::FromStd)?;
- let connection_limit = state.load().config.proxy.https_max_connections;
+ let connection_limit = state
+ .load()
+ .config
+ .proxy
+ .listeners
+ .transparent_https
+ .as_ref()
+ .expect("transparent_https listener must be configured before startup")
+ .max_connections;
let connection_semaphore = if connection_limit == 0 {
None
} else {
Some(Arc::new(Semaphore::new(connection_limit)))
};
+ let mut connections = JoinSet::new();
+ let (connection_shutdown, _) = crate::proxy::listener::connection_shutdown_channel();
tokio::pin!(shutdown);
- loop {
+ 'accepting: loop {
let connection_permit = match connection_semaphore.as_ref() {
Some(semaphore) => {
let semaphore = semaphore.clone();
- match tokio::select! {
- _ = &mut shutdown => {
- break;
+ loop {
+ match tokio::select! {
+ _ = &mut shutdown => {
+ crate::proxy::listener::signal_connection_shutdown(&connection_shutdown);
+ break 'accepting;
+ }
+ Some(join_result) = connections.join_next(), if !connections.is_empty() => {
+ crate::proxy::listener::log_connection_task_result("transparent_https", join_result);
+ continue;
+ }
+ permit = semaphore.clone().acquire_owned() => permit,
+ } {
+ Ok(permit) => break Some(permit),
+ Err(_) => break 'accepting,
}
- permit = semaphore.acquire_owned() => permit,
- } {
- Ok(permit) => Some(permit),
- Err(_) => break,
}
}
None => None,
@@ -167,18 +189,25 @@ where
tokio::select! {
_ = &mut shutdown => {
- // Stop accepting new connections; in-flight TLS sessions
- // will complete naturally.
+ crate::proxy::listener::signal_connection_shutdown(&connection_shutdown);
break;
}
+ Some(join_result) = connections.join_next(), if !connections.is_empty() => {
+ drop(connection_permit);
+ crate::proxy::listener::log_connection_task_result("transparent_https", join_result);
+ }
accept_res = listener.accept() => {
let (socket, remote_addr) = match accept_res {
Ok(v) => v,
- Err(e) => return Err(HttpsTransparentError::Accept(e)),
+ Err(e) => {
+ crate::proxy::listener::handle_accept_error("transparent_https", e).await;
+ continue;
+ }
};
let state = state.clone();
- tokio::spawn(async move {
+ let connection_shutdown = connection_shutdown.subscribe();
+ connections.spawn(async move {
let state_snapshot = state.load_full();
let limits = HttpsTransparentConnectionLimits::from_state(&state_snapshot);
let tls_acceptor = match state_snapshot
@@ -195,7 +224,12 @@ where
};
if let Err(err) = handle_tls_connection(
- state_snapshot, tls_acceptor, socket, remote_addr, limits,
+ state,
+ tls_acceptor,
+ socket,
+ remote_addr,
+ limits,
+ connection_shutdown,
)
.await
{
@@ -210,15 +244,20 @@ where
}
}
+ let drain_timeout = state.load().config.proxy.shutdown_drain_timeout();
+ crate::proxy::listener::drain_connections("transparent_https", &mut connections, drain_timeout)
+ .await;
+
Ok(())
}
async fn handle_tls_connection(
- state: Arc,
+ state: SharedAppState,
tls_acceptor: TlsAcceptor,
socket: tokio::net::TcpStream,
remote_addr: SocketAddr,
limits: HttpsTransparentConnectionLimits,
+ mut connection_shutdown: tokio::sync::watch::Receiver,
) -> Result<(), HttpsTransparentError> {
let accept = tls_acceptor.accept(socket);
let tls_result = match limits.handshake_timeout {
@@ -285,12 +324,11 @@ async fn handle_tls_connection(
port: Some(remote_addr.port()),
};
- let state_for_svc = state.clone();
let client_for_svc = client.clone();
let client_ip = client_ip_for_policy.clone();
let service = service_fn(move |req: Request| {
- let state = state_for_svc.clone();
+ let state = state.load_full();
let client = client_for_svc.clone();
let client_ip = client_ip.clone();
async move { handle_inner_https_request(state, client, client_ip, req).await }
@@ -305,10 +343,17 @@ async fn handle_tls_connection(
}
http.http2_max_concurrent_streams(limits.http2_max_concurrent_streams);
- http.serve_connection(tls, service)
- .with_upgrades()
- .await
- .map_err(HttpsTransparentError::Hyper)
+ let conn = http.serve_connection(tls, service).with_upgrades();
+ tokio::pin!(conn);
+ let result = tokio::select! {
+ result = &mut conn => result,
+ _ = crate::proxy::listener::wait_for_connection_shutdown(&mut connection_shutdown) => {
+ conn.as_mut().graceful_shutdown();
+ conn.await
+ }
+ };
+
+ result.map_err(HttpsTransparentError::Hyper)
}
async fn handle_inner_https_request(
diff --git a/src/proxy/listener.rs b/src/proxy/listener.rs
new file mode 100644
index 0000000..e1e8368
--- /dev/null
+++ b/src/proxy/listener.rs
@@ -0,0 +1,93 @@
+use std::io::{Error, ErrorKind};
+use std::time::Duration;
+
+use tokio::task::{JoinError, JoinSet};
+
+const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_secs(1);
+
+pub async fn handle_accept_error(listener: &str, err: Error) {
+ match err.kind() {
+ ErrorKind::ConnectionAborted
+ | ErrorKind::ConnectionRefused
+ | ErrorKind::ConnectionReset
+ | ErrorKind::Interrupted
+ | ErrorKind::WouldBlock => {
+ tracing::debug!(
+ listener,
+ error = %err,
+ "recoverable listener accept error"
+ );
+ tokio::task::yield_now().await;
+ }
+ _ => {
+ tracing::warn!(
+ listener,
+ error = %err,
+ backoff_ms = ACCEPT_ERROR_BACKOFF.as_millis(),
+ "listener accept error; backing off before retry"
+ );
+ tokio::time::sleep(ACCEPT_ERROR_BACKOFF).await;
+ }
+ }
+}
+
+pub fn connection_shutdown_channel() -> (
+ tokio::sync::watch::Sender,
+ tokio::sync::watch::Receiver,
+) {
+ tokio::sync::watch::channel(false)
+}
+
+pub fn signal_connection_shutdown(shutdown: &tokio::sync::watch::Sender) {
+ let _ = shutdown.send(true);
+}
+
+pub async fn wait_for_connection_shutdown(shutdown: &mut tokio::sync::watch::Receiver) {
+ if *shutdown.borrow() {
+ return;
+ }
+ while shutdown.changed().await.is_ok() {
+ if *shutdown.borrow() {
+ return;
+ }
+ }
+}
+
+pub fn log_connection_task_result(listener: &str, result: Result<(), JoinError>) {
+ if let Err(err) = result {
+ tracing::debug!(listener, "listener connection task failed: {err}");
+ }
+}
+
+pub async fn drain_connections(
+ listener: &str,
+ connections: &mut JoinSet<()>,
+ timeout: Option,
+) {
+ let drain = async {
+ while let Some(join_result) = connections.join_next().await {
+ log_connection_task_result(listener, join_result);
+ }
+ };
+
+ let Some(timeout) = timeout else {
+ drain.await;
+ return;
+ };
+
+ if tokio::time::timeout(timeout, drain).await.is_ok() {
+ return;
+ }
+
+ let remaining = connections.len();
+ tracing::warn!(
+ listener,
+ remaining_connections = remaining,
+ timeout_ms = timeout.as_millis(),
+ "timed out draining listener connections; aborting remaining tasks"
+ );
+ connections.abort_all();
+ while let Some(join_result) = connections.join_next().await {
+ log_connection_task_result(listener, join_result);
+ }
+}
diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs
index 24f50fd..c6e0c21 100644
--- a/src/proxy/mod.rs
+++ b/src/proxy/mod.rs
@@ -1,4 +1,6 @@
+pub mod explicit_https;
pub mod http;
pub mod https_connect;
pub mod https_transparent;
+pub(crate) mod listener;
pub mod websocket;
diff --git a/tests/capture.rs b/tests/capture.rs
index e4532e5..99c17a5 100644
--- a/tests/capture.rs
+++ b/tests/capture.rs
@@ -155,8 +155,7 @@ fn should_capture_respects_flags_in_integration() {
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"
diff --git a/tests/config_cli.rs b/tests/config_cli.rs
index d2d6b41..d701889 100644
--- a/tests/config_cli.rs
+++ b/tests/config_cli.rs
@@ -15,8 +15,7 @@ fn config_validate_succeeds_for_valid_config() {
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"
@@ -48,8 +47,7 @@ fn config_validate_warns_for_incomplete_redaction_env_marker() {
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"
@@ -97,8 +95,7 @@ fn config_validate_fails_for_wrong_schema_version() {
schema_version = "999"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
[logging]
directory = "logs"
@@ -129,6 +126,9 @@ fn config_validate_fails_for_invalid_loop_protection_header_name() {
r#"
schema_version = "1"
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:8080"
+
[loop_protection]
header_name = "invalid header"
@@ -158,8 +158,7 @@ fn env_overrides_are_applied() {
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"
@@ -178,8 +177,7 @@ default = "deny"
.arg("validate")
.arg("--config")
.arg(file.path())
- .env("PROXY_PORT", "9999")
- .env("PROXY_HOST", "0.0.0.0")
+ .env("PROXY_EXPLICIT_HTTP_BIND", "0.0.0.0:9999")
.env("LOG_LEVEL", "debug");
cmd.assert()
@@ -197,8 +195,7 @@ fn config_validate_fails_for_malformed_toml() {
schema_version =
[proxy]
-bind_address = "127.0.0.1"
-http_port = 8080
+listeners.explicit_http.bind = "127.0.0.1:8080"
"#
)
.expect("write config");
@@ -227,9 +224,7 @@ fn run_fails_when_logging_directory_cannot_initialize() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
-https_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "{}"
diff --git a/tests/config_reload.rs b/tests/config_reload.rs
index 35a3b55..fc6caa8 100644
--- a/tests/config_reload.rs
+++ b/tests/config_reload.rs
@@ -6,7 +6,8 @@ use std::sync::{Arc, Mutex};
use acl_proxy::app::AppState;
use acl_proxy::config::{
- Config, EgressTargetConfig, ExternalAuthProfileConfig, ExternalAuthProfileType,
+ CleartextHttpListenerConfig, Config, EgressTargetConfig, ExternalAuthProfileConfig,
+ ExternalAuthProfileType,
};
use acl_proxy::external_auth::ExternalDecision;
use acl_proxy::proxy::http::run_http_proxy_on_listener;
@@ -127,8 +128,7 @@ fn minimal_config() -> Config {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -219,6 +219,67 @@ async fn send_raw_http_request(addr: SocketAddr, raw_request: &str) -> (String,
(response, status)
}
+async fn read_keep_alive_http_response(stream: &mut tokio::net::TcpStream) -> (String, StatusCode) {
+ let mut buf = Vec::new();
+ while !buf.windows(4).any(|window| window == b"\r\n\r\n") {
+ let byte = stream.read_u8().await.expect("read response byte");
+ buf.push(byte);
+ }
+
+ let header_end = buf
+ .windows(4)
+ .position(|window| window == b"\r\n\r\n")
+ .map(|idx| idx + 4)
+ .expect("header terminator");
+ let headers = String::from_utf8_lossy(&buf[..header_end]);
+ let content_length = headers.lines().find_map(|line| {
+ let (name, value) = line.split_once(':')?;
+ if name.eq_ignore_ascii_case("content-length") {
+ value.trim().parse::().ok()
+ } else {
+ None
+ }
+ });
+ if let Some(content_length) = content_length {
+ while buf.len() - header_end < content_length {
+ let byte = stream.read_u8().await.expect("read response body byte");
+ buf.push(byte);
+ }
+ }
+
+ let response = String::from_utf8_lossy(&buf).to_string();
+ let status_line = response.lines().next().unwrap_or_default();
+ let status = status_line
+ .split_whitespace()
+ .nth(1)
+ .and_then(|code| code.parse::().ok())
+ .and_then(|code| StatusCode::from_u16(code).ok())
+ .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+ (response, status)
+}
+
+fn allow_rule(pattern: String) -> acl_proxy::config::PolicyRuleConfig {
+ acl_proxy::config::PolicyRuleConfig::Direct(acl_proxy::config::PolicyRuleDirectConfig {
+ action: acl_proxy::config::PolicyRuleAction::Allow,
+ pattern: Some(pattern),
+ patterns: None,
+ description: None,
+ methods: None,
+ subnets: Vec::new(),
+ headers_absent: None,
+ headers_match: None,
+ headers_not_match: None,
+ request_timeout_ms: None,
+ allow_upgrades: true,
+ redaction_profile: None,
+ with: None,
+ add_url_enc_variants: None,
+ header_actions: Vec::new(),
+ external_auth_profile: None,
+ rule_id: None,
+ })
+}
+
#[tokio::test(flavor = "multi_thread")]
async fn loop_header_injection_updates_after_reload() {
let (upstream_addr, seen_headers) = start_upstream_echo_server().await;
@@ -416,10 +477,10 @@ fn egress_forwarding_config_updates_after_reload() {
);
let mut updated = config.clone();
- updated.proxy.egress.default = Some(EgressTargetConfig {
- host: "proxy.internal".to_string(),
- port: 9443,
- });
+ updated.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "proxy.internal".to_string(),
+ 9443,
+ ));
AppState::reload_shared_from_config(&shared_state, updated).expect("reload config");
@@ -445,10 +506,10 @@ fn invalid_egress_forwarding_config_is_rejected_on_reload() {
let shared_state = AppState::shared_from_config(config.clone()).expect("app state");
let mut invalid = config.clone();
- invalid.proxy.egress.default = Some(EgressTargetConfig {
- host: "proxy.internal:9443".to_string(),
- port: 9443,
- });
+ invalid.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "proxy.internal:9443".to_string(),
+ 9443,
+ ));
let err = AppState::reload_shared_from_config(&shared_state, invalid)
.expect_err("reload should fail");
@@ -463,6 +524,89 @@ fn invalid_egress_forwarding_config_is_rejected_on_reload() {
);
}
+#[test]
+fn listener_config_changes_are_rejected_on_reload() {
+ let temp = tempfile::tempdir().expect("temp certs dir");
+
+ let mut config = minimal_config();
+ prepare_app_config_for_reload_tests(&mut config, temp.path());
+
+ let shared_state = AppState::shared_from_config(config.clone()).expect("app state");
+
+ let mut updated = config.clone();
+ updated.proxy.listeners.transparent_http = Some(CleartextHttpListenerConfig {
+ bind: "127.0.0.1:8081".to_string(),
+ enable_http2: true,
+ });
+
+ let err = AppState::reload_shared_from_config(&shared_state, updated)
+ .expect_err("listener reload should fail");
+ let msg = format!("{err}");
+ assert!(
+ msg.contains("listener configuration changed"),
+ "unexpected reload error: {msg}"
+ );
+ assert!(
+ shared_state
+ .load()
+ .config
+ .proxy
+ .listeners
+ .transparent_http
+ .is_none(),
+ "failed listener reload should preserve the previous state"
+ );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn policy_reload_applies_to_existing_keep_alive_connection() {
+ let (upstream_addr, _seen_headers) = start_upstream_echo_server().await;
+ let temp = tempfile::tempdir().expect("temp certs dir");
+
+ let mut config = minimal_config();
+ prepare_app_config_for_reload_tests(&mut config, temp.path());
+ let host = format!("{}:{}", upstream_addr.ip(), upstream_addr.port());
+ config.policy.default = acl_proxy::config::PolicyDefaultAction::Deny;
+ config.policy.rules = vec![allow_rule(format!("http://{host}/**"))];
+
+ let shared_state = AppState::shared_from_config(config.clone()).expect("app state");
+ let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
+ proxy_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let shutdown = Arc::new(tokio::sync::Notify::new());
+ let proxy_addr =
+ start_proxy_with_shared_state(shared_state.clone(), proxy_listener, shutdown.clone()).await;
+
+ let mut stream = tokio::net::TcpStream::connect(proxy_addr)
+ .await
+ .expect("connect proxy");
+ let raw_request = format!(
+ "GET http://{host}/reload HTTP/1.1\r\nHost: {host}\r\nConnection: keep-alive\r\n\r\n"
+ );
+ stream
+ .write_all(raw_request.as_bytes())
+ .await
+ .expect("write first request");
+ let (_response, status) = read_keep_alive_http_response(&mut stream).await;
+ assert_eq!(status, StatusCode::OK);
+
+ let mut updated = config.clone();
+ updated.policy.rules.clear();
+ AppState::reload_shared_from_config(&shared_state, updated).expect("reload config");
+
+ let raw_request =
+ format!("GET http://{host}/reload HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
+ stream
+ .write_all(raw_request.as_bytes())
+ .await
+ .expect("write second request");
+ let (_response, status) = read_keep_alive_http_response(&mut stream).await;
+ assert_eq!(status, StatusCode::FORBIDDEN);
+
+ shutdown.notify_waiters();
+}
+
#[tokio::test]
async fn reload_preserves_pending_external_auth_approvals() {
let temp = tempfile::tempdir().expect("temp certs dir");
@@ -795,10 +939,10 @@ async fn egress_forwarding_enable_disable_updates_after_reload() {
assert_eq!(forward_requests.lock().unwrap().len(), 0);
let mut enabled = config.clone();
- enabled.proxy.egress.default = Some(EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ enabled.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
AppState::reload_shared_from_config(&shared_state, enabled).expect("reload config");
let (response, status) = send_raw_http_request(proxy_addr, &raw_request).await;
diff --git a/tests/egress_forwarding_chain.rs b/tests/egress_forwarding_chain.rs
index 8a9ec0d..1c2100c 100644
--- a/tests/egress_forwarding_chain.rs
+++ b/tests/egress_forwarding_chain.rs
@@ -160,8 +160,7 @@ fn minimal_config() -> Config {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -312,10 +311,10 @@ async fn two_proxy_chain_forwards_allowed_requests_end_to_end() {
inner_config.policy.rules = vec![allow_rule(upstream_pattern, Vec::new())];
inner_config.loop_protection.enabled = true;
inner_config.loop_protection.add_header = false;
- inner_config.proxy.egress.default = Some(EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: outer_addr.port(),
- });
+ inner_config.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ outer_addr.port(),
+ ));
let (inner_addr, _inner_temp_dir) = start_proxy_with_config(inner_config).await;
let raw_request = format!(
@@ -356,10 +355,10 @@ async fn two_proxy_chain_preserves_http2_on_inner_to_outer_hop() {
inner_config.policy.rules = vec![allow_rule(upstream_pattern, Vec::new())];
inner_config.loop_protection.enabled = true;
inner_config.loop_protection.add_header = false;
- inner_config.proxy.egress.default = Some(EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: outer_addr.port(),
- });
+ inner_config.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ outer_addr.port(),
+ ));
let (inner_addr, _inner_temp_dir) = start_proxy_with_config(inner_config).await;
let (body, status) = send_h2c_http_request(inner_addr, &upstream_url).await;
@@ -367,11 +366,13 @@ async fn two_proxy_chain_preserves_http2_on_inner_to_outer_hop() {
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "outer-upstream-h2");
- let requests = seen_upstream.lock().unwrap();
- let request = requests
- .first()
- .expect("upstream should receive chained request");
- assert_eq!(request.uri, "/h2");
+ {
+ let requests = seen_upstream.lock().unwrap();
+ let request = requests
+ .first()
+ .expect("upstream should receive chained request");
+ assert_eq!(request.uri, "/h2");
+ }
let capture_dir = outer_temp_dir.path().join("captures");
for _ in 0..20 {
@@ -443,10 +444,10 @@ async fn two_proxy_chain_preserves_http1_upgrade_tunneling() {
inner_config.policy.rules = vec![allow_rule(upstream_pattern, Vec::new())];
inner_config.loop_protection.enabled = true;
inner_config.loop_protection.add_header = false;
- inner_config.proxy.egress.default = Some(EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: outer_addr.port(),
- });
+ inner_config.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ outer_addr.port(),
+ ));
let (inner_addr, _inner_temp_dir) = start_proxy_with_config(inner_config).await;
let mut stream = tokio::net::TcpStream::connect(inner_addr)
@@ -523,10 +524,10 @@ async fn h2_chain_hop_to_http1_only_egress_returns_bad_gateway_without_downgrade
)];
inner_config.loop_protection.enabled = true;
inner_config.loop_protection.add_header = false;
- inner_config.proxy.egress.default = Some(EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: probe_addr.port(),
- });
+ inner_config.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ probe_addr.port(),
+ ));
let (inner_addr, _inner_temp_dir) = start_proxy_with_config(inner_config).await;
let (_body, status) =
@@ -583,10 +584,10 @@ async fn header_action_trust_chain_removes_then_sets_identity_header() {
],
)];
inner_config.loop_protection.add_header = false;
- inner_config.proxy.egress.default = Some(EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: outer_addr.port(),
- });
+ inner_config.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ outer_addr.port(),
+ ));
let (inner_addr, _inner_temp_dir) = start_proxy_with_config(inner_config).await;
let raw_request = format!(
@@ -625,10 +626,10 @@ async fn unavailable_egress_destination_returns_bad_gateway() {
"http://example.invalid/**".to_string(),
Vec::new(),
)];
- config.proxy.egress.default = Some(EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: 1,
- });
+ config.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ 1,
+ ));
let (proxy_addr, _temp_dir) = start_proxy_with_config(config).await;
let raw_request =
@@ -661,10 +662,10 @@ async fn inner_proxy_can_disable_loop_header_injection_for_chained_deployments()
inner_config.policy.rules = vec![allow_rule(upstream_pattern, Vec::new())];
inner_config.loop_protection.enabled = true;
inner_config.loop_protection.add_header = false;
- inner_config.proxy.egress.default = Some(EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: outer_addr.port(),
- });
+ inner_config.proxy.egress.default = Some(EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ outer_addr.port(),
+ ));
let (inner_addr, _inner_temp_dir) = start_proxy_with_config(inner_config).await;
let raw_request = format!(
diff --git a/tests/logging.rs b/tests/logging.rs
index 7cf8da0..4cdebf0 100644
--- a/tests/logging.rs
+++ b/tests/logging.rs
@@ -14,8 +14,7 @@ fn config_validate_fails_for_invalid_logging_level() {
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"
diff --git a/tests/policy_cli.rs b/tests/policy_cli.rs
index 358f75e..51f6b5b 100644
--- a/tests/policy_cli.rs
+++ b/tests/policy_cli.rs
@@ -55,8 +55,7 @@ fn config_validate_succeeds_for_policy_with_macros_and_rulesets() {
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"
@@ -98,8 +97,7 @@ fn config_validate_fails_for_missing_macro_in_ruleset() {
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"
@@ -138,8 +136,7 @@ fn config_validate_fails_for_missing_macro_in_direct_rule() {
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"
@@ -175,8 +172,7 @@ fn policy_dump_defaults_to_json_on_non_tty() {
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"
@@ -237,8 +233,7 @@ fn policy_dump_masks_env_sourced_header_action_values() {
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"
@@ -286,8 +281,7 @@ fn policy_dump_prints_config_load_warnings_to_stderr() {
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"
@@ -334,8 +328,7 @@ fn policy_dump_table_format_contains_expected_fields() {
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"
@@ -378,8 +371,7 @@ fn policy_dump_json_expands_patterns_to_singular_effective_rules() {
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"
@@ -432,8 +424,7 @@ fn policy_dump_json_renders_delegate_action_and_external_auth_profile() {
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"
@@ -485,8 +476,7 @@ fn policy_dump_table_expands_patterns_to_rows() {
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"
@@ -531,8 +521,7 @@ fn policy_dump_json_includes_headers_absent() {
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"
@@ -580,8 +569,7 @@ fn policy_dump_table_includes_headers_absent_column() {
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"
@@ -623,8 +611,7 @@ fn policy_dump_json_includes_headers_match_values() {
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"
@@ -667,8 +654,7 @@ fn policy_dump_table_includes_headers_match_column_values() {
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"
@@ -710,8 +696,7 @@ fn policy_dump_json_includes_headers_not_match_values() {
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"
@@ -756,8 +741,7 @@ fn policy_dump_table_includes_headers_not_match_column_values() {
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"
diff --git a/tests/proxy_explicit_https.rs b/tests/proxy_explicit_https.rs
new file mode 100644
index 0000000..9eed65e
--- /dev/null
+++ b/tests/proxy_explicit_https.rs
@@ -0,0 +1,1267 @@
+use std::io::Cursor;
+use std::net::{SocketAddr, TcpListener as StdTcpListener};
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+
+use acl_proxy::app::AppState;
+use acl_proxy::config::Config;
+use acl_proxy::proxy::explicit_https::{
+ build_static_tls_acceptor, run_explicit_https_proxy_on_listener,
+};
+use acl_proxy::proxy::http::run_http_proxy_on_listener;
+use h2::client as h2_client;
+use http::header::{HeaderValue, CONNECTION, UPGRADE};
+use http::StatusCode;
+use hyper::server::conn::Http;
+use hyper::service::{make_service_fn, service_fn};
+use hyper::{Body, Request, Response, Server};
+use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
+use rustls::client::ServerName;
+use rustls::{
+ Certificate as RustlsCertificate, ClientConfig, PrivateKey, RootCertStore, ServerConfig,
+};
+use tempfile::TempDir;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio_rustls::{TlsAcceptor, TlsConnector};
+
+#[derive(Clone, Debug)]
+struct SeenRequest {
+ uri: String,
+ headers: hyper::HeaderMap,
+}
+
+async fn start_http_echo_server(body: &'static str) -> (SocketAddr, Arc>>) {
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind upstream");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking upstream");
+ let addr = listener.local_addr().expect("upstream addr");
+
+ let seen_requests: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let seen_requests_clone = seen_requests.clone();
+
+ let make_svc = make_service_fn(move |_conn| {
+ let seen_requests = seen_requests_clone.clone();
+ async move {
+ Ok::<_, hyper::Error>(service_fn(move |req: Request| {
+ let seen_requests = seen_requests.clone();
+ async move {
+ seen_requests.lock().unwrap().push(SeenRequest {
+ uri: req.uri().to_string(),
+ headers: req.headers().clone(),
+ });
+ Ok::<_, hyper::Error>(Response::new(Body::from(body)))
+ }
+ }))
+ }
+ });
+
+ tokio::spawn(
+ Server::from_tcp(listener)
+ .expect("server from tcp")
+ .serve(make_svc),
+ );
+
+ (addr, seen_requests)
+}
+
+async fn start_https_echo_server(body: &'static str) -> SocketAddr {
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind HTTPS upstream");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking HTTPS upstream");
+ let addr = listener.local_addr().expect("HTTPS upstream addr");
+
+ let mut params = CertificateParams::new(vec![addr.ip().to_string()]).expect("params");
+ let mut dn = DistinguishedName::new();
+ dn.push(DnType::CommonName, addr.ip().to_string());
+ params.distinguished_name = dn;
+ let key = KeyPair::generate().expect("generate upstream key");
+ let cert = params.self_signed(&key).expect("self-signed upstream cert");
+
+ let mut tls_config = ServerConfig::builder()
+ .with_safe_defaults()
+ .with_no_client_auth()
+ .with_single_cert(
+ vec![RustlsCertificate(cert.der().to_vec())],
+ PrivateKey(key.serialize_der()),
+ )
+ .expect("upstream server config");
+ tls_config.alpn_protocols = vec![b"http/1.1".to_vec()];
+ let tls_acceptor = TlsAcceptor::from(Arc::new(tls_config));
+
+ tokio::spawn(async move {
+ let listener = tokio::net::TcpListener::from_std(listener).expect("tokio HTTPS listener");
+ loop {
+ let (socket, _) = match listener.accept().await {
+ Ok(v) => v,
+ Err(_) => break,
+ };
+ let tls_acceptor = tls_acceptor.clone();
+ tokio::spawn(async move {
+ let tls = match tls_acceptor.accept(socket).await {
+ Ok(tls) => tls,
+ Err(_) => return,
+ };
+ let service = service_fn(move |_req: Request| async move {
+ Ok::<_, hyper::Error>(Response::new(Body::from(body)))
+ });
+ let _ = Http::new()
+ .http1_keep_alive(false)
+ .serve_connection(tls, service)
+ .await;
+ });
+ }
+ });
+
+ addr
+}
+
+async fn start_upstream_websocket_echo_server() -> SocketAddr {
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind websocket upstream");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking websocket upstream");
+ let addr = listener.local_addr().expect("websocket upstream addr");
+
+ tokio::spawn(async move {
+ let listener =
+ tokio::net::TcpListener::from_std(listener).expect("tokio websocket listener");
+ loop {
+ let (socket, _) = match listener.accept().await {
+ Ok(v) => v,
+ Err(_) => break,
+ };
+
+ tokio::spawn(async move {
+ let service = service_fn(|mut req: Request| async move {
+ let on_upgrade = hyper::upgrade::on(&mut req);
+ tokio::spawn(async move {
+ let mut upgraded = match on_upgrade.await {
+ Ok(stream) => stream,
+ Err(_) => return,
+ };
+
+ let mut buf = [0_u8; 1024];
+ loop {
+ let n = match upgraded.read(&mut buf).await {
+ Ok(0) => break,
+ Ok(n) => n,
+ Err(_) => break,
+ };
+ if upgraded.write_all(&buf[..n]).await.is_err() {
+ break;
+ }
+ }
+ });
+
+ let mut resp = Response::new(Body::empty());
+ *resp.status_mut() = StatusCode::SWITCHING_PROTOCOLS;
+ resp.headers_mut()
+ .insert(CONNECTION, HeaderValue::from_static("Upgrade"));
+ resp.headers_mut()
+ .insert(UPGRADE, HeaderValue::from_static("websocket"));
+ Ok::<_, hyper::Error>(resp)
+ });
+
+ let _ = Http::new()
+ .http1_keep_alive(false)
+ .serve_connection(socket, service)
+ .with_upgrades()
+ .await;
+ });
+ }
+ });
+
+ addr
+}
+
+fn write_proxy_identity(temp_dir: &TempDir) -> (PathBuf, PathBuf) {
+ let mut params = CertificateParams::new(vec!["127.0.0.1".to_string()]).expect("params");
+ let mut dn = DistinguishedName::new();
+ dn.push(DnType::CommonName, "127.0.0.1");
+ params.distinguished_name = dn;
+
+ let key = KeyPair::generate().expect("generate proxy key");
+ let cert = params.self_signed(&key).expect("self-signed proxy cert");
+
+ let cert_path = temp_dir.path().join("proxy-cert.pem");
+ let key_path = temp_dir.path().join("proxy-key.pem");
+ std::fs::write(&cert_path, cert.pem()).expect("write proxy cert");
+ std::fs::write(&key_path, key.serialize_pem()).expect("write proxy key");
+
+ (cert_path, key_path)
+}
+
+fn explicit_https_config(cert_path: &Path, key_path: &Path, allow_pattern: &str) -> Config {
+ let toml = format!(
+ r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_https.bind = "127.0.0.1:1"
+listeners.explicit_https.cert_chain_path = {cert_path:?}
+listeners.explicit_https.key_path = {key_path:?}
+
+[logging]
+level = "info"
+
+[capture]
+allowed_request = false
+allowed_response = false
+denied_request = false
+denied_response = false
+directory = "logs-capture"
+
+[tls]
+verify_upstream = false
+
+[policy]
+default = "deny"
+
+[[policy.rules]]
+action = "allow"
+pattern = {allow_pattern:?}
+"#,
+ cert_path = cert_path.display().to_string(),
+ key_path = key_path.display().to_string(),
+ );
+
+ toml::from_str(&toml).expect("parse explicit https config")
+}
+
+fn explicit_http_config_with_https_parent(
+ parent_addr: SocketAddr,
+ ca_cert_path: &Path,
+ allow_pattern: &str,
+) -> Config {
+ let tls_block = format!(
+ r#"[proxy.egress.default.tls]
+trust = "custom_ca"
+server_name = "127.0.0.1"
+ca_cert_path = {ca_cert_path:?}
+"#,
+ ca_cert_path = ca_cert_path.display().to_string(),
+ );
+ explicit_http_config_with_https_parent_tls_block(parent_addr, Some(&tls_block), allow_pattern)
+}
+
+fn explicit_http_config_with_https_parent_tls_block(
+ parent_addr: SocketAddr,
+ tls_block: Option<&str>,
+ allow_pattern: &str,
+) -> Config {
+ let toml = format!(
+ r#"
+schema_version = "1"
+
+[proxy]
+listeners.explicit_http.bind = "127.0.0.1:1"
+
+[proxy.egress.default]
+type = "explicit_proxy"
+transport = "https"
+host = "127.0.0.1"
+port = {parent_port}
+
+{tls_block}
+
+[logging]
+level = "info"
+
+[capture]
+allowed_request = false
+allowed_response = false
+denied_request = false
+denied_response = false
+directory = "logs-capture"
+
+[tls]
+verify_upstream = false
+
+[policy]
+default = "deny"
+
+[[policy.rules]]
+action = "allow"
+pattern = {allow_pattern:?}
+"#,
+ parent_port = parent_addr.port(),
+ tls_block = tls_block.unwrap_or(""),
+ );
+
+ toml::from_str(&toml).expect("parse explicit http config")
+}
+
+async fn start_explicit_https_proxy(
+ mut config: Config,
+ listener: StdTcpListener,
+) -> (SocketAddr, TempDir) {
+ let addr = listener.local_addr().expect("proxy addr");
+ let temp_dir = TempDir::new().expect("temp dir");
+ config.logging.directory = None;
+ config.capture.directory = temp_dir
+ .path()
+ .join("captures")
+ .to_string_lossy()
+ .to_string();
+ config.certificates.certs_dir = temp_dir.path().join("certs").to_string_lossy().to_string();
+
+ let listener_cfg = config
+ .proxy
+ .listeners
+ .explicit_https
+ .as_ref()
+ .expect("explicit https listener")
+ .clone();
+ let tls_acceptor = build_static_tls_acceptor(&listener_cfg).expect("static TLS acceptor");
+ let state = AppState::shared_from_config(config).expect("app state");
+
+ tokio::spawn(async move {
+ let _ = run_explicit_https_proxy_on_listener(
+ state,
+ listener,
+ tls_acceptor,
+ listener_cfg,
+ std::future::pending(),
+ )
+ .await
+ .map_err(|e| eprintln!("explicit HTTPS proxy server on {addr} exited: {e}"));
+ });
+
+ (addr, temp_dir)
+}
+
+async fn start_explicit_http_proxy(mut config: Config) -> (SocketAddr, TempDir) {
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let addr = listener.local_addr().expect("proxy addr");
+
+ let temp_dir = TempDir::new().expect("temp dir");
+ config.logging.directory = None;
+ config.capture.directory = temp_dir
+ .path()
+ .join("captures")
+ .to_string_lossy()
+ .to_string();
+ config.certificates.certs_dir = temp_dir.path().join("certs").to_string_lossy().to_string();
+ let state = AppState::shared_from_config(config).expect("app state");
+
+ tokio::spawn(async move {
+ let _ = run_http_proxy_on_listener(state, listener, std::future::pending())
+ .await
+ .map_err(|e| eprintln!("explicit HTTP proxy server on {addr} exited: {e}"));
+ });
+
+ (addr, temp_dir)
+}
+
+async fn send_explicit_https_request(
+ proxy_addr: SocketAddr,
+ ca_cert_path: &Path,
+ absolute_url: &str,
+) -> (StatusCode, String) {
+ let mut tls = open_explicit_https_proxy_tls(proxy_addr, ca_cert_path).await;
+
+ let request = format!(
+ "GET {absolute_url} HTTP/1.1\r\nHost: ignored-by-explicit-proxy.test\r\nConnection: close\r\n\r\n"
+ );
+ tls.write_all(request.as_bytes())
+ .await
+ .expect("write request");
+
+ let mut buf = Vec::new();
+ tls.read_to_end(&mut buf).await.expect("read response");
+ let response = String::from_utf8_lossy(&buf).to_string();
+ let status_line = response.lines().next().unwrap_or_default();
+ let status = status_line
+ .split_whitespace()
+ .nth(1)
+ .and_then(|code| code.parse::().ok())
+ .and_then(|code| StatusCode::from_u16(code).ok())
+ .expect("response status");
+ (status, response)
+}
+
+async fn open_explicit_https_proxy_tls(
+ proxy_addr: SocketAddr,
+ ca_cert_path: &Path,
+) -> tokio_rustls::client::TlsStream {
+ open_explicit_https_proxy_tls_with_alpn(proxy_addr, ca_cert_path, vec![b"http/1.1".to_vec()])
+ .await
+}
+
+async fn open_explicit_https_proxy_tls_with_alpn(
+ proxy_addr: SocketAddr,
+ ca_cert_path: &Path,
+ alpn_protocols: Vec>,
+) -> tokio_rustls::client::TlsStream {
+ let stream = tokio::net::TcpStream::connect(proxy_addr)
+ .await
+ .expect("connect proxy");
+
+ let ca_pem = std::fs::read(ca_cert_path).expect("read proxy cert");
+ let mut reader = Cursor::new(ca_pem);
+ let mut roots = RootCertStore::empty();
+ for cert in rustls_pemfile::certs(&mut reader).expect("parse proxy cert") {
+ roots
+ .add(&RustlsCertificate(cert.to_vec()))
+ .expect("add proxy cert root");
+ }
+
+ let mut config = ClientConfig::builder()
+ .with_safe_defaults()
+ .with_root_certificates(roots)
+ .with_no_client_auth();
+ config.alpn_protocols = alpn_protocols;
+
+ let connector = TlsConnector::from(Arc::new(config));
+ let server_name = ServerName::try_from("127.0.0.1").expect("server name");
+ connector
+ .connect(server_name, stream)
+ .await
+ .expect("tls connect")
+}
+
+async fn send_https_via_explicit_https_connect(
+ proxy_addr: SocketAddr,
+ proxy_cert_path: &Path,
+ mitm_ca_cert_path: &Path,
+ target_host: &str,
+ path: &str,
+) -> (StatusCode, String) {
+ let mut outer_tls = open_explicit_https_proxy_tls(proxy_addr, proxy_cert_path).await;
+ let connect_req = format!(
+ "CONNECT {target_host} HTTP/1.1\r\nHost: {target_host}\r\nConnection: keep-alive\r\n\r\n"
+ );
+ outer_tls
+ .write_all(connect_req.as_bytes())
+ .await
+ .expect("write CONNECT");
+
+ let (_head, connect_status) = read_http_response_head(&mut outer_tls)
+ .await
+ .expect("read CONNECT response");
+ if connect_status != StatusCode::OK {
+ return (connect_status, String::new());
+ }
+
+ let ca_pem = std::fs::read(mitm_ca_cert_path).expect("read MITM CA cert");
+ let mut reader = Cursor::new(ca_pem);
+ let mut roots = RootCertStore::empty();
+ for cert in rustls_pemfile::certs(&mut reader).expect("parse MITM CA cert") {
+ roots
+ .add(&RustlsCertificate(cert.to_vec()))
+ .expect("add MITM CA cert root");
+ }
+
+ let config = ClientConfig::builder()
+ .with_safe_defaults()
+ .with_root_certificates(roots)
+ .with_no_client_auth();
+ let connector = TlsConnector::from(Arc::new(config));
+ let host_only = target_host.split(':').next().expect("target host name");
+ let server_name = ServerName::try_from(host_only).expect("target server name");
+ let mut inner_tls = connector
+ .connect(server_name, outer_tls)
+ .await
+ .expect("inner TLS connect");
+
+ let request =
+ format!("GET {path} HTTP/1.1\r\nHost: {target_host}\r\nConnection: close\r\n\r\n");
+ inner_tls
+ .write_all(request.as_bytes())
+ .await
+ .expect("write inner HTTPS request");
+ let mut buf = Vec::new();
+ inner_tls
+ .read_to_end(&mut buf)
+ .await
+ .expect("read inner HTTPS response");
+ parse_http_response(&buf)
+}
+
+async fn send_https_via_explicit_http_connect(
+ proxy_addr: SocketAddr,
+ mitm_ca_cert_path: &Path,
+ target_host: &str,
+ path: &str,
+) -> (StatusCode, String) {
+ let mut stream = tokio::net::TcpStream::connect(proxy_addr)
+ .await
+ .expect("connect proxy");
+ let connect_req = format!(
+ "CONNECT {target_host} HTTP/1.1\r\nHost: {target_host}\r\nConnection: keep-alive\r\n\r\n"
+ );
+ stream
+ .write_all(connect_req.as_bytes())
+ .await
+ .expect("write CONNECT");
+
+ let (_head, connect_status) = read_http_response_head(&mut stream)
+ .await
+ .expect("read CONNECT response");
+ if connect_status != StatusCode::OK {
+ return (connect_status, String::new());
+ }
+
+ let ca_pem = std::fs::read(mitm_ca_cert_path).expect("read MITM CA cert");
+ let mut reader = Cursor::new(ca_pem);
+ let mut roots = RootCertStore::empty();
+ for cert in rustls_pemfile::certs(&mut reader).expect("parse MITM CA cert") {
+ roots
+ .add(&RustlsCertificate(cert.to_vec()))
+ .expect("add MITM CA cert root");
+ }
+
+ let config = ClientConfig::builder()
+ .with_safe_defaults()
+ .with_root_certificates(roots)
+ .with_no_client_auth();
+ let connector = TlsConnector::from(Arc::new(config));
+ let host_only = target_host.split(':').next().expect("target host name");
+ let server_name = ServerName::try_from(host_only).expect("target server name");
+ let mut inner_tls = connector
+ .connect(server_name, stream)
+ .await
+ .expect("inner TLS connect");
+
+ let request =
+ format!("GET {path} HTTP/1.1\r\nHost: {target_host}\r\nConnection: close\r\n\r\n");
+ inner_tls
+ .write_all(request.as_bytes())
+ .await
+ .expect("write inner HTTPS request");
+ let mut buf = Vec::new();
+ inner_tls
+ .read_to_end(&mut buf)
+ .await
+ .expect("read inner HTTPS response");
+ parse_http_response(&buf)
+}
+
+async fn send_h2c_http_request(addr: SocketAddr, uri: &str) -> (String, StatusCode) {
+ let stream = tokio::net::TcpStream::connect(addr)
+ .await
+ .expect("connect h2c proxy");
+ let (send_request, connection) = h2_client::handshake(stream).await.expect("h2 handshake");
+ tokio::spawn(async move {
+ if let Err(err) = connection.await {
+ eprintln!("h2 connection error: {err}");
+ }
+ });
+
+ let mut send_request = send_request.ready().await.expect("h2 ready");
+ let request = http::Request::builder()
+ .method("GET")
+ .uri(uri)
+ .body(())
+ .expect("build h2 request");
+ let (response_fut, _send_stream) = send_request
+ .send_request(request, true)
+ .expect("send h2 request");
+ let response = response_fut.await.expect("await h2 response");
+ let status = response.status();
+ let mut body = response.into_body();
+ let mut bytes = Vec::new();
+ while let Some(chunk) = body.data().await {
+ let chunk = chunk.expect("h2 response chunk");
+ bytes.extend_from_slice(&chunk);
+ }
+ (String::from_utf8_lossy(&bytes).to_string(), status)
+}
+
+async fn send_h2_request_via_explicit_https(
+ proxy_addr: SocketAddr,
+ proxy_cert_path: &Path,
+ method: &str,
+ uri: &str,
+) -> StatusCode {
+ let tls = open_explicit_https_proxy_tls_with_alpn(
+ proxy_addr,
+ proxy_cert_path,
+ vec![b"h2".to_vec(), b"http/1.1".to_vec()],
+ )
+ .await;
+ assert_eq!(tls.get_ref().1.alpn_protocol(), Some(&b"h2"[..]));
+
+ let (send_request, connection) = h2_client::handshake(tls).await.expect("h2 handshake");
+ tokio::spawn(async move {
+ if let Err(err) = connection.await {
+ eprintln!("h2 explicit HTTPS connection error: {err}");
+ }
+ });
+
+ let mut send_request = send_request.ready().await.expect("h2 ready");
+ let request = http::Request::builder()
+ .method(method)
+ .uri(uri)
+ .body(())
+ .expect("build h2 request");
+ let (response_fut, _send_stream) = send_request
+ .send_request(request, true)
+ .expect("send h2 request");
+ response_fut.await.expect("await h2 response").status()
+}
+
+async fn read_http_response_head(reader: &mut R) -> std::io::Result<(String, StatusCode)>
+where
+ R: tokio::io::AsyncRead + Unpin,
+{
+ let mut buf = Vec::new();
+ let mut byte = [0_u8; 1];
+ loop {
+ reader.read_exact(&mut byte).await?;
+ buf.push(byte[0]);
+ if buf.ends_with(b"\r\n\r\n") {
+ break;
+ }
+ }
+
+ let head = String::from_utf8_lossy(&buf).to_string();
+ let status = head
+ .lines()
+ .next()
+ .and_then(|line| line.split_whitespace().nth(1))
+ .and_then(|code| code.parse::().ok())
+ .and_then(|code| StatusCode::from_u16(code).ok())
+ .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+ Ok((head, status))
+}
+
+async fn read_until_contains(reader: &mut R, needle: &[u8]) -> Vec
+where
+ R: tokio::io::AsyncRead + Unpin,
+{
+ let mut buf = Vec::new();
+ let mut tmp = [0_u8; 1024];
+ loop {
+ let n = tokio::time::timeout(tokio::time::Duration::from_secs(2), reader.read(&mut tmp))
+ .await
+ .expect("timed out waiting for response")
+ .expect("read response");
+ assert!(n > 0, "unexpected EOF before response contained needle");
+ buf.extend_from_slice(&tmp[..n]);
+ if buf.windows(needle.len()).any(|window| window == needle) {
+ return buf;
+ }
+ }
+}
+
+fn parse_http_response(buf: &[u8]) -> (StatusCode, String) {
+ let response = String::from_utf8_lossy(buf).to_string();
+ let status = response
+ .lines()
+ .next()
+ .and_then(|line| line.split_whitespace().nth(1))
+ .and_then(|code| code.parse::().ok())
+ .and_then(|code| StatusCode::from_u16(code).ok())
+ .expect("response status");
+ let body = response
+ .split("\r\n\r\n")
+ .nth(1)
+ .unwrap_or_default()
+ .to_string();
+ (status, body)
+}
+
+async fn send_raw_http_request(proxy_addr: SocketAddr, absolute_url: &str) -> (StatusCode, String) {
+ let mut stream = tokio::net::TcpStream::connect(proxy_addr)
+ .await
+ .expect("connect proxy");
+ let request = format!(
+ "GET {absolute_url} HTTP/1.1\r\nHost: ignored-by-explicit-proxy.test\r\nConnection: close\r\n\r\n"
+ );
+ stream
+ .write_all(request.as_bytes())
+ .await
+ .expect("write request");
+
+ let mut buf = Vec::new();
+ stream.read_to_end(&mut buf).await.expect("read response");
+ let response = String::from_utf8_lossy(&buf).to_string();
+ let status_line = response.lines().next().unwrap_or_default();
+ let status = status_line
+ .split_whitespace()
+ .nth(1)
+ .and_then(|code| code.parse::().ok())
+ .and_then(|code| StatusCode::from_u16(code).ok())
+ .expect("response status");
+ (status, response)
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn explicit_https_listener_accepts_absolute_form_requests() {
+ let (upstream_addr, seen_requests) = start_http_echo_server("explicit https ok").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+ let config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind explicit https proxy");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let (proxy_addr, _proxy_temp_dir) = start_explicit_https_proxy(config, listener).await;
+
+ let absolute_url = format!("http://{}:{}/ok", upstream_addr.ip(), upstream_addr.port());
+ let (status, response) =
+ send_explicit_https_request(proxy_addr, &cert_path, &absolute_url).await;
+
+ assert_eq!(status, StatusCode::OK);
+ assert!(
+ response.contains("\r\n\r\nexplicit https ok"),
+ "unexpected response: {response}"
+ );
+
+ let seen = seen_requests.lock().unwrap();
+ let request = seen.first().expect("upstream request");
+ assert_eq!(request.uri, "/ok");
+ assert_eq!(
+ request
+ .headers
+ .get("host")
+ .and_then(|value| value.to_str().ok()),
+ Some(format!("{}:{}", upstream_addr.ip(), upstream_addr.port()).as_str())
+ );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn explicit_https_listener_defaults_to_http1_alpn() {
+ let (upstream_addr, _seen_requests) = start_http_echo_server("unused").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+ let config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind explicit https proxy");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let (proxy_addr, _proxy_temp_dir) = start_explicit_https_proxy(config, listener).await;
+
+ let tls = open_explicit_https_proxy_tls_with_alpn(
+ proxy_addr,
+ &cert_path,
+ vec![b"h2".to_vec(), b"http/1.1".to_vec()],
+ )
+ .await;
+
+ assert_eq!(tls.get_ref().1.alpn_protocol(), Some(&b"http/1.1"[..]));
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn explicit_https_h2_connect_returns_501() {
+ let (upstream_addr, _seen_requests) = start_http_echo_server("unused").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+ let mut config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+ config
+ .proxy
+ .listeners
+ .explicit_https
+ .as_mut()
+ .expect("explicit HTTPS listener")
+ .enable_http2 = true;
+
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind explicit https proxy");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let (proxy_addr, _proxy_temp_dir) = start_explicit_https_proxy(config, listener).await;
+
+ let status = send_h2_request_via_explicit_https(
+ proxy_addr,
+ &cert_path,
+ "CONNECT",
+ "connect-target.test:443",
+ )
+ .await;
+
+ assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn explicit_https_listener_supports_connect_mitm_over_http1() {
+ let upstream_addr = start_https_echo_server("explicit https connect ok").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("https://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+ let config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind explicit https proxy");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let (proxy_addr, proxy_temp_dir) = start_explicit_https_proxy(config, listener).await;
+
+ let target_host = format!("{}:{}", upstream_addr.ip(), upstream_addr.port());
+ let mitm_ca_cert_path = proxy_temp_dir.path().join("certs").join("ca-cert.pem");
+ let (status, body) = send_https_via_explicit_https_connect(
+ proxy_addr,
+ &cert_path,
+ &mitm_ca_cert_path,
+ &target_host,
+ "/ok",
+ )
+ .await;
+
+ assert_eq!(status, StatusCode::OK);
+ assert!(
+ body.contains("explicit https connect ok"),
+ "unexpected response body: {body}"
+ );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn explicit_https_listener_tunnels_websocket_upgrades() {
+ let upstream_addr = start_upstream_websocket_echo_server().await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+ let config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind explicit https proxy");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let (proxy_addr, _proxy_temp_dir) = start_explicit_https_proxy(config, listener).await;
+
+ let mut tls = open_explicit_https_proxy_tls(proxy_addr, &cert_path).await;
+ let upstream_url = format!("http://{}:{}/ws", upstream_addr.ip(), upstream_addr.port());
+ let upstream_host = format!("{}:{}", upstream_addr.ip(), upstream_addr.port());
+ let request = format!(
+ concat!(
+ "GET {url} HTTP/1.1\r\n",
+ "Host: {host}\r\n",
+ "Connection: Upgrade\r\n",
+ "Upgrade: websocket\r\n",
+ "Sec-WebSocket-Version: 13\r\n",
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n",
+ "\r\n"
+ ),
+ url = upstream_url,
+ host = upstream_host
+ );
+ tls.write_all(request.as_bytes())
+ .await
+ .expect("write websocket upgrade request");
+
+ let (_head, status) = read_http_response_head(&mut tls)
+ .await
+ .expect("read websocket response");
+ assert_eq!(status, StatusCode::SWITCHING_PROTOCOLS);
+
+ let payload = b"explicit-https-websocket";
+ tls.write_all(payload)
+ .await
+ .expect("write tunneled payload");
+ let mut echoed = vec![0_u8; payload.len()];
+ tokio::time::timeout(
+ tokio::time::Duration::from_secs(2),
+ tls.read_exact(&mut echoed),
+ )
+ .await
+ .expect("timed out waiting for echoed payload")
+ .expect("read echoed payload");
+
+ assert_eq!(echoed.as_slice(), payload);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn explicit_https_listener_shutdown_closes_idle_keep_alive_connection() {
+ let (upstream_addr, _seen_requests) = start_http_echo_server("shutdown ok").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+ let mut config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+
+ let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind explicit https proxy");
+ listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let proxy_addr = listener.local_addr().expect("proxy addr");
+
+ let temp_dir = TempDir::new().expect("temp dir");
+ config.logging.directory = None;
+ config.capture.directory = temp_dir
+ .path()
+ .join("captures")
+ .to_string_lossy()
+ .to_string();
+ config.certificates.certs_dir = temp_dir.path().join("certs").to_string_lossy().to_string();
+ let listener_cfg = config
+ .proxy
+ .listeners
+ .explicit_https
+ .as_ref()
+ .expect("explicit https listener")
+ .clone();
+ let tls_acceptor = build_static_tls_acceptor(&listener_cfg).expect("static TLS acceptor");
+ let state = AppState::shared_from_config(config).expect("app state");
+
+ let shutdown = Arc::new(tokio::sync::Notify::new());
+ let shutdown_for_task = shutdown.clone();
+ let server_task = tokio::spawn(async move {
+ run_explicit_https_proxy_on_listener(
+ state,
+ listener,
+ tls_acceptor,
+ listener_cfg,
+ async move {
+ shutdown_for_task.notified().await;
+ },
+ )
+ .await
+ });
+
+ let absolute_url = format!(
+ "http://{}:{}/keepalive",
+ upstream_addr.ip(),
+ upstream_addr.port()
+ );
+ let mut tls = open_explicit_https_proxy_tls(proxy_addr, &cert_path).await;
+ let request = format!(
+ "GET {absolute_url} HTTP/1.1\r\nHost: ignored.test\r\nConnection: keep-alive\r\n\r\n"
+ );
+ tls.write_all(request.as_bytes())
+ .await
+ .expect("write keep-alive request");
+ let response = read_until_contains(&mut tls, b"shutdown ok").await;
+ assert!(
+ String::from_utf8_lossy(&response).contains("200 OK"),
+ "unexpected response: {}",
+ String::from_utf8_lossy(&response)
+ );
+
+ shutdown.notify_waiters();
+ tokio::time::timeout(tokio::time::Duration::from_secs(2), server_task)
+ .await
+ .expect("explicit HTTPS listener did not stop after shutdown")
+ .expect("listener task join")
+ .expect("listener shutdown result");
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn explicit_http_can_forward_to_parent_explicit_proxy_over_https() {
+ let (upstream_addr, seen_requests) = start_http_echo_server("https parent ok").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+
+ let parent_config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+ let parent_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind parent proxy");
+ parent_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking parent proxy");
+ let (parent_addr, _parent_temp_dir) =
+ start_explicit_https_proxy(parent_config, parent_listener).await;
+
+ let mut inner_config =
+ explicit_http_config_with_https_parent(parent_addr, &cert_path, &allow_pattern);
+ inner_config.loop_protection.add_header = false;
+ let (inner_addr, _inner_temp_dir) = start_explicit_http_proxy(inner_config).await;
+
+ let absolute_url = format!(
+ "http://{}:{}/via-parent",
+ upstream_addr.ip(),
+ upstream_addr.port()
+ );
+ let (status, response) = send_raw_http_request(inner_addr, &absolute_url).await;
+
+ assert_eq!(status, StatusCode::OK);
+ assert!(
+ response.contains("\r\n\r\nhttps parent ok"),
+ "unexpected response: {response}"
+ );
+
+ let seen = seen_requests.lock().unwrap();
+ let request = seen.first().expect("upstream request");
+ assert_eq!(request.uri, "/via-parent");
+ assert_eq!(
+ request
+ .headers
+ .get("host")
+ .and_then(|value| value.to_str().ok()),
+ Some(format!("{}:{}", upstream_addr.ip(), upstream_addr.port()).as_str())
+ );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn h2_request_to_https_parent_requires_h2_alpn_without_downgrade() {
+ let (upstream_addr, seen_requests) = start_http_echo_server("should not downgrade").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+
+ let mut parent_config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+ parent_config
+ .proxy
+ .listeners
+ .explicit_https
+ .as_mut()
+ .expect("parent explicit HTTPS listener")
+ .enable_http2 = false;
+ let parent_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind parent proxy");
+ parent_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking parent proxy");
+ let (parent_addr, _parent_temp_dir) =
+ start_explicit_https_proxy(parent_config, parent_listener).await;
+
+ let mut inner_config =
+ explicit_http_config_with_https_parent(parent_addr, &cert_path, &allow_pattern);
+ inner_config.loop_protection.add_header = false;
+ let (inner_addr, _inner_temp_dir) = start_explicit_http_proxy(inner_config).await;
+
+ let absolute_url = format!(
+ "http://{}:{}/h2-parent",
+ upstream_addr.ip(),
+ upstream_addr.port()
+ );
+ let (_body, status) = send_h2c_http_request(inner_addr, &absolute_url).await;
+
+ assert_eq!(status, StatusCode::BAD_GATEWAY);
+ assert!(
+ seen_requests.lock().unwrap().is_empty(),
+ "HTTP/2 requests must not downgrade to HTTP/1.1 on an HTTPS parent hop"
+ );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn websocket_upgrade_can_tunnel_over_https_parent() {
+ let upstream_addr = start_upstream_websocket_echo_server().await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+
+ let parent_config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+ let parent_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind parent proxy");
+ parent_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking parent proxy");
+ let (parent_addr, _parent_temp_dir) =
+ start_explicit_https_proxy(parent_config, parent_listener).await;
+
+ let mut inner_config =
+ explicit_http_config_with_https_parent(parent_addr, &cert_path, &allow_pattern);
+ inner_config.loop_protection.add_header = false;
+ let (inner_addr, _inner_temp_dir) = start_explicit_http_proxy(inner_config).await;
+
+ let mut stream = tokio::net::TcpStream::connect(inner_addr)
+ .await
+ .expect("connect inner proxy");
+ let upstream_url = format!("http://{}:{}/ws", upstream_addr.ip(), upstream_addr.port());
+ let upstream_host = format!("{}:{}", upstream_addr.ip(), upstream_addr.port());
+ let request = format!(
+ concat!(
+ "GET {url} HTTP/1.1\r\n",
+ "Host: {host}\r\n",
+ "Connection: Upgrade\r\n",
+ "Upgrade: websocket\r\n",
+ "Sec-WebSocket-Version: 13\r\n",
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n",
+ "\r\n"
+ ),
+ url = upstream_url,
+ host = upstream_host
+ );
+ stream
+ .write_all(request.as_bytes())
+ .await
+ .expect("write websocket upgrade request");
+
+ let (_head, status) = read_http_response_head(&mut stream)
+ .await
+ .expect("read websocket response");
+ assert_eq!(status, StatusCode::SWITCHING_PROTOCOLS);
+
+ let payload = b"https-parent-websocket";
+ stream
+ .write_all(payload)
+ .await
+ .expect("write tunneled payload");
+ let mut echoed = vec![0_u8; payload.len()];
+ tokio::time::timeout(
+ tokio::time::Duration::from_secs(2),
+ stream.read_exact(&mut echoed),
+ )
+ .await
+ .expect("timed out waiting for echoed payload")
+ .expect("read echoed payload");
+
+ assert_eq!(echoed.as_slice(), payload);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn h2_request_can_forward_to_h2_enabled_https_parent() {
+ let (upstream_addr, seen_requests) = start_http_echo_server("h2 https parent ok").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+
+ let mut parent_config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+ parent_config
+ .proxy
+ .listeners
+ .explicit_https
+ .as_mut()
+ .expect("parent explicit HTTPS listener")
+ .enable_http2 = true;
+ let parent_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind parent proxy");
+ parent_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking parent proxy");
+ let (parent_addr, _parent_temp_dir) =
+ start_explicit_https_proxy(parent_config, parent_listener).await;
+
+ let mut inner_config =
+ explicit_http_config_with_https_parent(parent_addr, &cert_path, &allow_pattern);
+ inner_config.loop_protection.add_header = false;
+ let (inner_addr, _inner_temp_dir) = start_explicit_http_proxy(inner_config).await;
+
+ let absolute_url = format!(
+ "http://{}:{}/h2-parent-ok",
+ upstream_addr.ip(),
+ upstream_addr.port()
+ );
+ let (body, status) = send_h2c_http_request(inner_addr, &absolute_url).await;
+
+ assert_eq!(status, StatusCode::OK);
+ assert_eq!(body, "h2 https parent ok");
+
+ let seen = seen_requests.lock().unwrap();
+ let request = seen.first().expect("upstream request");
+ assert_eq!(request.uri, "/h2-parent-ok");
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn connect_inner_requests_can_forward_to_https_parent() {
+ let (forward_addr, seen_requests) = start_http_echo_server("connect via https parent").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = "https://connect-target.test:9443/**";
+
+ let mut parent_config = explicit_https_config(&cert_path, &key_path, allow_pattern);
+ parent_config.proxy.egress.default =
+ Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
+ let parent_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind parent proxy");
+ parent_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking parent proxy");
+ let (parent_addr, _parent_temp_dir) =
+ start_explicit_https_proxy(parent_config, parent_listener).await;
+
+ let mut inner_config =
+ explicit_http_config_with_https_parent(parent_addr, &cert_path, allow_pattern);
+ inner_config.loop_protection.add_header = false;
+ let (inner_addr, inner_temp_dir) = start_explicit_http_proxy(inner_config).await;
+ let inner_ca_cert_path = inner_temp_dir.path().join("certs").join("ca-cert.pem");
+
+ let (status, body) = send_https_via_explicit_http_connect(
+ inner_addr,
+ &inner_ca_cert_path,
+ "connect-target.test:9443",
+ "/ok",
+ )
+ .await;
+
+ assert_eq!(status, StatusCode::OK);
+ assert!(
+ body.contains("connect via https parent"),
+ "unexpected response body: {body}"
+ );
+
+ let seen = seen_requests.lock().unwrap();
+ let forwarded = seen.first().expect("forwarding destination request");
+ assert_eq!(forwarded.uri, "https://connect-target.test:9443/ok");
+ assert_eq!(
+ forwarded
+ .headers
+ .get("host")
+ .and_then(|value| value.to_str().ok()),
+ Some("connect-target.test:9443")
+ );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn https_parent_egress_fails_closed_when_parent_cert_is_untrusted() {
+ let (upstream_addr, seen_requests) = start_http_echo_server("should not arrive").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+
+ let parent_config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+ let parent_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind parent proxy");
+ parent_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking parent proxy");
+ let (parent_addr, _parent_temp_dir) =
+ start_explicit_https_proxy(parent_config, parent_listener).await;
+
+ let mut inner_config =
+ explicit_http_config_with_https_parent_tls_block(parent_addr, None, &allow_pattern);
+ inner_config.loop_protection.add_header = false;
+ let (inner_addr, _inner_temp_dir) = start_explicit_http_proxy(inner_config).await;
+
+ let absolute_url = format!(
+ "http://{}:{}/via-untrusted-parent",
+ upstream_addr.ip(),
+ upstream_addr.port()
+ );
+ let (status, _response) = send_raw_http_request(inner_addr, &absolute_url).await;
+
+ assert_eq!(status, StatusCode::BAD_GATEWAY);
+ assert!(
+ seen_requests.lock().unwrap().is_empty(),
+ "untrusted parent certificate should fail before reaching upstream"
+ );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn https_parent_egress_trust_disabled_accepts_untrusted_parent_cert() {
+ let (upstream_addr, seen_requests) = start_http_echo_server("trust disabled ok").await;
+ let identity_dir = TempDir::new().expect("identity dir");
+ let (cert_path, key_path) = write_proxy_identity(&identity_dir);
+ let allow_pattern = format!("http://{}:{}/**", upstream_addr.ip(), upstream_addr.port());
+
+ let parent_config = explicit_https_config(&cert_path, &key_path, &allow_pattern);
+ let parent_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind parent proxy");
+ parent_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking parent proxy");
+ let (parent_addr, _parent_temp_dir) =
+ start_explicit_https_proxy(parent_config, parent_listener).await;
+
+ let tls_block = r#"[proxy.egress.default.tls]
+trust = "disabled"
+server_name = "127.0.0.1"
+"#;
+ let mut inner_config = explicit_http_config_with_https_parent_tls_block(
+ parent_addr,
+ Some(tls_block),
+ &allow_pattern,
+ );
+ inner_config.loop_protection.add_header = false;
+ let (inner_addr, _inner_temp_dir) = start_explicit_http_proxy(inner_config).await;
+
+ let absolute_url = format!(
+ "http://{}:{}/via-disabled-trust-parent",
+ upstream_addr.ip(),
+ upstream_addr.port()
+ );
+ let (status, response) = send_raw_http_request(inner_addr, &absolute_url).await;
+
+ assert_eq!(status, StatusCode::OK);
+ assert!(
+ response.contains("\r\n\r\ntrust disabled ok"),
+ "unexpected response: {response}"
+ );
+ assert_eq!(seen_requests.lock().unwrap().len(), 1);
+}
diff --git a/tests/proxy_http.rs b/tests/proxy_http.rs
index 5d1e9ea..ebc205a 100644
--- a/tests/proxy_http.rs
+++ b/tests/proxy_http.rs
@@ -8,7 +8,9 @@ use std::time::Duration;
use acl_proxy::app::AppState;
use acl_proxy::config::Config;
-use acl_proxy::proxy::http::run_http_proxy_on_listener;
+use acl_proxy::proxy::http::{
+ run_http_proxy_on_listener, run_http_proxy_on_listener_with_mode, HttpListenerMode,
+};
use base64::engine::general_purpose;
use base64::Engine;
use flate2::read::{DeflateDecoder, GzDecoder, ZlibDecoder};
@@ -337,8 +339,7 @@ async fn external_auth_webhook_failure_emits_status_event() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -442,8 +443,7 @@ async fn external_auth_webhook_uses_approval_timeout_when_webhook_timeout_is_uns
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -609,8 +609,7 @@ async fn approval_macros_are_exposed_and_applied() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -796,8 +795,7 @@ done
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -817,7 +815,7 @@ default = "deny"
[policy.external_auth_profiles.gitlab_acl]
type = "plugin"
command = "{script_path}"
-timeout_ms = 1000
+timeout_ms = 5000
include_headers = ["authorization"]
[[policy.rules]]
@@ -905,8 +903,7 @@ done
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -926,7 +923,7 @@ default = "deny"
[policy.external_auth_profiles.body_guard]
type = "plugin"
command = "{script_path}"
-timeout_ms = 1000
+timeout_ms = 5000
include_headers = ["authorization"]
[[policy.rules]]
@@ -1004,8 +1001,7 @@ done
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -1025,7 +1021,7 @@ default = "deny"
[policy.external_auth_profiles.upgrade_guard]
type = "plugin"
command = "{script_path}"
-timeout_ms = 1000
+timeout_ms = 5000
[[policy.rules]]
action = "delegate"
@@ -1498,8 +1494,7 @@ async fn external_auth_callback_pass_falls_through_to_later_allow() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -1565,8 +1560,7 @@ async fn external_auth_callback_unknown_request_id_returns_404() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -1653,8 +1647,7 @@ done
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -1674,7 +1667,7 @@ default = "deny"
[policy.external_auth_profiles.pass_plugin]
type = "plugin"
command = "{script_path}"
-timeout_ms = 1000
+timeout_ms = 5000
[[policy.rules]]
action = "delegate"
@@ -1717,8 +1710,7 @@ fn minimal_config() -> Config {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -1834,10 +1826,10 @@ async fn allowed_request_uses_configured_egress_forwarding_destination() {
rule_id: None,
},
)];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
proxy_listener
@@ -1899,10 +1891,10 @@ async fn explicit_http_canonicalizes_url_before_policy_and_egress_forwarding() {
rule_id: None,
},
)];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
proxy_listener
@@ -2010,10 +2002,10 @@ async fn global_egress_request_actions_are_applied_after_rule_actions_without_af
rule_id: None,
}),
];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
config.proxy.egress.request_header_actions = vec![
acl_proxy::config::EgressRequestHeaderActionConfig {
action: acl_proxy::config::HeaderActionKind::ReplaceSubstring,
@@ -2149,10 +2141,10 @@ async fn global_egress_request_actions_respect_intra_layer_order() {
rule_id: None,
},
)];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
config.proxy.egress.request_header_actions = vec![
acl_proxy::config::EgressRequestHeaderActionConfig {
action: acl_proxy::config::HeaderActionKind::Set,
@@ -2260,10 +2252,10 @@ async fn empty_global_egress_actions_preserve_existing_rule_header_behavior() {
rule_id: None,
},
)];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
config.proxy.egress.request_header_actions = Vec::new();
let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
@@ -2389,10 +2381,11 @@ async fn external_auth_webhook_transport_is_not_redirected_by_egress_forwarding(
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[proxy.egress.default]
+type = "explicit_proxy"
+transport = "http"
host = "127.0.0.1"
port = {forward_port}
@@ -2493,6 +2486,45 @@ async fn start_proxy_with_config(
(addr, temp_dir)
}
+async fn start_transparent_http_proxy_with_config(
+ mut config: Config,
+ listener: StdTcpListener,
+) -> (SocketAddr, TempDir) {
+ let addr = listener.local_addr().expect("proxy addr");
+
+ config.proxy.listeners.explicit_http = None;
+ config.proxy.listeners.transparent_http =
+ Some(acl_proxy::config::CleartextHttpListenerConfig {
+ bind: "127.0.0.1:1".to_string(),
+ enable_http2: true,
+ });
+
+ let temp_dir = TempDir::new().expect("temp dir for capture");
+ let capture_dir = temp_dir.path().join("captures");
+ let certs_dir = temp_dir.path().join("certs");
+ config.capture.directory = capture_dir.to_string_lossy().to_string();
+ config.certificates.certs_dir = certs_dir.to_string_lossy().to_string();
+
+ let state = AppState::shared_from_config(config).expect("app state");
+
+ let listener_addr = addr;
+ tokio::spawn(async move {
+ let _ = run_http_proxy_on_listener_with_mode(
+ state,
+ listener,
+ HttpListenerMode::Transparent,
+ true,
+ std::future::pending(),
+ )
+ .await
+ .map_err(|e| {
+ eprintln!("transparent HTTP proxy server on {listener_addr} exited: {e}");
+ });
+ });
+
+ (addr, temp_dir)
+}
+
fn build_websocket_frame(opcode: u8, payload: &[u8], masked: bool) -> Vec {
build_websocket_frame_with_bits(true, false, opcode, payload, masked)
}
@@ -2793,8 +2825,7 @@ fn body_plugin_config(
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -2813,7 +2844,7 @@ default = "deny"
[policy.external_auth_profiles.body_guard]
type = "plugin"
command = "{script_path}"
-timeout_ms = 1000
+timeout_ms = 5000
include_request_body = {include_request_body}
max_request_body_bytes = {max_request_body_bytes}
max_decompressed_request_body_bytes = {max_decompressed_request_body_bytes}
@@ -3059,8 +3090,7 @@ async fn body_aware_auth_plugin_rewrites_http_request_body() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -3079,7 +3109,7 @@ default = "deny"
[policy.external_auth_profiles.body_guard]
type = "plugin"
command = "{script_path}"
-timeout_ms = 1000
+timeout_ms = 5000
include_request_body = true
max_request_body_bytes = 4096
max_decompressed_request_body_bytes = 4096
@@ -3142,8 +3172,7 @@ async fn native_redaction_rewrites_http_request_body() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -3337,8 +3366,7 @@ async fn native_redaction_recompresses_gzip_request_body() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -4384,6 +4412,47 @@ async fn loop_detected_returns_508() {
);
}
+#[tokio::test(flavor = "multi_thread")]
+async fn explicit_http_listener_rejects_origin_form_proxy_requests() {
+ let mut config = minimal_config();
+ config.policy.default = acl_proxy::config::PolicyDefaultAction::Allow;
+
+ let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
+ proxy_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let (proxy_addr, _temp_dir) = start_proxy_with_config(config, proxy_listener).await;
+
+ let raw_request =
+ "GET /relative/path HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n";
+ let (_response, status) = send_raw_http_request(proxy_addr, raw_request).await;
+
+ assert_eq!(status, StatusCode::BAD_REQUEST);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn transparent_http_listener_rejects_explicit_proxy_shapes() {
+ let mut config = minimal_config();
+ config.policy.default = acl_proxy::config::PolicyDefaultAction::Allow;
+
+ let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
+ proxy_listener
+ .set_nonblocking(true)
+ .expect("set nonblocking proxy");
+ let (proxy_addr, _temp_dir) =
+ start_transparent_http_proxy_with_config(config, proxy_listener).await;
+
+ let absolute_request =
+ "GET http://example.com/path HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n";
+ let (_response, status) = send_raw_http_request(proxy_addr, absolute_request).await;
+ assert_eq!(status, StatusCode::BAD_REQUEST);
+
+ let connect_request =
+ "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nConnection: close\r\n\r\n";
+ let (_response, status) = send_raw_http_request(proxy_addr, connect_request).await;
+ assert_eq!(status, StatusCode::BAD_REQUEST);
+}
+
#[tokio::test(flavor = "multi_thread")]
async fn origin_form_request_with_host_is_forwarded() {
let (upstream_addr, _seen_headers) = start_upstream_echo_server().await;
@@ -4418,7 +4487,8 @@ async fn origin_form_request_with_host_is_forwarded() {
.set_nonblocking(true)
.expect("set nonblocking proxy");
- let (proxy_addr, _temp_dir) = start_proxy_with_config(config, proxy_listener).await;
+ let (proxy_addr, _temp_dir) =
+ start_transparent_http_proxy_with_config(config, proxy_listener).await;
let raw_request =
format!("GET /relative/path HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
@@ -4560,8 +4630,7 @@ async fn header_actions_apply_to_request_for_matching_rule() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -4667,8 +4736,7 @@ async fn header_actions_apply_to_response_for_matching_rule() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
diff --git a/tests/proxy_https_connect.rs b/tests/proxy_https_connect.rs
index d72881e..7a82b97 100644
--- a/tests/proxy_https_connect.rs
+++ b/tests/proxy_https_connect.rs
@@ -214,8 +214,7 @@ fn minimal_connect_config() -> Config {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -829,10 +828,10 @@ async fn configured_egress_forwarding_applies_to_https_connect_inner_requests()
rule_id: None,
},
)];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
proxy_listener
@@ -891,10 +890,10 @@ async fn global_egress_request_actions_apply_to_https_connect_inner_requests() {
rule_id: None,
},
)];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
config.proxy.egress.request_header_actions =
vec![acl_proxy::config::EgressRequestHeaderActionConfig {
action: acl_proxy::config::HeaderActionKind::Set,
@@ -1020,8 +1019,7 @@ done
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
+listeners.explicit_http.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -1047,7 +1045,7 @@ default = "deny"
[policy.external_auth_profiles.pass_plugin]
type = "plugin"
command = "{script_path}"
-timeout_ms = 1000
+timeout_ms = 5000
[[policy.rules]]
action = "delegate"
@@ -1108,10 +1106,10 @@ async fn headers_match_is_evaluated_on_decrypted_inner_connect_requests() {
rule_id: None,
},
)];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
proxy_listener
@@ -1216,10 +1214,10 @@ async fn headers_not_match_is_evaluated_on_decrypted_inner_connect_requests() {
rule_id: None,
}),
];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
proxy_listener
diff --git a/tests/proxy_https_transparent.rs b/tests/proxy_https_transparent.rs
index fc19170..9619f0b 100644
--- a/tests/proxy_https_transparent.rs
+++ b/tests/proxy_https_transparent.rs
@@ -566,10 +566,7 @@ fn minimal_https_transparent_config() -> Config {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
-https_bind_address = "127.0.0.1"
-https_port = 0
+listeners.transparent_https.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -625,9 +622,15 @@ async fn start_proxy_with_config(
#[tokio::test(flavor = "multi_thread")]
async fn transparent_https_listener_times_out_idle_tls_handshake() {
let mut config = minimal_https_transparent_config();
- config.proxy.https_handshake_timeout_ms = 50;
- config.proxy.https_request_header_timeout_ms = 500;
- config.proxy.https_max_connections = 16;
+ let listener = config
+ .proxy
+ .listeners
+ .transparent_https
+ .as_mut()
+ .expect("transparent https listener");
+ listener.handshake_timeout_ms = 50;
+ listener.request_header_timeout_ms = 500;
+ listener.max_connections = 16;
let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
listener
@@ -1277,10 +1280,10 @@ async fn configured_egress_forwarding_applies_to_https_transparent_requests() {
rule_id: None,
},
)];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind proxy");
proxy_listener
@@ -1346,10 +1349,10 @@ async fn global_egress_request_actions_apply_to_https_transparent_requests() {
rule_id: None,
},
)];
- config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig {
- host: "127.0.0.1".to_string(),
- port: forward_addr.port(),
- });
+ config.proxy.egress.default = Some(acl_proxy::config::EgressTargetConfig::explicit_http_proxy(
+ "127.0.0.1".to_string(),
+ forward_addr.port(),
+ ));
config.proxy.egress.request_header_actions =
vec![acl_proxy::config::EgressRequestHeaderActionConfig {
action: acl_proxy::config::HeaderActionKind::Set,
@@ -2923,10 +2926,7 @@ done
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
-https_bind_address = "127.0.0.1"
-https_port = 0
+listeners.transparent_https.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -2952,7 +2952,7 @@ default = "deny"
[policy.external_auth_profiles.pass_plugin]
type = "plugin"
command = "{script_path}"
-timeout_ms = 1000
+timeout_ms = 5000
[[policy.rules]]
action = "delegate"
@@ -3002,10 +3002,7 @@ async fn body_aware_auth_plugin_rewrites_transparent_https_request_body() {
schema_version = "1"
[proxy]
-bind_address = "127.0.0.1"
-http_port = 0
-https_bind_address = "127.0.0.1"
-https_port = 0
+listeners.transparent_https.bind = "127.0.0.1:1"
[logging]
directory = "logs"
@@ -3030,7 +3027,7 @@ default = "deny"
[policy.external_auth_profiles.body_guard]
type = "plugin"
command = "{script_path}"
-timeout_ms = 1000
+timeout_ms = 5000
include_request_body = true
max_request_body_bytes = 4096
max_decompressed_request_body_bytes = 4096