sequenceDiagram
participant App as App Container
participant IPT as iptables
participant BotBox as BotBox Sidecar
participant API as Upstream API
App->>IPT: GET /v1/models<br/>Host: api.openai.com
IPT->>BotBox: REDIRECT :80 → :8080
Note over BotBox: Allowlist check → allow
Note over BotBox: Header rewrite → Authorization: Bearer sk-...
Note over BotBox: TLS origination → HTTPS
BotBox->>API: GET /v1/models<br/>Authorization: Bearer sk-...
API-->>BotBox: 200 OK (response body)
BotBox-->>App: 200 OK (streamed)
When HTTPS interception is enabled, outbound port-443 traffic follows this path:
sequenceDiagram
participant App as App Container
participant IPT as iptables
participant TLS as BotBox HTTPS Interception<br/>(port 8443)
participant Proxy as BotBox Proxy Handler
participant API as Upstream API
App->>IPT: TLS ClientHello<br/>SNI: api.openai.com
IPT->>TLS: REDIRECT :443 → :8443
Note over TLS: Issue leaf cert for api.openai.com<br/>(signed by local CA, cached in LRU)
TLS->>App: ServerHello + leaf cert
App->>TLS: GET /v1/models<br/>Host: api.openai.com
Note over TLS: Validate: origin-form only,<br/>SNI/Host match, port 443
TLS->>Proxy: Decrypted HTTP request
Note over Proxy: Allowlist check → allow
Note over Proxy: Header rewrite → Authorization: Bearer sk-...
Note over Proxy: TLS origination → HTTPS
Proxy->>API: GET /v1/models<br/>Authorization: Bearer sk-...
API-->>Proxy: 200 OK (response body)
Proxy-->>TLS: 200 OK
TLS-->>App: 200 OK (re-encrypted)
The proxy handler executes these steps for each request:
- Extract host — from URI authority (explicit proxy) or Host header (transparent proxy)
- Allowlist check — O(1) HashMap lookup + port check; deny → 403
- Strip hop-by-hop headers —
Connection,Proxy-Authorization, etc. - Set Host header — for the upstream TLS handshake (SNI)
- Header rewrite — delete existing header first (prevents smuggling), then add with injected secret
- TLS origination — rewrite
http://→https://, connect via rustls - Stream response — forward upstream response body without buffering
src/
├── allowlist.rs # O(1) HashMap host lookup, IPv6-aware port stripping
├── config.rs # YAML config parsing with serde validation
├── error.rs # ProxyError: SecretNotFound, InvalidHeaderName, InvalidHeaderValue
├── header_rewrite.rs # Delete-then-add pattern to prevent header smuggling
├── https_interception.rs # HTTPS interception: CA loading, cert cache, TLS listener, request validation
├── lib.rs # Module re-exports
├── logging.rs # tracing JSON subscriber setup
├── main.rs # Server startup, signal handling, graceful shutdown
├── metrics.rs # Prometheus counters/histograms on separate port
├── proxy.rs # Core handler: allowlist → rewrite → TLS → forward
├── secrets.rs # K8s directory secret store with inotify hot-reload (notify + ArcSwap)
└── tls.rs # rustls HTTPS-only connector (ring backend, webpki roots)
An init container runs before BotBox to install NAT + filter rules.
This repo provides an optional init image (Docker target iptables-init) which runs scripts/iptables-init.sh to apply the rules below:
docker build --target iptables-init -t botbox-iptables-init:test .Example init container:
- name: iptables-init
image: botbox-iptables-init:test
env:
- name: BOTBOX_ENABLE_IPV6
value: "1" # required — set to "0" if ip6tables or ip6table_nat is unavailable
# - name: BOTBOX_UID
# value: "1337"
# - name: BOTBOX_PROXY_PORT
# value: "8080"
securityContext:
runAsUser: 0
runAsNonRoot: false
capabilities:
add: [NET_ADMIN]The init script supports overrides via environment variables:
BOTBOX_UID(default1337)BOTBOX_PROXY_PORT(default8080)BOTBOX_REDIRECT_FROM_PORT(default80)BOTBOX_ENABLE_HTTPS_INTERCEPTION(default0— set to1to add the port-443 → 8443 NAT redirect)BOTBOX_HTTPS_INTERCEPTION_PORT(default8443)BOTBOX_ENABLE_IPV6(required, no default —1to mirror all rules via ip6tables,0for IPv4-only)BOTBOX_NAT_CHAIN(defaultEGRESS_REDIRECT)BOTBOX_FILTER_CHAIN(defaultEGRESS_FILTER)BOTBOX_IPTABLES_WAIT_SECONDS(default5)
iptables -t nat -N EGRESS_REDIRECT
iptables -t nat -A EGRESS_REDIRECT -o lo -j RETURN
iptables -t nat -A EGRESS_REDIRECT -m owner --uid-owner 1337 -j RETURN
iptables -t nat -A EGRESS_REDIRECT -p tcp --dport 80 -j REDIRECT --to-port 8080
iptables -t nat -I OUTPUT 1 -p tcp -j EGRESS_REDIRECT
iptables -N EGRESS_FILTER
iptables -A EGRESS_FILTER -o lo -j RETURN
iptables -A EGRESS_FILTER -m owner --uid-owner 1337 -j RETURN
iptables -A EGRESS_FILTER -p udp --dport 53 -j RETURN
iptables -A EGRESS_FILTER -p tcp --dport 53 -j RETURN
iptables -A EGRESS_FILTER -p tcp -j DROP
iptables -A EGRESS_FILTER -p udp -j DROP
iptables -I OUTPUT 1 -j EGRESS_FILTER
| Rule | Purpose |
|---|---|
NAT: -o lo -j RETURN |
Skip loopback traffic (healthz probes, metrics scraping) |
NAT: --uid-owner 1337 -j RETURN |
Skip proxy's own outbound connections (prevents redirect loops) |
NAT: --dport 80 -j REDIRECT --to-port 8080 |
Redirect HTTP to the proxy |
NAT: --dport 443 -j REDIRECT --to-port 8443 |
Redirect HTTPS to the interception listener (when enabled) |
NAT: -I OUTPUT 1 -j EGRESS_REDIRECT |
Insert the NAT redirect chain at the top of OUTPUT (ensures priority over existing rules) |
Filter: -o lo -j RETURN |
Allow loopback traffic |
Filter: --uid-owner 1337 -j RETURN |
Allow proxy's upstream HTTPS connections |
Filter: -p udp --dport 53 -j RETURN |
Allow DNS resolution |
Filter: -p tcp --dport 53 -j RETURN |
Allow DNS over TCP |
Filter: -p tcp -j DROP |
Block all other direct outbound TCP from app containers |
Filter: -p udp -j DROP |
Block all other direct outbound UDP from app containers (prevents QUIC bypass) |
Filter: -I OUTPUT 1 -j EGRESS_FILTER |
Insert the filter chain at the top of OUTPUT (ensures priority over existing rules) |
BotBox runs as UID 1337 (Istio convention). This ensures its upstream HTTPS connections are not redirected back to itself. Ensure application containers do NOT run as UID 1337, or they can bypass the owner-match rules.
The proxy supports both modes via extract_host():
- Explicit — client sends
GET http://host/path; host is extracted from URI authority - Transparent — client sends
GET /pathwithHost: host; host is extracted from the Host header
After iptables redirect, applications send standard HTTP requests and BotBox transparently handles them.
initContainers:
- name: iptables-init # 1. Regular init: installs iptables rules, exits
- name: botbox # 2. Sidecar init (restartPolicy: Always): runs for pod lifetime
containers:
- name: app # 3. Main container: your applicationKubernetes runs init containers sequentially, so iptables rules are in place before the proxy starts accepting connections.
listen_addr: "127.0.0.1" # Proxy listen address (loopback only)
listen_port: 8080 # Proxy listen port
metrics_port: 9090 # Metrics/healthz port
secrets_dir: "/var/run/secrets/botbox" # K8s Secret mount path
max_connections: 1024 # Connection limit (semaphore)
allow_non_loopback: false # Must be true to bind non-loopback addresses
egress_policy:
default_action: deny # deny | allow
rules:
- host: api.openai.com # Exact host match (port-aware, default: 443 only)
action: allow
header_rewrites:
- name: Authorization # Header to inject
value: "Bearer {value}" # {value} is replaced with secret contents
secret_ref: openai-api-key # Filename in secrets_dir
- host: api.anthropic.com
action: allow
header_rewrites:
- name: x-api-key
value: "{value}"
secret_ref: anthropic-api-key
- name: anthropic-version
value: "2023-06-01" # Static value (no secret_ref needed)
- host: custom-api.example.com
action: allow
allowed_ports: [443, 8443] # Allow non-standard portFor each rewrite rule, the proxy:
- Deletes any existing header with the same name (prevents clients from smuggling their own credentials)
- Adds the header with the configured value
If secret_ref is specified, {value} is replaced with the file contents from the secrets directory. If the secret file is missing, the request fails with 500.
The secrets directory is watched via inotify (Linux) using the notify crate. When a file changes, the entire secret store is reloaded atomically via ArcSwap. This means:
- Key rotation takes effect without restarting the proxy
- Kubernetes Secret updates (via kubelet sync) are picked up automatically
| Port | Path | Description |
|---|---|---|
| 8080 | — | HTTP proxy listener (loopback only) |
| 8443 | — | HTTPS interception TLS listener (loopback only, when enabled) |
| 9090 | /healthz |
Returns 200 when ready, 503 otherwise |
| 9090 | /metrics |
Prometheus exposition format |
| 9090 | other | Returns 404 |
All metrics are exposed in Prometheus text format on the metrics port.
| Metric | Type | Labels | Description |
|---|---|---|---|
botbox_requests_total |
Counter | host, decision |
Total requests by host and allow/deny decision |
botbox_request_duration_seconds |
Histogram | host |
Upstream request duration |
botbox_upstream_errors_total |
Counter | host, status_code |
Upstream errors (connection failures, timeouts, HTTP errors) |
botbox_header_rewrites_total |
Counter | host |
Header rewrite operations |
botbox_tls_handshakes_total |
Counter | result |
HTTPS interception TLS handshakes (ok, io_error, timeout) |
botbox_https_interception_cert_issued_total |
Counter | result |
Certificates issued or denied (issued, denied) |
botbox_https_interception_cert_error_total |
Counter | error_type |
Certificate generation errors (signing_failed) |
botbox_https_interception_cert_cache_total |
Counter | result |
Cert cache lookups (hit, miss) |
botbox_https_interception_host_mismatch_total |
Counter | — | SNI/Host header mismatch detections |
On SIGINT or SIGTERM:
- Stop accepting new connections
- Drain in-flight connections (up to 30 seconds)
- Abort remaining connections after timeout
- Shut down metrics server (5-second drain)
- 71 unit tests — allowlist (13), config (14), header_rewrite (6), secrets (9), proxy (29)
- 11 integration tests — denied 403, CONNECT 405, missing host 400, metrics, healthz (ready/not-ready), 404, header injection, allowed forward, missing secret 500, timeout 504
- E2E —
tests/e2e/manifests/egress-test.yamlruns on a kind cluster with a real OpenAI API call (dummy key → 401)