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
17 changes: 12 additions & 5 deletions components/egress/docs/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This page lists the OpenTelemetry metrics currently implemented in egress.
|---|---|---|---|
| `egress.dns.query.duration` | Histogram | `s` | Upstream DNS forward latency (recorded for allowed queries). |
| `egress.dns.query.failed_total` | Counter | - | Queries the proxy could not resolve, by `reason`. |
| `egress.dns.reply.failed_total` | Counter | - | Reply writes that failed after a decision, by `stage`. A nonzero count means a query was handled but its answer never reached the client. |
| `egress.policy.denied_total` | Counter | - | Number of DNS queries denied by policy. |
| `egress.nftables.rules.count` | Observable Gauge | `{element}` | Approximate policy size after last successful static apply (fleet profile: summed across every installed subject's policy, 0 while deny-first). |
| `egress.nftables.updates.count` | Counter | - | Number of successful nftables updates (static apply + dynamic IP add). |
Expand Down Expand Up @@ -66,15 +67,21 @@ queried name nor the error text is ever attached:

`egress.nftables.updates.failed_total` covers the other silent failure. Its `operation`
attribute is one of `static_apply`, `dynamic_add`, `remove`, or — in the fleet profile
(OSEP-0022) — `deny_first`, `dispatch_update`, `reset`; `dynamic_add` is the one to
(OSEP-0022) — `deny_first`, `reset`; `dynamic_add` is the one to
alert on, because a failed add means the kernel never learned about IPs the policy allows,
so the chain drops traffic that should pass — which looks exactly like a policy denial from
inside the sandbox while `egress.policy.denied_total` stays flat.

The per-sandbox netns layer (fleet profile) counts its updates under the same operations;
two expected cases are deliberately NOT counted as failures: a sandbox-layer removal whose
netns is already destroyed (the rules died with it), and the startup recovery sweep of
netns that never had a table installed.
`egress.dns.reply.failed_total` covers the last silent failure class: a query that was
**handled** (decided, maybe forwarded and answered upstream) whose reply write then failed.
Until the write error was surfaced, such windows were indistinguishable from "query never
handled" — the fleet-profile case where guest-originated DNS is answered in the proxy but
the reply never reaches the sandbox (issue #1704). Its `stage` attribute is one of
`malformed`, `unknown_source`, `deny`, `upstream_error`, `answer`, and every failure also
emits a `[dns] reply write failed (stage=… remote=… question=…)` warning with the remote
address and query name, so the counter pinpoints the condition and the log line the flow.
Alert on any nonzero value: like `dynamic_add`, an `answer`-stage failure means traffic the
policy allows is not reaching the client.

A `static_apply` failure happens during startup, where the sidecar logs and exits. Metrics
leave through a periodic reader and `os.Exit` skips the deferred shutdown, so that path
Expand Down
187 changes: 99 additions & 88 deletions components/egress/docs/policy-traffic-vault-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,76 +9,102 @@ kernel rules, and credentials.
The sidecar profile differs (single policy, `hook output`, iptables DNS
REDIRECT on 15353); only the fleet model is drawn here.

## 1. Control plane: policy and credential push
## 1. Subject lifecycle: fastlet action protocol → deny-first → active

The server pushes policy and vault revisions over fast-sandbox's own proxy
route. The egress listener binds the Pod netns loopback only — fastlet-proxy
is the only peer and injects `X-Fast-Sandbox-Uid` to route the push to a
subject. There is no sandbox-reachable policy surface.
The Fastlet is the sole lifecycle dispatcher (Sandbox Actions Handler
protocol, `sandbox.fast.io/actions/v1`). The egress Handler implements
`GET /_fastlet/v1/actions/status` (process incarnation probe) and
`POST /_fastlet/v1/actions` (`SET_BINDING` / `LIFECYCLE_HOOK` /
`REMOVE_BINDING`). The binding input **is the policy** (declarative, carried
by the Sandbox CRD `actionBindings`); the attachment block carries the
network identity (source IP, gateway, veth, private CIDR). There is no
file-driven observation source.

A subject is fail-closed from the moment `SET_BINDING` registers it until its
`sandbox.data-plane-ready` Hook succeeds: registration installs deny-first
rules immediately, the policy is held pending, and DNS keeps denying.

```mermaid
stateDiagram-v2
[*] --> absent
absent --> denying: SET_BINDING (input = policy, deny-first install)
denying --> denying: deny-first install failing (Fastlet retries)
denying --> active: LIFECYCLE_HOOK sandbox.data-plane-ready (policy applied)
active --> denying: SET_BINDING null input (binding removed, revert to deny-first)
active --> denying: rebind (new runtimeInstanceId/attachmentId) - policy discarded
active --> absent: REMOVE_BINDING (unload: chain+sets removed)
denying --> absent: REMOVE_BINDING
```

Recovery after egress restart: `ApplyReset` wipes the table, the Handler
serves a new `instanceId`, the Fastlet detects it and replays the latest
`SET_BINDING` followed by the already-reached Hooks (every live subject
re-enters `denying` through the same registration path), and the server's
reconciliation re-pushes credential revisions.

## 2. Control plane: policy and credential push

The egress listener binds the Pod netns loopback only. It serves the action
endpoints (Fastlet) and the proxy-route policy/credential surfaces
(fastlet-proxy is the only peer and injects `X-Fast-Sandbox-Uid` to route a
push to a subject). There is no sandbox-reachable policy surface.

Policy itself rides `SET_BINDING` (the CRD binding is the complete desired
value; updates arrive as new bindings). The proxy route carries credential
vault revisions (memory-only, OSEP-0012 — the binding input is NOT a secret
transport) and runtime policy operations.

```mermaid
sequenceDiagram
autonumber
participant S as Server (OpenSandbox)
participant P as fastlet-proxy
participant F as Fastlet
participant E as Egress listener<br/>(127.0.0.1:18080, loopback)
participant R as Subject registry<br/>(memory)
participant N as nftables<br/>(table opensandbox-fleet)
participant D as DNS proxy<br/>(gateway:53, shared)
participant S as Server (OpenSandbox)
participant P as fastlet-proxy

Note over S: ResolveEndpoint(egress) -> route + route credential
S->>P: PUT /v1/sandboxfleets/{sid}/egress/policy
F->>E: SET_BINDING (sandbox, revision, attachment, policy input)
E->>R: RegisterAndEnforce (denying, deny-first)
E->>N: deny-first install (empty sets + drop chain + dispatch rule)
Note over E: policy stored PENDING - DNS still denies
F->>E: LIFECYCLE_HOOK sandbox.runtime-ready (confirm)
F->>E: LIFECYCLE_HOOK sandbox.data-plane-ready
E->>R: ApplyPolicy (denying -> active, effective = user + always rules)
E->>N: atomic swap (subject chain + static sets, single nft -f)
E->>D: per-query selector now returns this subject's policy
Note over S,P: create-then-configure (server side)
S->>P: PUT /v1/sandboxfleets/{sid}/egress/credential-vault
P->>E: forward (credential verified, X-Fast-Sandbox-Uid added)
alt subject registered (slot observed)
E->>R: ApplyPolicy (denying -> active, effective = user + always rules)
E->>N: atomic swap (subject chain + static sets, single nft -f)
E->>D: per-query selector now returns this subject's policy
else slot not observed yet (create-then-configure race)
alt subject registered
E->>E: apply vault revision (memory-only per subject)
else binding not observed yet (race)
E->>E: cache as pending (TTL, X-Fast-Sandbox-Generation check)
E-->>P: 202 Accepted (push will be applied on registration)
E-->>P: 202 Accepted (push applied on registration)
end
```

## 2. Subject lifecycle: deny-first → active

A subject is fail-closed from the moment its slot is observed until its own
policy lands. Registration installs deny-first rules (empty sets, drop chain,
resolv.conf → gateway) before the policy push can arrive.

```mermaid
stateDiagram-v2
[*] --> absent
absent --> denying: slot Bound observed (poll of slot store)
denying --> denying: deny-first install failing (retry with backoff)
denying --> active: policy push lands (atomic nft swap)
active --> denying: rebind (new generation) - policy discarded, nft reset
active --> absent: slot file deleted (unload: element+chain+sets removed)
denying --> absent: slot file deleted
```

Recovery after egress restart: `ApplyReset` wipes the table, the watch
re-delivers every bound slot (all subjects re-enter `denying`), and the
server's reconciliation re-pushes policies.

## 3. Data plane: outbound traffic flow

Two enforcement layers per sandbox: the authoritative Pod netns `forward`
hook (below), plus a per-sandbox netns OUTPUT chain mirroring the same policy
as defense in depth (`nsenter` from the host, table `opensandbox-fleet-ns`).
The sandbox layer allows loopback, DNS to the slot gateway only (dport 53,
gateway-scoped — the Pod layer enforces DNS policy via the proxy), and the
mirrored deny/dyn/allow verdicts; it catches traffic the forward hook never
sees (sandbox → host-local destinations take the INPUT path). DNS-learned
leases are refreshed in lockstep between both layers by the per-subject
connection refresh loop (Pod netns conntrack, bucketed by source IP, one
batched transaction per tick). Only TCP sessions are renewed — UDP/QUIC
(HTTP/3) relies on DNS lease TTLs; a sandbox-layer mirror miss marks the IPs
pending and redelivers them on the next tick.

Dispatch is a verdict map keyed by
`ip saddr . iifname` (the host veth binding is defense in depth against UDP
spoofing); the master chain defaults to **drop** so unregistered sources are
denied before their slot is even observed.
The authoritative enforcement layer is the Pod netns `forward` hook
(`table opensandbox-fleet`, master chain policy **accept** with an
unmarked-drop tail — the forward path never issues an explicit accept,
because on the fast-sandbox Firecracker bridge topology
(`bridge-nf-call-iptables=1`) an accept verdict returns the frame to the
bridge L2 path and drops it before postrouting). Allowed destinations are
marked in per-subject `hook prerouting` chains (`meta mark set 0x2` for
allow/dyn set members, unconditional for default-allow policies); per-subject
dispatch by `ip saddr` (the source IP is the only dispatch key — an iifname
match would never fire on the bridge topology, where the IP hooks see
skb->dev = the bridge) leads to subject chains whose deny sets drop explicitly,
and the unmarked-drop tail denies everything else (unregistered sources,
deny-first subjects). Intercepted MITM traffic is delivered locally (DNAT)
and enforced by the dedicated INPUT chain on the conntrack original
destination. DNS-learned leases are kept alive by the per-subject connection
refresh loop (Pod netns conntrack, bucketed by source IP, one batched
transaction per tick); only TCP sessions are renewed — UDP/QUIC (HTTP/3)
relies on DNS lease TTLs.

```mermaid
flowchart LR
Expand All @@ -87,17 +113,17 @@ flowchart LR
APP --> TCP[TCP/UDP egress]
end

DNSQ -->|resolv.conf rewritten to gateway| GW[gateway:53]
DNSQ -->|addressed to gateway:53| GW[gateway:53 - REDIRECT to :15353]
GW --> DP[DNS proxy - per-query policy by source IP]
DP -->|subject unknown / denied| NX[NXDOMAIN]
DP -->|allowed| UP[Upstream resolver]
UP -->|answer| DNSQ
UP -->|resolved IPs with TTL| DYN[subject dynamic allow set - timeout lease]

TCP -->|via host veth| DISPATCH[dispatch chain - hook forward, policy DROP]
TCP -->|via host veth| DISPATCH[dispatch chain - hook forward, ACCEPT + unmarked-drop tail]
DISPATCH -->|ct state established,related| ACC1[accept]
DISPATCH -->|tcp/udp dport 853| DROP1[drop - DoT blocked]
DISPATCH -->|vmap: ip saddr . iifname| JUMP[jump subj_&lt;id&gt; chain]
DISPATCH -->|ip saddr| JUMP[jump subj_&lt;id&gt; chain]

JUMP -->|deny_v4/v6 sets| DROP2[drop]
JUMP -->|dyn_v4/v6 + allow_v4/v6 sets| ACC2[accept]
Expand All @@ -110,16 +136,12 @@ flowchart LR

## 4. Credential vault

> Status: the **control plane** below (revision push + per-subject in-memory
> store) is implemented. The **data plane** (shared mitmdump, REDIRECT with
> preserved source IP, subject-aware active API, addon wiring) is **design
> only — not yet implemented**. The diagram shows the target shape.

Vault revisions are pushed over the proxy route and held **memory-only** per
subject (OSEP-0012 model — no Secret volume, nothing written to egress disk).
The shared mitmdump instance selects the subject's vault by the client's
source IP (transparent REDIRECT preserves it); a rebind swaps the revision in
memory and new flows pick up the new credentials.
source IP (transparent REDIRECT/DNAT preserves it); a revision push rebinds
in memory and new flows pick up the new credentials. See
[fleet-mitm-data-plane](../../../docs/components/egress-fleet-mitm-data-plane.md).

```mermaid
sequenceDiagram
Expand All @@ -134,45 +156,34 @@ sequenceDiagram
S->>P: PUT /v1/sandboxfleets/{sid}/egress/credential-vault (full revision)
P->>E: forward (UID header -> subject)
E->>V: replace revision (memory-only, new flows rebind)
C->>M: HTTP(S) flow (REDIRECT preserves source IP)
C->>M: HTTP(S) flow (DNAT preserves source IP)
M->>M: script: client source IP -> subject -> subject's vault
M->>V: resolve credential/binding for the flow
V-->>M: credential (active snapshot)
M-->>C: proxied flow with credential applied
```

Missing pieces for the data plane (all subject-aware extensions of the
sidecar's single-vault mechanism — `mitmscripts/system.py` + the `/_active`
unix socket):

- shared mitmdump startup in the fleet assembly, and the per-sandbox netns
OUTPUT REDIRECT pairs installed from the host (`nsenter`), see
[fleet-mitm-data-plane](../../../docs/components/egress-fleet-mitm-data-plane.md)
- subject dispatch inside the shared active socket: `/_active?clientIp=…`
→ `registry.Resolve` → subject vault snapshot (one socket, no per-subject
sockets, no UID in the socket protocol)
- addon wiring: `flow.client.peername[0]` -> active socket -> vault

## 5. Fail-closed invariants

| Transition / event | Guarantee |
|---|---|
| Slot observed, no policy yet | deny-first: empty nft sets + drop chain, resolv.conf → gateway, DNS NXDOMAIN |
| Policy push before slot | cached pending (TTL); applied on registration; generation mismatch discards it |
| Policy push lands | one atomic `nft -f` transaction (chain + static sets); DNS selector switches per subject |
| Rebind (new generation) | policy discarded in registry AND nft chain/sets/DNS leases force-reset |
| Unload (slot deleted) | dispatch element + chain + all sets removed in one transaction |
| Egress restart | stale rules wiped (ApplyReset), all subjects re-enter denying, server re-pushes |
| Unregistered source | master chain policy drop — denied before the slot is ever observed |
| Unparseable slot record | fail closed (event error logged, subject never activated) |
| SET_BINDING, no data-plane-ready yet | deny-first: empty nft sets + drop chain, gateway DNS REDIRECT, DNS NXDOMAIN |
| Vault push before SET_BINDING | cached pending (TTL); applied on registration; generation mismatch discards it |
| data-plane-ready lands | one atomic `nft -f` transaction (chain + static sets); DNS selector switches per subject |
| Rebind (new runtimeInstanceId/attachmentId) | policy discarded in registry AND nft chain/sets/DNS leases force-reset |
| Unload (REMOVE_BINDING) | chain + all sets removed in one transaction; stale fence ignored |
| Egress restart | stale rules wiped (ApplyReset); new instanceId triggers Fastlet replay of SET_BINDING + reached Hooks |
| Unregistered source | unmarked -> master-chain tail drop — denied before the binding is ever observed |
| Malformed action envelope | rejected (never silently ignored); the subject is never activated |
| data-plane-ready without pending policy | failed (protocol violation) — the subject stays denying |

## Component map

| Concern | Implementation |
|---|---|
| Subject state machine | `pkg/subject` (`MemoryRegistry`, `Controller`) |
| Slot store (identity) | `pkg/slotsource` (`FileParser`, polling `FileSource`) |
| Per-subject nft rules | `pkg/fleetnft` (verdict-map dispatch, atomic swap, reset) |
| Actions wire model + validation | `pkg/actionhandler` (envelope, operations, Hooks) |
| Subject state machine | `pkg/subject` (`MemoryRegistry`, lifecycle hooks) |
| Per-subject nft rules | `pkg/fleetnft` (dispatch rules, atomic swap, reset) |
| Actions endpoints + lifecycle mapping | `fleet_actions.go` (SET_BINDING / LIFECYCLE_HOOK / REMOVE_BINDING) |
| Policy/vault HTTP surface | `fleet_server.go` (UID routing, pending cache, per-subject vault) |
| DNS per-query dispatch | `pkg/dnsproxy` `SetQueryPolicySelector` |
| resolv.conf rewrite | `pkg/resolvrewrite` |
Loading
Loading