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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 67 additions & 106 deletions components/egress/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# OpenSandbox Egress Sidecar

The **Egress Sidecar** is a core component of OpenSandbox that provides **FQDN-based egress control**. It runs alongside the sandbox application container (sharing the same network namespace) and enforces declared network policies.
The **Egress** is a core component of OpenSandbox that provides **FQDN-based egress control**.

It runs alongside the sandbox application container (sharing the same network namespace) and enforces declared network policies.

## Features

- **FQDN-based Allowlist**: Control outbound traffic by domain name (e.g., `api.github.com`).
- **Wildcard Support**: Allow subdomains using wildcards (e.g., `*.pypi.org`).
- **Transparent Interception**: Uses transparent DNS proxying; no application configuration required.
- **Experimental: Transparent HTTPS MITM (mitmproxy)**: Optional transparent TLS interception for outbound `80/443` traffic in the sidecar network namespace. See [mitmproxy transparent mode](docs/mitmproxy-transparent.md).
- **Dynamic DNS (dns+nft mode)**: When a domain is allowed and the proxy resolves it, the resolved A/AAAA IPs are added to nftables with TTL so that default-deny + domain-allow is enforced at the network layer.
- **Privilege Isolation**: Requires `CAP_NET_ADMIN` only for the sidecar; the application container runs unprivileged.
- **Graceful Degradation**: If `CAP_NET_ADMIN` is missing, it warns and disables enforcement instead of crashing.
Expand All @@ -33,75 +36,45 @@ The egress control is implemented as a **Sidecar** that shares the network names

## Configuration

- Policy bootstrap & runtime:
- Default deny-all. Initial policy comes from **`OPENSANDBOX_EGRESS_RULES`** (JSON, same shape as `/policy`) unless a policy file wins; empty/`{}`/`null` in env stays deny-all.
- **`OPENSANDBOX_EGRESS_POLICY_FILE`** (optional): path to a JSON policy file on disk. **Startup:** if this variable is set and the file exists, is non-empty, and parses as valid policy, that file is used as the initial policy; otherwise initial policy is loaded from `OPENSANDBOX_EGRESS_RULES` (same when the variable is unset, or the file is missing, empty, or invalid—egress logs a warning and falls back to env).
- **Runtime:** if `OPENSANDBOX_EGRESS_POLICY_FILE` is set, a successful **`POST`**, **`PATCH`**, or **empty-body reset** on `/policy` updates that file to match the policy you just applied. If the variable is unset, the API does not write a policy file.
- `/policy` at runtime; empty body resets to default deny-all.
- HTTP service:
- Listen address: `OPENSANDBOX_EGRESS_HTTP_ADDR` (default `:18080`).
- Auth: `OPENSANDBOX_EGRESS_TOKEN` with header `OPENSANDBOX-EGRESS-AUTH: <token>`; if unset, endpoint is open.
- **Egress rule cap (POST/PATCH):** `OPENSANDBOX_EGRESS_MAX_RULES` (default `4096`). After parsing, `len(egress)` must not exceed this value; otherwise the API returns **413**. Set to **`0`** to disable the limit. Invalid or negative values fall back to the default. This applies only to **`POST /policy`** and **`PATCH /policy`**—not to initial policy loaded from `OPENSANDBOX_EGRESS_RULES` or `OPENSANDBOX_EGRESS_POLICY_FILE` at startup.
- Mode (`OPENSANDBOX_EGRESS_MODE`, default `dns`):
- `dns`: DNS proxy only, no nftables (IP/CIDR rules have no effect at L2).
- `dns+nft`: enable nftables; if nft apply fails, fallback to `dns`. IP/CIDR enforcement and DoH/DoT blocking require this mode.
- **Nameserver exempt**
Set `OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT` to a comma-separated list of **nameserver IPs** (e.g. `26.26.26.26` or `26.26.26.26,100.100.2.116`). Only single IPs are supported; CIDR entries are ignored. Traffic to these IPs on port 53 is not redirected to the proxy (iptables RETURN). In `dns+nft` mode, these IPs are also merged into the nft allow set so proxy upstream traffic to them (sent without SO_MARK) is accepted. Use when the upstream is reachable only via a specific route (e.g. tunnel) and SO_MARK would send proxy traffic elsewhere.
- **DNS and nft mode (nameserver whitelist)**
In `dns+nft` mode, the sidecar automatically allows:
- **127.0.0.1** — so packets redirected by iptables to the proxy (127.0.0.1:15353) are accepted by nft.
- **Nameserver IPs** from `/etc/resolv.conf` — so client DNS and proxy upstream work (e.g. private DNS).
Nameserver IPs are validated (unspecified and loopback are skipped) and capped at the first **10** lines from `/etc/resolv.conf` (not configurable). See [SECURITY-RISKS.md](SECURITY-RISKS.md) for trust and scope of this whitelist.
- **Blocked hostname webhook**
- `OPENSANDBOX_EGRESS_DENY_WEBHOOK`: HTTP endpoint URL. When set, egress asynchronously POSTs JSON **only when a hostname is denied**: `{"hostname": "<original query>", "timestamp": "<RFC3339>", "source": "opensandbox-egress", "sandboxId": "<id-or-empty>"}`. Default timeout 5s, up to 3 retries with exponential backoff starting at 1s; 4xx is not retried, 5xx/network errors are retried.
- `OPENSANDBOX_EGRESS_SANDBOX_ID`: optional sandbox identifier injected into the webhook payload as `sandboxId`. The value is read once at startup (unset → empty string).
- **Allow requirement**: you must allow the webhook host (or its IP/CIDR) in the policy; with default deny, if you don’t explicitly allow it, the webhook traffic will be blocked by egress itself. Example: `{"defaultAction":"deny","egress":[{"action":"allow","target":"webhook.example.com"}]}`. If a broader deny CIDR covers the resolved IP, it will still be blocked—adjust your policy accordingly.
- DoH/DoT blocking:
- DoT (tcp/udp 853) blocked by default.
- Optional DoH over 443: `OPENSANDBOX_EGRESS_BLOCK_DOH_443=true`. If enabled without blocklist, all 443 is dropped.
- DoH blocklist (IP/CIDR, comma-separated): `OPENSANDBOX_EGRESS_DOH_BLOCKLIST="9.9.9.9,1.1.1.1/32,2001:db8::/32"`.
Most deployments only need these settings:

- **Mode**: `OPENSANDBOX_EGRESS_MODE`
- `dns` (default): DNS filtering only
- `dns+nft`: DNS + nftables IP/CIDR enforcement (recommended for strict default-deny)
- **Initial policy**:
- `OPENSANDBOX_EGRESS_RULES` (JSON, same shape as `POST /policy`)
- or `OPENSANDBOX_EGRESS_POLICY_FILE` (if valid file exists, it takes precedence at startup)
- **HTTP API**:
- `OPENSANDBOX_EGRESS_HTTP_ADDR` (default `:18080`)
- `OPENSANDBOX_EGRESS_TOKEN` (optional auth via `OPENSANDBOX-EGRESS-AUTH`)
- **Rule limit**:
- `OPENSANDBOX_EGRESS_MAX_RULES` for `POST/PATCH /policy` (default `4096`, `0` disables cap)

Optional advanced features:

- Nameserver bypass: `OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT`
- Denied hostname webhook: `OPENSANDBOX_EGRESS_DENY_WEBHOOK`, `OPENSANDBOX_EGRESS_SANDBOX_ID`
- DoH/DoT controls: `OPENSANDBOX_EGRESS_BLOCK_DOH_443`, `OPENSANDBOX_EGRESS_DOH_BLOCKLIST`

### Runtime HTTP API

- Default listen address: `:18080` (override with `OPENSANDBOX_EGRESS_HTTP_ADDR`).
- `POST`/`PATCH` enforce `OPENSANDBOX_EGRESS_MAX_RULES` on the resulting `egress` list (see [Configuration](#configuration)).
- Endpoints:
- `GET /policy` — returns the current policy.
- `POST /policy` — replaces the policy. Empty/whitespace/`{}`/`null` resets to default deny-all.
- `PATCH /policy` — merge/append rules at runtime. Body **must** be a JSON array of egress rules (not wrapped in an object). New rules are placed before existing ones (same target overrides), so a later PATCH can override prior wildcard denies with a more specific allow, and vice versa.

Examples:

- DNS allowlist (default deny):
```bash
curl -XPOST http://127.0.0.1:18080/policy \
-d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.bing.com"}]}'
```
- DNS blocklist (default allow):
```bash
curl -XPOST http://127.0.0.1:18080/policy \
-d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.bing.com"}]}'
```
- IP/CIDR only:
```bash
curl -XPOST http://127.0.0.1:18080/policy \
-d '{"defaultAction":"deny","egress":[{"action":"allow","target":"1.1.1.1"},{"action":"deny","target":"10.0.0.0/8"}]}'
```
- Mixed DNS + IP/CIDR:
```bash
curl -XPOST http://127.0.0.1:18080/policy \
-d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.example.com"},{"action":"allow","target":"203.0.113.0/24"},{"action":"deny","target":"*.bad.com"}]}'
```
- Merge-only PATCH (override wildcard deny with a specific allow):
```bash
# baseline: deny *.cloudflare.com
curl -XPOST http://127.0.0.1:18080/policy \
-d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.cloudflare.com"}]}'

# allow a specific host; PATCH rules are prepended, so this wins
curl -XPATCH http://127.0.0.1:18080/policy \
-d '[{"action":"allow","target":"www.cloudflare.com"}]'
```
- `GET /policy`: get current policy
- `POST /policy`: replace policy (`{}`, `null`, empty body => reset to deny-all)
- `PATCH /policy`: merge/append rules (body is JSON array of egress rules)

Quick example:

```bash
curl -XPOST http://127.0.0.1:18080/policy \
-d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.example.com"}]}'
```

### Experimental: Transparent MITM (mitmproxy)

> Status: **Experimental**. APIs, environment variables, and behavior may change.

Optional transparent HTTPS interception for outbound `80/443` traffic in the sidecar network namespace.
See [docs/mitmproxy-transparent.md](docs/mitmproxy-transparent.md) for configuration and limitations.

### Observability (OpenTelemetry)

Expand All @@ -111,7 +84,7 @@ See **[Egress OpenTelemetry reference](docs/opentelemetry.md)** for metrics, str

## Build & Run

### 1. Build Docker Image
### Build Docker Image

```bash
# Build locally
Expand All @@ -121,48 +94,37 @@ docker build -t opensandbox/egress:local .
./build.sh
```

### 2. Run Locally (Docker)

To test the sidecar with a sandbox application:
### Run Locally

1. **Start the Sidecar** (creates the network namespace):
1. Start sidecar:

```bash
docker run -d --name sandbox-egress \
--cap-add=NET_ADMIN \
opensandbox/egress:local
```

*Note: `CAP_NET_ADMIN` is required for `iptables` redirection.*

After start, push policy via HTTP (empty body resets to deny-all):

```bash
curl -XPOST http://11.167.84.130:18080/policy \
-H "OPENSANDBOX-EGRESS-AUTH: $OPENSANDBOX_EGRESS_TOKEN" \
-d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.bing.com"}]}'
```
```bash
docker run -d --name sandbox-egress \
--cap-add=NET_ADMIN \
opensandbox/egress:local
Comment thread
Pangjiping marked this conversation as resolved.
```

2. **Start Application** (shares sidecar's network):
2. Apply policy:

```bash
docker run --rm -it \
--network container:sandbox-egress \
curlimages/curl \
sh
```
```bash
curl -XPOST http://127.0.0.1:18080/policy \
-d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.google.com"}]}'
```

3. **Verify**:
3. Run app container in the same network namespace:

Inside the application container:
```bash
docker run --rm -it \
--network container:sandbox-egress \
curlimages/curl sh
```

```bash
# Allowed domain
curl -I https://google.com # Should succeed
4. Verify from app container:

# Denied domain
curl -I https://github.com # Should fail (resolve error)
```
```bash
curl -I https://google.com
curl -I https://github.com
```

## Development

Expand Down Expand Up @@ -192,7 +154,6 @@ More details in [docs/benchmark.md](docs/benchmark.md).

## Troubleshooting

- **"iptables setup failed"**: Ensure the sidecar container has `--cap-add=NET_ADMIN`.
- **DNS resolution fails for all domains**:
Check upstream reachability from the sidecar (`ip route`, `dig @<upstream> . NS +timeout=3`). In `dns+nft` mode, check logs for `[dns] whitelisting proxy listen + N nameserver(s)`.
- **Traffic not blocked**: If nftables apply fails, the sidecar falls back to dns; check logs, `nft list table inet opensandbox`, and `CAP_NET_ADMIN`.
- **"iptables setup failed"**: ensure sidecar has `--cap-add=NET_ADMIN`.
- **DNS fails for all domains**: check sidecar upstream DNS reachability and logs.
- **Traffic not blocked as expected**: in `dns+nft`, verify nft applied (`nft list table inet opensandbox`) and check sidecar logs for fallback.
16 changes: 14 additions & 2 deletions components/egress/docs/benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ cd components/egress

### View results

- **Terminal**: tables per scenario (latency / throughput vs no-MITM).
- **Terminal**: tables per scenario (latency / throughput vs no-MITM), plus **`E2E latency loss (avg time_total)`** in **ms/request** and **%**.
- **Host `/tmp`**:
- Latency artifacts: `bench-mitm-*-short-*.txt`, `*-download-*.tsv`, `*-wall.txt`, etc.
- **Container metrics** (always written): `bench-mitm-docker-stats-dns_nft.tsv`, `bench-mitm-docker-stats-dns_nft_mitm.tsv` — `unix_ts`, **`/proc/loadavg`** (load1/5/15, …), **`docker stats`** (CPUPerc, MemUsage, …). *`loadavg` inside the container often tracks the host; use for relative trends.*
Expand All @@ -62,11 +62,23 @@ Illustrative only — **same machine, same script**, not a SLA. **MITM** row = *

### `BENCH_SCENARIOS=short` (HEAD storm; **sparse** rows if the phase is short)

Run profile (sample): `10 rounds × 40 URLs × 1 inflight = 400 requests`.

| Metric | `dns+nft` | + mitm |
|--------|-----------|--------|
| **Req/s** | **3.64** | **1.90** (**-47.6%**) |
| **Avg latency (time_total)** | **0.315 s** | **0.605 s** (**+91.9%**) |
| **P50 latency** | **0.136 s** | **0.143 s** (**+5.2%**) |
| **P99 latency** | **1.439 s** | **10.006 s** (**+595.2%**) |
| **E2E latency loss (avg)** | baseline | **+289.88 ms/request (+91.95%)** |

| Metric | `dns+nft` | + mitm |
|--------|-----------|--------|
| **CPUPerc** | Hot sample **~132%** | Hot sample **~232%** |
| **MemUsage** | **~6–10 MiB** | **~58–88 MiB** |

**`CPUPerc` > 100%** on multi-core is normal (container can use more than one core-equivalent per Docker’s metric).

**Takeaway**: peak CPU sample **~1.8×** (**232/132**); RSS much higher with mitmdump. Numbers are **timing-sensitive**; longer runs or **`BENCH_DOCKER_STATS_INTERVAL=0.5`** give denser TSVs.
**Takeaway**: this sample shows clear request-side overhead from transparent MITM, about **+289.88 ms/request** on average with throughput dropping to about half. `P50` is close to baseline while `P99` grows sharply, indicating tail-latency amplification. With only **40 requests**, tail metrics are timing-sensitive; use more rounds or more domains for stable P99.

CPU/memory trend remains consistent: peak CPU sample **~1.8×** (**232/132**), and RSS is much higher with mitmdump. For denser host/container telemetry, use longer runs or **`BENCH_DOCKER_STATS_INTERVAL=0.5`**.
106 changes: 106 additions & 0 deletions components/egress/docs/mitmproxy-transparent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Python mitmproxy Transparent Mode (with Egress)

Transparent mode starts `mitmdump --mode transparent` inside the sidecar and redirects local outbound `TCP 80/443` traffic to the mitmproxy listener via `iptables`. Its core benefits are:

- **No application changes**: no need to set `HTTP_PROXY`; app traffic is intercepted transparently.
- **Observability and extensibility**: use mitm scripts for header injection, auditing, and debugging.
- **Controlled bypass**: use `ignore_hosts` for pass-through TLS (forward only, no decryption).

Typical use case: add L7 visibility/processing at the egress boundary without changing the application networking stack.

## Quick Setup (Minimum Working Config)

### Prerequisites

- Linux network namespace with `CAP_NET_ADMIN` in the container.
- `mitmdump` installed and `mitmproxy` user present in the image (included in official egress image).
- Client/system trusts the mitm root CA; otherwise HTTPS handshakes will fail.

### Enable Transparent MITM

```bash
export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true
```

By default, mitmproxy listens on `18081` and transparent redirect rules are set automatically.

### Common Optional Settings

```bash
# Optional: change listening port (default: 18081)
export OPENSANDBOX_EGRESS_MITMPROXY_PORT=18081

# Optional: enable mitm addon script (e.g., inject request headers)
export OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT=/opt/opensandbox/mitmscripts/add_header.py

# Optional: bypass decryption for selected domains (semicolon-separated regex list)
export OPENSANDBOX_EGRESS_MITMPROXY_IGNORE_HOSTS='.*\.log\.aliyuncs\.com;.*\.example\.internal'
```

## Configuration Reference

| Variable | Required | Purpose | Default |
|------|----------|------|--------|
| `OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT` | Yes | Enable transparent mitmproxy (`1/true/on`, etc.) | Disabled |
| `OPENSANDBOX_EGRESS_MITMPROXY_PORT` | No | mitmdump listen port; `iptables` redirects `80/443` here | `18081` |
| `OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT` | No | mitm addon script path (`-s`) | Empty |
| `OPENSANDBOX_EGRESS_MITMPROXY_IGNORE_HOSTS` | No | Host/IP regex list for TLS pass-through (`;` separated) | Empty |
| `OPENSANDBOX_EGRESS_MITMPROXY_CONFDIR` | No | mitm config and CA directory (passed as `--set confdir=`, also used as `HOME`) | Default directory under `/var/lib/mitmproxy` |
| `OPENSANDBOX_EGRESS_MITMPROXY_UPSTREAM_TRUST_DIR` | No | Trust directory for upstream TLS verification (OpenSSL style) | `/etc/ssl/certs` |

Notes:

- `OPENSANDBOX_EGRESS_MITMPROXY_IGNORE_HOSTS` means **no decryption**, not “completely bypass mitm process”.
- In transparent mode, mitmproxy generally recommends matching by IP/range; verify SNI/resolve behavior if using domain regex only.
- Before mitm, `iptables`, and CA export are ready, `GET /healthz` returns `503 (mitm not ready)` to prevent premature readiness.

## Common Configuration Templates

### 1) Enable Transparent MITM Only

```bash
export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true
```

### 2) Enable with Header Injection

```bash
export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true
export OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT=/opt/opensandbox/mitmscripts/add_header.py
```

Built-in example script: `/opt/opensandbox/mitmscripts/add_header.py` (adds `X-OpenSandbox-Egress: 1`).

### 3) Bypass Decryption for Specific Domains (e.g. log upload)

```bash
export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true
export OPENSANDBOX_EGRESS_MITMPROXY_IGNORE_HOSTS='.*\.log\.aliyuncs\.com'
```

### 4) Use a Fixed CA (consistent fingerprint across replicas)

If CA files already exist in `confdir`, mitmproxy reuses them instead of regenerating on each startup. Typical paths:

- `/var/lib/mitmproxy/.mitmproxy/mitmproxy-ca.pem` (private key)
- `/var/lib/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem` (public cert)

Ensure correct permissions (for example `mitmproxy:mitmproxy`, private key mode `600`).

## Relationship with Policy/DNS

Transparent mitmproxy does not automatically consume egress `NetworkPolicy`. Domain allow/deny behavior is still determined by DNS + (optional) nft rules. If L7 policy enforcement is needed, implement it in mitm scripts.

## Implementation Notes and Limits

Startup flow (high level):

1. Start mitmdump as user `mitmproxy`, listening on `127.0.0.1:<port>`.
2. Wait until the local listener is reachable.
3. Apply IPv4 `iptables` redirect rules: except loopback and mitmproxy-owned traffic, redirect outbound `80/443` to mitm port.

Limits:

- Currently IPv4 `iptables` only; IPv6 is not automatically handled.
- Non-Linux environments (for example local macOS runtime) are not supported for transparent mode.
- Full HTTPS decryption introduces CPU/memory and certificate trust overhead; benchmark before production rollout.
Loading
Loading