A Rust-based ACL-aware HTTP/HTTPS proxy with a TOML configuration file and a flexible URL policy engine. Evaluate every request against ordered rules matching on URL patterns, HTTP methods, client subnets, and headers — then allow, deny, or gate behind an external approval workflow.
- How It Works
- Quick Start
- Proxy Modes
- Policy Engine
- Configuration Reference
- External Auth
- Operations
- TLS & Certificates
- Logging & Capture
- CLI Reference
- Troubleshooting
- Project Structure
- Development
- Demos
- License
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.
graph TB
Client["Client"]
subgraph acl-proxy
HTTP["HTTP Listener<br/>:8881"]
HTTPS["Transparent HTTPS Listener<br/>:8889"]
LP["Loop Protection"]
PE["Policy Engine<br/>pattern · method · subnet · headers"]
EA["External Auth<br/>(optional approval gate)"]
HA["Header Actions<br/>request · response mutations"]
EF["Egress Forwarding<br/>(optional chained proxy)"]
end
Upstream["Upstream Services"]
Client -->|"explicit proxy<br/>transparent HTTP<br/>CONNECT MITM"| HTTP
Client -->|"transparent HTTPS<br/>HTTP/2 · WebSocket"| HTTPS
HTTP --> LP
HTTPS --> LP
LP --> PE
PE -->|allow| EA
PE -->|deny| Deny["403 Forbidden"]
EA -->|approved| HA
HA --> EF
EF --> Upstream
Policy evaluation order for each rule: pattern → subnets → methods → headers_absent → headers_match. First match wins; if nothing matches, policy.default applies.
- Rust toolchain (2021 edition) — install
git clone https://github.com/kcosr/acl-proxy.git
cd acl-proxy
cargo build --releaseGenerate a minimal config:
acl-proxy config init config/acl-proxy.tomlOr copy the sample and edit it:
mkdir -p config
cp acl-proxy.sample.toml config/acl-proxy.tomlThe generated minimal config defaults to deny-all and disables capture.
# Validate configuration
acl-proxy config validate --config config/acl-proxy.toml
# Start the proxy
acl-proxy --config config/acl-proxy.tomlThe 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_portifhttps_portis non-zero.
# Check readiness
curl http://127.0.0.1:8881/_acl-proxy/ready
# HTTP explicit proxy
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
# HTTPS proxy (CONNECT MITM)
curl -x http://127.0.0.1:8881 https://example.com/ \
--proxy-cacert certs/ca-cert.pem
# Transparent HTTPS listener
curl https://upstream.internal/resource \
--connect-to upstream.internal:443:127.0.0.1:8889 \
--cacert certs/ca-cert.pemOr run directly with Cargo during development:
cargo run -- --config config/acl-proxy.tomlacl-proxy supports four request paths. All modes apply the same policy engine, logging, capture, and header actions.
| 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 |
sequenceDiagram
participant Client
participant Listener as HTTP/HTTPS Listener
participant LP as Loop Protection
participant Policy as Policy Engine
participant Auth as External Auth
participant Headers as Header Actions
participant Upstream
Client->>Listener: HTTP request / TLS connection
Listener->>LP: Check loop header
alt Loopback detected
LP-->>Client: 508 Loop Detected
end
LP->>Policy: Evaluate rules (pattern, subnet, method, headers)
alt No rule matches
Policy->>Policy: Apply default action
end
alt Denied
Policy-->>Client: 403 Forbidden (+ capture if configured)
end
alt Allowed + external auth gate
Policy->>Auth: Send approval webhook
Auth-->>Policy: Callback with decision
end
Policy->>Headers: Apply rule request header actions
Headers->>Headers: Apply plugin request header actions (if any)
Headers->>Headers: Apply global egress request header actions
Headers->>Upstream: Forward request
Upstream-->>Headers: Response
Headers->>Headers: Apply response header actions
Headers-->>Client: Forward response (+ capture/log)
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.
- Policy is evaluated on each decrypted request; the CONNECT request itself is only used to establish the tunnel.
- Request-header predicates (
headers_absent,headers_match) apply only to decrypted inner requests, not to the outer CONNECT establishment request. - Loop protection runs on both the CONNECT request and the inner requests.
- The outer CONNECT handshake remains local to the first proxy hop. If egress forwarding is enabled, only the decrypted inner HTTPS requests use the egress destination.
- Clients must trust the proxy CA (
certs/ca-cert.pemby default).
- Set
https_port = 0to disable this listener. - URL construction is based on the Host header or the request URI authority.
- Inbound HTTP/2 is supported via ALPN negotiation.
- If the Host header is missing or invalid, the proxy returns
400 Bad Request.
- HTTP/1.1 upgrade handshakes are proxied on all HTTP/1.1 request paths (HTTP listener, CONNECT inner, transparent HTTPS when HTTP/1.1 is negotiated).
- After a
101 Switching Protocolsresponse, acl-proxy switches to a bidirectional byte tunnel between client and upstream. - HTTP/2 extended CONNECT / RFC 8441 is not currently implemented.
By default, acl-proxy uses HTTP/1.1 for upstream connections, even when clients speak HTTP/2 to the proxy. To enable upstream HTTP/2:
[tls]
enable_http2_upstream = trueWhen enabled, ALPN is used per origin; the proxy will use HTTP/2 where supported and fall back to HTTP/1.1 otherwise.
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 forwarded request keeps the original target URI and
Hostheader, 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.
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.
Timeout guidance: Set the inner proxy timeout high enough to cover the outer proxy timeout plus its upstream work.
The policy engine evaluates each request against an ordered list of rules and returns the first match. If no rule matches, policy.default applies. Invalid or unparseable URLs are always denied.
- Normalize the request URL.
- Normalize the client IP (if present).
- Normalize the HTTP method (if present).
- Evaluate rules in the order they appear:
- Pattern match (if set)
- Subnet match (if set)
- Method match (if set)
headers_absentmatch (if set)headers_matchmatch (if set)
- First match wins.
- If nothing matches, apply
policy.default.
All predicates use AND semantics — a rule matches only when every predicate passes.
Before applying rules, the engine normalizes input URLs to:
protocol + "//" + host[:port] + path + optional "?query"
- The scheme is preserved (
http:orhttps:). - The host includes a port only when it was explicit in the URL. No default port is added.
- The path defaults to
/when empty. - Query strings are preserved; fragments are ignored.
- IPv6 hostnames use standard bracket notation (e.g.,
https://[::1]:8443/path).
Patterns are matched case-insensitively against normalized URLs.
- Scheme is optional:
https://example.com/**andexample.com/**both matchhttpsandhttp. - Wildcards:
*matches any sequence of characters except/.**matches any sequence of characters including/.
- Host-only patterns:
https://example.commatcheshttps://example.comandhttps://example.com/but not deeper paths.
Examples:
https://example.com/api/** # any path under /api
https://example.com/api/*/v1 # one segment between /api and /v1
example.com # host-only, any scheme
Methods are specified as a string or list of strings and normalized to uppercase:
methods = "POST"
methods = ["GET", "HEAD"]Rules without methods have no method restriction.
Client IP subnets are specified as IPv4 or IPv6 CIDR ranges:
subnets = ["10.0.0.0/8", "192.168.0.0/16", "::1/128"]Client IP normalization:
- Strips interface suffixes after
%(e.g.,fe80::1%eth0→fe80::1). - Maps
::ffff:x.y.z.wtox.y.z.w. - Maps
::1to127.0.0.1.
Rules can match on missing inbound request headers:
[[policy.rules]]
action = "deny"
pattern = "**"
headers_absent = ["x-workload-id"]
description = "Deny requests missing workload identity"- Matches when any listed request header is missing.
- Header-name lookup is case-insensitive.
- A header present with an empty value still counts as present.
- When all listed headers are present, the rule falls through to the next rule.
- On HTTPS over CONNECT, applies to the decrypted inner request, not the outer CONNECT.
Rules can require exact inbound request-header values:
[[policy.rules]]
action = "allow"
pattern = "https://api.internal.example.com/**"
headers_match = { "x-workload-id" = ["worker-123", "worker-456"], "x-tenant-id" = "tenant-a" }
description = "Allow trusted workload identities"- Across header keys:
ANDsemantics. - Within one key's values:
ORsemantics. - Header names are case-insensitive.
- Value matching is exact and case-sensitive — no trimming, no comma splitting.
- Repeated inbound header values are supported; any exact match satisfies that key.
- Empty configured values are rejected during config validation.
- When both
headers_absentandheaders_matchare configured, both predicates must pass.
Macros are named placeholders expanded before patterns are compiled:
[policy.macros]
repo = ["team/service-a", "team/service-b"]
[[policy.rulesets.git_repo]]
action = "allow"
pattern = "https://git.internal/{repo}.git/**"
description = "Git HTTP(S) for {repo}"
methods = ["GET", "POST"]Include rules expand a ruleset into concrete rules:
[[policy.rules]]
include = "git_repo"
add_url_enc_variants = true
methods = ["GET", "POST"] # overrides template methods
subnets = ["10.0.0.0/8"] # overrides template subnetswithprovides macro overrides specific to this include.add_url_enc_variants = truegenerates both raw and URL-encoded variants for all placeholders.methodsandsubnetson the include override the template values; when omitted, template values are used.headers_absentandheaders_matchare inherited from the template, not overridden.- Missing macros required by a ruleset cause validation failure.
Rules can modify headers on matching requests and responses. Actions do not participate in rule matching — they run only after a rule matches.
[[policy.rules]]
action = "allow"
pattern = "https://github.com/**"
[[policy.rules.header_actions]]
direction = "request" # request | response | both
action = "set" # set | add | remove | replace_substring
name = "user-agent"
value = "acl-proxy/1.0"
when = "always" # always | if_present | if_absent
[[policy.rules.header_actions]]
direction = "response"
action = "replace_substring"
name = "x-upstream-tag"
search = "old"
replace = "new"| Action | Description |
|---|---|
set |
Replace all existing values with the configured value(s) |
add |
Append new values without removing existing ones |
remove |
Delete the header entirely |
replace_substring |
Find and replace within each current value |
when conditions: always (default), if_present, if_absent — evaluated against the original header state before any actions for that direction run.
value / values: Exactly one must be provided for set/add. Values must be valid HTTP header values.
Environment variable interpolation: Exact whole-string ${NAME} placeholders in value/values resolve once at config load/reload time. NAME must match [A-Za-z_][A-Za-z0-9_]*. Mixed strings like Bearer ${TOKEN} are rejected. Missing env vars fail validation, startup, and reload.
Approval macros: {{name}} placeholders are a separate feature for external auth workflows — they are not resolved at config load time. See External Auth.
A global request-only layer under [[proxy.egress.request_header_actions]] applies the same outbound mutations to every forwarded request after matched-rule/plugin request actions.
[[proxy.egress.request_header_actions]]
action = "set"
name = "x-egress-tag"
value = "edge-a"
when = "always"Ordering for outbound requests:
- Evaluate policy and match the first rule.
- Apply matched-rule request header actions.
- Apply plugin request header actions (when present).
- Apply global egress request header actions.
- Send upstream.
Global egress actions never affect rule matching. Their when conditions are evaluated against header presence at the start of the global layer (after rule/plugin actions). Global response-header actions are not supported — only request-direction actions are available in this layer.
Use the policy inspection CLI to see the fully expanded rule set:
acl-proxy policy dump --config config/acl-proxy.toml
acl-proxy policy dump --format table
acl-proxy policy dump --format jsonpolicy dump defaults to table output on a TTY and JSON otherwise. It includes headers_match values — treat output as sensitive when those values represent credentials.
The proxy resolves the config path in this order:
--config <path>CLI argumentACL_PROXY_CONFIGenvironment variableconfig/acl-proxy.toml(relative to the current working directory)
If the default path is missing, the CLI suggests running acl-proxy config init.
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 |
LOG_LEVEL |
logging.level |
info |
schema_version = "1" # required; only "1" is supported
[proxy]
[proxy.egress]
[[proxy.egress.request_header_actions]]
[logging]
[capture]
[loop_protection]
[certificates]
[tls]
[external_auth]
[policy]All sections except schema_version and [policy].default are optional with sensible defaults.
[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
internal_base_path = "/_acl-proxy" # base path for internal endpointsinternal_base_pathmust 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.
[proxy.egress.default]
host = "172.17.0.1" # DNS hostname or IP; IPv6 bare (::1) or bracketed ([::1])
port = 8889 # TCP port (1–65535)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.
[logging]
level = "info" # trace | debug | info | warn | error
directory = "logs" # omit for console-only
max_bytes = 104857600 # rotation threshold (bytes)
max_files = 5 # rotated files to keep
console = true # also write to stdout- When
directoryis set, logs go to{directory}/acl-proxy.logand rotate by size. - Log writing is non-blocking; when the internal buffer fills, entries are dropped to avoid stalling requests.
- Transport logs on
acl_proxy::transport(debug level) include per-request ingress, egress-attempt, egress, and completion events.
[logging.policy_decisions]
log_allows = false # log allowed decisions
log_denies = true # log denied decisions
level_allows = "info" # log level for allows
level_denies = "warn" # log level for deniesPolicy decision events are emitted to the acl_proxy::policy target with structured fields: request_id, allowed, url, method, client_ip, rule_action, rule_pattern, rule_description.
[capture]
allowed_request = false # capture allowed request records
allowed_response = false # capture allowed response records
denied_request = false # capture denied request records
denied_response = false # capture denied response records
directory = "logs-capture" # base directory for capture files
filename = "{requestId}-{suffix}.json" # template ({requestId}, {kind}, {suffix})
max_body_bytes = 65536 # max body bytes to serialize (0 = skip body)Capture happens for:
- Allowed requests/responses when the corresponding flags are enabled.
- Denied requests/responses for policy or loop protection when denied flags are enabled.
- Upstream failures (502/504) as allowed traffic when capture is enabled.
body.length always records the full logical body length even when body.data is truncated.
Each JSON file contains a single object:
| Field | Type | Description |
|---|---|---|
timestamp |
string | RFC 3339 timestamp |
requestId |
string | Internal request ID |
kind |
string | "request" or "response" |
decision |
string | "allow" or "deny" |
mode |
string | "http_proxy", "https_connect", or "https_transparent" |
url |
string | Normalized URL (no fragment) |
method |
string | HTTP method |
httpVersion |
string | e.g., "1.1", "2" |
statusCode |
number | HTTP status code (responses only) |
statusMessage |
string | Status message (responses only) |
client |
object | address (string), port (number) |
target |
object | Upstream address and port (when available) |
headers |
object | Lowercase keys; values are string or string array |
body |
object | encoding ("base64"), length (full), data (base64), contentType |
acl-proxy-extract-capture-body logs-capture/req-123-res.json > body.binReports errors for invalid JSON, missing bodies, or unsupported encodings.
[loop_protection]
enabled = true # enable loop detection on all paths
add_header = true # inject header into outbound requests
header_name = "x-acl-proxy-request-id" # header name for detection/injectionWhen an inbound request contains the loop header and loop protection is enabled, the proxy responds with:
- Status:
508 Loop Detected - Body:
{ "error": "LoopDetected", "message": "Proxy loop detected via loop protection header" }
Loop detection runs on: HTTP listener requests (explicit + transparent), CONNECT requests, decrypted CONNECT inner requests, and transparent HTTPS requests.
[certificates]
certs_dir = "certs" # base directory for certificate material
ca_key_path = "/path/to/ca-key.pem" # optional external CA key
ca_cert_path = "/path/to/ca-cert.pem" # optional external CA cert
max_cached_certs = 1024 # LRU cache size (min 1)- When both
ca_key_pathandca_cert_pathare absent, the proxy auto-generates a CA incerts_dirand reuses it if valid files already exist. - When both are provided, the proxy uses them as-is; invalid/unreadable files cause a startup error.
- When only one is provided, validation fails — both must be set or both omitted.
- Per-host certificates are generated on demand, cached in memory (LRU), and also written to
certs_dir/dynamic/as<host>.crt,<host>.key, and<host>-chain.crtfor debugging transparency. On-disk files are not reloaded on startup.
[tls]
verify_upstream = true # verify upstream HTTPS certificates
enable_http2_upstream = false # HTTP/1.1-only by defaultverify_upstream = falseaccepts all upstream certificates regardless of host or issuer. Use only in controlled test environments.enable_http2_upstream = truelets ALPN negotiation choose HTTP/2 per origin; the proxy falls back to HTTP/1.1 when the origin doesn't advertiseh2.
These settings affect only outbound TLS from proxy to upstream. Incoming TLS is always terminated using the proxy's CA.
[external_auth]
callback_url = "https://proxy.example.com/_acl-proxy/external-auth/callback"callback_url must be an absolute URL with a host. Empty or relative values are rejected. This value is included in external auth webhooks as callbackUrl.
[policy]
default = "deny" # "allow" or "deny" (case-insensitive)See Policy Engine for full details on rules, macros, rulesets, and header actions.
[[policy.rules]]
action = "allow" # required: "allow" | "deny"
pattern = "https://example.com/**" # optional: URL pattern
description = "Example rule" # optional
methods = ["GET", "POST"] # optional: HTTP methods
subnets = ["10.0.0.0/8"] # optional: client IP CIDRs
headers_absent = ["x-id"] # optional: missing-header check
headers_match = { "x-id" = "v1" } # optional: exact header match
request_timeout_ms = 5000 # optional: override upstream timeout
rule_id = "stable-id" # optional: stable ID for webhooks
external_auth_profile = "name" # optional: approval gate (allow rules only)At least one of pattern, methods, subnets, headers_absent, or headers_match must be present.
[[policy.rules]]
include = "ruleset_name" # required: reference a ruleset
with = { repo = "override" } # optional: macro overrides
add_url_enc_variants = true # optional: URL-encode placeholders
methods = ["GET"] # optional: override template methods
subnets = ["10.0.0.0/8"] # optional: override template subnets
request_timeout_ms = 5000 # optional: override template timeout[policy.approval_macros]
github_token = { label = "GitHub token", required = true, secret = true }
reason = { label = "Approval reason", required = false, secret = false }label: Human-friendly label for approver UIs (defaults to macro name).required: Whether the approver must supply a non-empty value (defaulttrue).secret: Hint to mask input and avoid logging (defaultfalse).
[policy.external_auth_profiles.github_mfa]
type = "http" # "http" (default) | "plugin"
webhook_url = "https://..." # required for type = "http"
timeout_ms = 5000 # required: approval/plugin timeout
webhook_timeout_ms = 1000 # optional: webhook delivery timeout
on_webhook_failure = "error" # "deny" | "error" | "timeout"
[policy.external_auth_profiles.url_allow]
type = "plugin"
command = "/usr/local/bin/url-allow" # required for type = "plugin"
args = ["--config", "/etc/url-allow.json"]
timeout_ms = 1000
restart_delay_ms = 10000 # delay before restarting crashed plugin
include_headers = ["x-*"] # header name globs to forward
env = { KEY = "value" } # env vars for the plugin processschema_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
internal_base_path = "/_acl-proxy"
[logging]
level = "info"
[policy]
default = "deny"The repository includes acl-proxy.sample.toml as a comprehensive example covering all options.
The config loader performs validation beyond basic TOML parsing:
schema_versionmust equal"1".- Direct rules must have at least one of
pattern,methods,subnets,headers_absent, orheaders_match. - Include rules must reference an existing ruleset.
- Macro placeholders (
{name}) must resolve frompolicy.macrosorwithoverrides. ca_key_pathandca_cert_pathmust be both set or both omitted.loop_protection.header_namemust be a valid HTTP header name.${NAME}env placeholders must resolve at validation/startup/reload time. Existing literal${...}strings inset/addheader-action values are reserved syntax and must be migrated.external_auth_profileonaction = "deny"rules is rejected — approval-required deny rules are not allowed.
On validation failure, config validate and startup report a human-readable error and abort, leaving any previously running instance (in the case of reload) unchanged.
External auth turns allow rules into approval-required gates. When a matching request hits an approval-gated rule, acl-proxy pauses the request, sends a webhook to an external service, and waits for a callback decision.
sequenceDiagram
participant Client
participant Proxy as acl-proxy
participant Approver as External Auth Service
Client->>Proxy: HTTPS request
Proxy->>Proxy: Policy match → approval required
Proxy->>Approver: POST webhook (X-Acl-Proxy-Event: pending)
Note over Proxy: Waiting for callback...
Approver->>Proxy: POST /_acl-proxy/external-auth/callback
Note over Approver: approve / deny
alt Approved
Proxy->>Proxy: Interpolate approval macros into headers
Proxy-->>Client: Forward upstream response
else Denied / Timeout
Proxy-->>Client: 403 Forbidden / 504 Timeout
end
When a rule with external_auth_profile matches, the proxy POSTs a webhook:
- URL:
policy.external_auth_profiles.<name>.webhook_url - Header:
X-Acl-Proxy-Event: pending - Payload fields:
requestId,profile,ruleIndex, optionalruleId,url,method,clientIp,status: "pending",terminal: false,timestamp(RFC3339),elapsedMs,eventId,callbackUrl(when configured), andmacros(approval macro descriptors).
For lifecycle events (webhook failure, timeout, error, cancellation), the proxy emits a best-effort status webhook:
- Header:
X-Acl-Proxy-Event: status - Fields:
status(webhook_failed|timed_out|error|cancelled),terminal: true,reason, optionalfailureKindandhttpStatus. - At most one terminal event per
requestId. - Status webhooks are telemetry only — delivery failures never affect the allow/deny decision.
POST /{internal_base_path}/external-auth/callback
Content-Type: application/json
{
"requestId": "req-...",
"decision": "allow" | "deny",
"macros": {
"github_token": "ghp_...",
"reason": "Approving for test"
}
}
| Response | Condition |
|---|---|
200 OK with { "status": "ok" } |
Success |
404 Not Found |
Unknown or already completed requestId |
400 Bad Request |
Invalid body or missing required macros |
Macro validation: Required macros must be present and non-empty. Values must not contain control characters (ASCII < 0x20 or DEL). Optional macros may be omitted or empty.
Stdio-based synchronous auth. The proxy spawns a long-running plugin process and sends JSON requests over stdin, waiting for allow/deny JSON responses from stdout (newline-delimited). On allow, the proxy applies rule header actions first, plugin header actions second, and global egress request actions third. Plugins can also return response header actions. See docs/design/auth-plugins-design.md for the full protocol specification.
on_webhook_failure controls behavior when the initial webhook fails:
| Mode | Response |
|---|---|
deny |
403 Forbidden |
error |
503 Service Unavailable (default) |
timeout |
504 Gateway Timeout |
The callback endpoint does not authenticate requests. Restrict access to /{internal_base_path}/external-auth/callback using network policy or firewall rules.
acl-proxy validates configuration at startup. If logging initialization fails, the proxy still starts but reports the error to stderr.
curl http://127.0.0.1:8881/_acl-proxy/ready
# → {"status": "ready"}The endpoint is internal to the proxy and does not require a policy match. Only GET is supported (returns 405 for other methods).
Send SIGHUP to reload configuration without downtime:
kill -HUP $(pidof acl-proxy)- A new
AppStateis built and swapped atomically viaArcSwap. - New connections use the new config immediately. In-flight requests complete with the previous config.
- 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.
SIGTERM or Ctrl+C stops accepting new connections. In-flight requests continue using existing state until they finish.
Loop protection rejects any request containing the configured header (default x-acl-proxy-request-id) with:
- Status:
508 Loop Detected - Body:
{ "error": "LoopDetected", "message": "Proxy loop detected via loop protection header" }
Capture logging for loop-detected requests follows the [capture] flags for denied traffic.
proxy.request_timeout_mssets the default upstream timeout (30s).- Rules can override with
request_timeout_ms. 0disables the timeout.- When the timeout expires, the proxy responds with
504 Gateway Timeout.
[proxy.egress.default]
host = "172.17.0.1"
port = 8889Operational 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.
- A valid config reload atomically swaps in the updated egress destination.
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.
graph LR
subgraph CA["Certificate Authority"]
AutoCA["Auto-generated CA<br/>(default, saved to certs_dir)"]
ExtCA["External CA<br/>(ca_key_path + ca_cert_path)"]
end
subgraph Cache["Per-Host Certificate Cache"]
Cert1["*.example.com"]
Cert2["api.internal"]
CertN["... (LRU, max 1024)"]
end
AutoCA --> Cache
ExtCA --> Cache
Cache -->|SNI selection| TLS["TLS Termination"]
- Default CA (no explicit paths): Auto-generated at
certs/ca-key.pemandcerts/ca-cert.pem. If valid files exist, they are reused; otherwise a new CA is generated and written. - External CA (
ca_key_path+ca_cert_path): Used as-is. Missing/invalid files cause startup/reload failure. Both must be set or both omitted.
Generated on demand for each host (SNI or CONNECT target). Cached in memory with LRU eviction (configurable max_cached_certs, default 1024). Also written to disk for debugging:
certs/dynamic/<host>.crt— leaf certificatecerts/dynamic/<host>.key— private keycerts/dynamic/<host>-chain.crt— leaf + CA chain
On-disk files are not reloaded on startup — the proxy regenerates from the CA as needed.
Clients must trust the proxy CA:
- CONNECT MITM:
--proxy-cacert certs/ca-cert.pemor import the CA system-wide. - Transparent HTTPS:
--cacert certs/ca-cert.pemor import the CA system-wide. - Plain HTTP proxy traffic does not require CA trust.
Outgoing TLS from the proxy to upstream is verified against system root certificates by default. Set tls.verify_upstream = false only in controlled test environments.
- Configurable level (
tracethrougherror), with env override viaLOG_LEVEL. - Optional file rotation by size (default 100 MB, 5 files) when
logging.directoryis set. - Non-blocking — entries are dropped when the buffer is full (never stalls requests).
- Console output controlled by
logging.console(defaulttrue).
Separate control for allow vs. deny decisions with configurable log levels. Events are emitted to the acl_proxy::policy target with structured fields: request_id, allowed, url, method, client_ip, and rule metadata.
JSON capture files with request metadata, headers, and base64-encoded bodies. Enable independently for allowed/denied requests/responses. Body size capped at capture.max_body_bytes (default 64 KiB); full logical length always recorded in body.length.
acl-proxy-extract-capture-body logs-capture/abc123-request.json > body.bin# Run the proxy (default command)
acl-proxy [--config <path>]
# Initialize a minimal config file
acl-proxy config init <path>
# Validate configuration
acl-proxy config validate [--config <path>]
# Inspect the effective policy
acl-proxy policy dump [--config <path>]
acl-proxy policy dump --format table
acl-proxy policy dump --format jsonpolicy dump defaults to table output on a TTY and JSON otherwise.
Warnings:
config validateuses the same${NAME}env interpolation as startup — missing/malformed placeholders fail validation.policy dumpprints resolved values. If a header action loaded a secret from an env var, the resolved value appears in output. Treat redirected output and CI logs as sensitive.
acl-proxy-extract-capture-body <capture-file.json> > body.binDecodes the base64 body payload from a capture JSON file. Reports errors for invalid JSON, missing bodies, or unsupported encodings.
| 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 |
| 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 |
| 504 Gateway Timeout | Upstream timed out (request_timeout_ms); external auth decision timed out (timeout_ms) |
Adjust timeouts; check upstream latency |
| 508 Loop Detected | Loop protection header found in inbound request | Remove header from clients or adjust loop protection config |
- Clients must trust the proxy CA for CONNECT and transparent HTTPS modes.
- Use
--proxy-cacert certs/ca-cert.pem(CONNECT) or--cacert certs/ca-cert.pem(transparent HTTPS). - If upstream TLS verification fails, check
tls.verify_upstreamand confirm upstream certificates are valid.
acl-proxy config init config/acl-proxy.toml- Export required env vars in the same environment used for validation, startup, and reload.
- Use exact whole-string placeholders only:
${NAME}. - Do not use mixed strings like
Bearer ${TOKEN}.
acl-proxy/
├── src/
│ ├── main.rs # Entry point, CLI dispatch
│ ├── lib.rs # Public module exports
│ ├── cli/mod.rs # CLI parsing and command handlers
│ ├── config/mod.rs # Configuration structs and loading
│ ├── policy/mod.rs # Policy engine and rule compilation
│ ├── app.rs # AppState and shared state (ArcSwap)
│ ├── proxy/
│ │ ├── http.rs # HTTP listener (explicit + transparent)
│ │ ├── https_connect.rs # CONNECT MITM handler
│ │ └── https_transparent.rs # Transparent HTTPS listener
│ ├── external_auth.rs # Webhook and callback handling
│ ├── auth_plugin.rs # Stdio plugin lifecycle
│ ├── capture/mod.rs # Request/response capture
│ ├── certs/mod.rs # CA and per-host cert management
│ ├── logging/mod.rs # Structured logging and rotation
│ └── loop_protection/mod.rs # Loop detection and header injection
├── src/bin/
│ └── extract-capture-body.rs # Helper to decode captured bodies
├── tests/ # Integration tests
├── demos/ # Auth plugin and webhook demos
├── docs/
│ ├── design/ # Internal design documents
│ └── implementation/ # Feature implementation notes
├── config/ # Default config location
├── scripts/ # Release tooling
├── acl-proxy.sample.toml # Comprehensive sample config
├── Cargo.toml
└── CHANGELOG.md
# Build
cargo build
# Run tests (deterministic, offline)
cargo test
# Lint
cargo clippy
# Format
cargo fmt
# Release build
cargo build --releaseThe test suite includes:
- Unit tests for config parsing, policy expansion, logging, capture, and certs.
- Integration tests for HTTP listener modes (explicit + transparent), HTTPS CONNECT MITM, transparent HTTPS (HTTP/1.1 and HTTP/2), HTTP/1.1 upgrade/WebSocket tunneling, reload behavior, egress forwarding, and loop protection.
Release tooling: see scripts/release.mjs and scripts/bump-version.mjs.
demos/external-auth-webapp/— Minimal approval web UIdemos/auth-plugin-stdio/— Stdio-based auth plugindemos/external-auth-termstation-adapter/— TermStation integration
See LICENSE for details.