Skip to content

Commit 82088c6

Browse files
stainluclaude
andcommitted
feat(networking: limited, shipment 1/3): egress-proxy image + schema
Shipment 1 of the three-shipment rollout in docs/designs/networking-limited.md. Dark launch — schema accepts `limited` + allowedHosts, the egress-proxy image builds and publishes, but the pool doesn't wire it up yet. Zero behavior change for any existing deploy. Schema (NetworkingSchema in src/orchestrator/types.ts): - Extended to a discriminated union {unrestricted | limited}. - allowedHosts validated: hostnames + wildcard prefixes only. IP literals, CIDRs, URL schemes, ports, paths all rejected with a specific error message. Max 256 entries, 253 chars each. - 13 new types tests cover every rejection case. Egress-proxy sidecar (docker/egress-proxy/): - proxy.mjs uses stdlib node:http + node:net + node:dgram. No hand- rolled HTTP parser (advisor flagged that as a footgun class). - allowlist.mjs: exact-match + wildcard prefix (`*.x.com` matches `a.x.com` and `a.b.x.com` but NOT bare `x.com`). Industry convention, explicit in the README. - dns.mjs: parses the first question's name, synthesizes RFC-compliant NXDOMAIN responses (parseable by getaddrinfo/dig) for denied hosts, forwards allowed queries to an upstream resolver. - Dockerfile on node:22-alpine. Final image ~65 MB. EXPOSE covers 8118 (HTTP proxy), 8119 (healthz), 53/udp (DNS). - 23 stdlib node:test cases covering host matching (exact, wildcard, apex exclusion, suffix-boundary edge cases) + DNS (minimal parse, truncated/malformed input rejection, NXDOMAIN round-trip through our own parser). Runtime smoke (manually verified): - healthz returns {ok: true, session_id} - allowed plain HTTP → 200 - denied plain HTTP → 403 - denied CONNECT → 403 (curl sees status=000 because the TLS tunnel never established, which is the correct behavior) CI: - test.yaml gate now runs `node --test` on the proxy suite. - publish-images.yaml builds and publishes a third GHCR image (openclaw-managed-agents-egress-proxy) alongside orchestrator/agent. - vitest.config.ts excludes docker/** so the node:test-flavored .mjs files in the proxy don't get scanned by vitest. Full test suite: 139 vitest passing (was 125) + 23 proxy passing (new). Shipment 2 will wire the pool to actually spawn this sidecar when the session's environment has networking.type === "limited". Shipment 3 adds the full Linux-Docker E2E proving raw-socket bypass + AWS IMDS SSRF are both blocked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3c12cb1 commit 82088c6

12 files changed

Lines changed: 940 additions & 8 deletions

File tree

.github/workflows/publish-images.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ name: publish-images
88
# Packages land at:
99
# ghcr.io/stainlu/openclaw-managed-agents-orchestrator:{latest,sha-<sha>}
1010
# ghcr.io/stainlu/openclaw-managed-agents-agent:{latest,sha-<sha>}
11+
# ghcr.io/stainlu/openclaw-managed-agents-egress-proxy:{latest,sha-<sha>}
1112
#
1213
# After the first successful publish, make both packages public in the GitHub
1314
# UI (user profile -> Packages -> each package -> Package settings ->
@@ -46,9 +47,17 @@ jobs:
4647
- name: orchestrator
4748
dockerfile: Dockerfile.orchestrator
4849
image: openclaw-managed-agents-orchestrator
50+
context: .
4951
- name: agent
5052
dockerfile: Dockerfile.runtime
5153
image: openclaw-managed-agents-agent
54+
context: .
55+
- name: egress-proxy
56+
dockerfile: docker/egress-proxy/Dockerfile
57+
image: openclaw-managed-agents-egress-proxy
58+
# Proxy image only needs its own directory; COPY is relative
59+
# to the context root, and the Dockerfile lives inside it.
60+
context: docker/egress-proxy
5261

5362
steps:
5463
- name: Checkout
@@ -80,7 +89,7 @@ jobs:
8089
- name: Build and push ${{ matrix.name }}
8190
uses: docker/build-push-action@v6
8291
with:
83-
context: .
92+
context: ${{ matrix.context }}
8493
file: ${{ matrix.dockerfile }}
8594
platforms: linux/amd64,linux/arm64
8695
push: true

.github/workflows/test.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ jobs:
6767
- name: apply-provider-config.mjs syntax
6868
run: node --check docker/apply-provider-config.mjs
6969

70+
# Egress-proxy sidecar (networking: limited enforcement). Shipped
71+
# as its own tiny image with no deps — uses node:test built-in.
72+
- name: Egress-proxy — syntax
73+
run: |
74+
node --check docker/egress-proxy/proxy.mjs
75+
node --check docker/egress-proxy/allowlist.mjs
76+
node --check docker/egress-proxy/dns.mjs
77+
78+
- name: Egress-proxy — unit tests
79+
working-directory: docker/egress-proxy
80+
run: node --test allowlist.test.mjs dns.test.mjs
81+
7082
# TypeScript SDK lives in its own package with its own deps (vitest,
7183
# typescript). Install via npm (not pnpm workspaces) so it stays
7284
# self-contained and can be published independently.

docker/egress-proxy/Dockerfile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Egress-proxy sidecar image for `networking: limited` sessions.
2+
#
3+
# Zero runtime dependencies — just stdlib `node:http` + `node:net` +
4+
# `node:dgram`. node:alpine is the smallest Node distribution that still
5+
# ships the full stdlib. Final image is ~65 MB.
6+
#
7+
# Design doc: docs/designs/networking-limited.md
8+
9+
FROM node:22-alpine
10+
11+
LABEL org.opencontainers.image.source="https://github.com/stainlu/openclaw-managed-agents"
12+
LABEL org.opencontainers.image.description="Egress-filtering sidecar for networking: limited sessions"
13+
LABEL org.opencontainers.image.licenses="MIT"
14+
15+
WORKDIR /app
16+
17+
# Copy only the three files that run in production. .test.mjs files
18+
# exist but don't ship in the image.
19+
COPY proxy.mjs allowlist.mjs dns.mjs ./
20+
21+
USER node
22+
23+
# The proxy binds:
24+
# TCP 8118 — HTTP(S) proxy
25+
# TCP 8119 — /healthz
26+
# UDP 53 — DNS filter
27+
# EXPOSE is metadata only; actual publishing happens via docker-run -p.
28+
EXPOSE 8118/tcp 8119/tcp 53/udp
29+
30+
# Healthcheck hits /healthz via the standard HTTP endpoint.
31+
HEALTHCHECK --interval=5s --timeout=2s --start-period=5s --retries=3 \
32+
CMD node -e "require('http').get('http://127.0.0.1:8119/healthz', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
33+
34+
ENTRYPOINT ["node", "/app/proxy.mjs"]

docker/egress-proxy/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# openclaw-egress-proxy
2+
3+
The sidecar that runs next to a `networking: limited` agent container and enforces an egress allowlist at both the HTTP proxy layer (TCP 8118) and the DNS layer (UDP 53).
4+
5+
Design doc: [`docs/designs/networking-limited.md`](../../docs/designs/networking-limited.md).
6+
7+
## Ports
8+
9+
| Port | Proto | Purpose |
10+
|---|---|---|
11+
| `8118` | TCP | HTTP(S) proxy. The agent container's `HTTP_PROXY` / `HTTPS_PROXY` env vars point here. |
12+
| `8119` | TCP | `GET /healthz` liveness probe (orchestrator uses this to decide when the sidecar is ready). |
13+
| `53` | UDP | DNS filter. The agent container's `--dns` flag points here so raw `socket` / `getaddrinfo` code hits the allowlist too, not just HTTP clients. |
14+
15+
## Config (env vars)
16+
17+
| Env | Required | Default | Meaning |
18+
|---|---|---|---|
19+
| `OPENCLAW_EGRESS_ALLOWED_HOSTS` | yes || JSON array of hostname patterns. `"api.openai.com"` (exact) or `"*.example.com"` (wildcard prefix, any depth, doesn't match the bare apex). |
20+
| `OPENCLAW_EGRESS_SESSION_ID` | yes || Session id for log correlation. Surfaced in every log line. |
21+
| `OPENCLAW_EGRESS_UPSTREAM_DNS` | no | `1.1.1.1` | Resolver used when forwarding allowed DNS queries. |
22+
| `OPENCLAW_EGRESS_HTTP_PORT` | no | `8118` | Override for testing. |
23+
| `OPENCLAW_EGRESS_HEALTHZ_PORT` | no | `8119` | Override for testing. |
24+
| `OPENCLAW_EGRESS_DNS_PORT` | no | `53` | Override for testing. |
25+
26+
## Enforcement boundary
27+
28+
**What is enforced:**
29+
- HTTP/HTTPS requests from anything that respects the standard proxy env vars (Node `fetch`, Python `requests`, `curl`, `git`, etc.) are filtered by host. Denied hosts get `403 Forbidden`.
30+
- DNS resolution is filtered by name. Denied hosts return `NXDOMAIN`, so even a raw-socket caller that bypasses the proxy can't resolve them.
31+
- Used together with a `--internal` Docker network topology (see design doc), there is no path out for a confined container except through this sidecar.
32+
33+
**What is NOT enforced:**
34+
- Per-URL path allowlist (e.g. "only `POST /v1/chat/completions`"). Allowlist is host-level only.
35+
- Egress to an allowlisted host's IP range if a different hostname resolves there (shared-IP CDN footgun — not a v1 concern).
36+
- Side-channel data leaks via timing or allowed hosts (e.g. encoding secrets into DNS queries against an allowlisted domain).
37+
38+
## Logging
39+
40+
One JSON-per-line to stdout per allow/deny decision plus proxy readiness. Example:
41+
42+
```json
43+
{"ts":"2026-04-17T20:04:00.000Z","session_id":"ses_abc","decision":"allow","protocol":"connect","host":"api.openai.com","port":443}
44+
{"ts":"2026-04-17T20:04:01.000Z","session_id":"ses_abc","decision":"deny","protocol":"dns","host":"evil.example.org"}
45+
```
46+
47+
Docker's stdout capture picks these up. For a shared host, pipe to the operator's log aggregator like any other container.

docker/egress-proxy/allowlist.mjs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Shared host-allowlist matcher for the egress-proxy sidecar.
2+
//
3+
// Two pattern styles are allowed in the config:
4+
// - Exact hostname: "api.example.com" matches ONLY "api.example.com".
5+
// - Wildcard prefix: "*.example.com" matches any name ending in
6+
// ".example.com" at any depth (so "foo.example.com" and
7+
// "a.b.c.example.com" both match), but does NOT match
8+
// "example.com" itself. List both if you want that too.
9+
//
10+
// Normalization: we lowercase both the config entries and the query
11+
// before matching, strip trailing dots, and reject any entry that
12+
// looks like an IP literal (caught upstream by the zod schema, but
13+
// we guard here too so bad input can't confuse the matcher).
14+
//
15+
// Kept dependency-free so it runs the same way in the sidecar and in
16+
// the unit tests.
17+
18+
/** @param {string} s */
19+
function normalize(s) {
20+
let out = s.toLowerCase().trim();
21+
if (out.endsWith(".")) out = out.slice(0, -1);
22+
return out;
23+
}
24+
25+
/**
26+
* Compile a list of patterns into a matcher. Returns a function that
27+
* takes a hostname and returns true if it is allowed.
28+
*
29+
* @param {string[]} patterns
30+
* @returns {(host: string) => boolean}
31+
*/
32+
export function compileAllowlist(patterns) {
33+
const exact = new Set();
34+
/** @type {string[]} */
35+
const wildcardSuffixes = [];
36+
for (const raw of patterns) {
37+
const p = normalize(raw);
38+
if (p.length === 0) continue;
39+
if (p.startsWith("*.")) {
40+
// Strip the "*" but keep the leading dot so the suffix check
41+
// enforces at least one label in front.
42+
const suffix = p.slice(1); // ".example.com"
43+
wildcardSuffixes.push(suffix);
44+
} else {
45+
exact.add(p);
46+
}
47+
}
48+
return (host) => {
49+
const h = normalize(host);
50+
if (h.length === 0) return false;
51+
if (exact.has(h)) return true;
52+
for (const suffix of wildcardSuffixes) {
53+
// suffix is ".example.com"; require the host to end with it AND
54+
// have at least one character before it (so a bare "example.com"
55+
// doesn't match "*.example.com").
56+
if (h.length > suffix.length && h.endsWith(suffix)) return true;
57+
}
58+
return false;
59+
};
60+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { strict as assert } from "node:assert";
2+
import { describe, it } from "node:test";
3+
4+
import { compileAllowlist } from "./allowlist.mjs";
5+
6+
describe("compileAllowlist — exact matches", () => {
7+
it("matches an exact hostname, case-insensitively", () => {
8+
const m = compileAllowlist(["api.openai.com"]);
9+
assert.equal(m("api.openai.com"), true);
10+
assert.equal(m("API.OpenAI.Com"), true);
11+
assert.equal(m("other.openai.com"), false);
12+
});
13+
14+
it("strips a trailing dot on the input (FQDN form)", () => {
15+
const m = compileAllowlist(["api.openai.com"]);
16+
assert.equal(m("api.openai.com."), true);
17+
});
18+
19+
it("strips a trailing dot on the pattern too", () => {
20+
const m = compileAllowlist(["api.openai.com."]);
21+
assert.equal(m("api.openai.com"), true);
22+
});
23+
24+
it("returns false for the empty string", () => {
25+
const m = compileAllowlist(["api.openai.com"]);
26+
assert.equal(m(""), false);
27+
});
28+
29+
it("rejects hostnames not in the list", () => {
30+
const m = compileAllowlist(["api.openai.com"]);
31+
assert.equal(m("evil.example.org"), false);
32+
assert.equal(m("api.openai.com.evil.example.org"), false);
33+
});
34+
});
35+
36+
describe("compileAllowlist — wildcard prefixes", () => {
37+
it("matches any subdomain at any depth, but NOT the apex", () => {
38+
const m = compileAllowlist(["*.googleapis.com"]);
39+
assert.equal(m("maps.googleapis.com"), true);
40+
assert.equal(m("a.b.c.googleapis.com"), true);
41+
// The apex is explicitly excluded — operators must list it separately.
42+
assert.equal(m("googleapis.com"), false);
43+
});
44+
45+
it("doesn't match an unrelated suffix that happens to share letters", () => {
46+
const m = compileAllowlist(["*.example.com"]);
47+
// Attacker domain containing "example.com" as a label: the suffix
48+
// check must NOT match because the match suffix is ".example.com"
49+
// which isn't literally present at the right boundary.
50+
assert.equal(m("myexample.com"), false);
51+
assert.equal(m("example.com.evil.net"), false);
52+
});
53+
54+
it("handles multiple wildcards in the config", () => {
55+
const m = compileAllowlist(["*.googleapis.com", "*.amazonaws.com"]);
56+
assert.equal(m("s3.us-east-1.amazonaws.com"), true);
57+
assert.equal(m("maps.googleapis.com"), true);
58+
assert.equal(m("api.openai.com"), false);
59+
});
60+
61+
it("combines exact and wildcard patterns", () => {
62+
const m = compileAllowlist(["openai.com", "*.openai.com"]);
63+
assert.equal(m("openai.com"), true); // exact
64+
assert.equal(m("api.openai.com"), true); // wildcard
65+
assert.equal(m("other.org"), false);
66+
});
67+
});
68+
69+
describe("compileAllowlist — edge cases", () => {
70+
it("returns false on an empty config", () => {
71+
const m = compileAllowlist([]);
72+
assert.equal(m("api.openai.com"), false);
73+
assert.equal(m("anything"), false);
74+
});
75+
76+
it("skips empty string entries in the config", () => {
77+
const m = compileAllowlist(["", "api.openai.com", ""]);
78+
assert.equal(m("api.openai.com"), true);
79+
});
80+
81+
it("treats whitespace-surrounded entries as trimmed", () => {
82+
const m = compileAllowlist([" api.openai.com "]);
83+
assert.equal(m("api.openai.com"), true);
84+
});
85+
});

docker/egress-proxy/dns.mjs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Minimal DNS wire-format parser + NXDOMAIN synthesizer. Scope is
2+
// narrow: we parse just enough of an incoming query to extract the
3+
// first question's name, decide allow/deny, and either forward the
4+
// original query to the upstream resolver or return an NXDOMAIN
5+
// response. No zone data, no caching, no DNSSEC.
6+
//
7+
// RFC 1035 message format:
8+
// HEADER (12 bytes): id | flags | qdcount | ancount | nscount | arcount
9+
// QUESTION * qdcount: name (length-prefixed labels + null) | qtype (2) | qclass (2)
10+
//
11+
// Labels are length-prefixed; 0x00 terminates the name. Message
12+
// compression (0xC0 pointers) CAN appear in questions though it's
13+
// extremely rare — we still handle it defensively.
14+
15+
/**
16+
* Parse the first question's name from a DNS query buffer.
17+
* Returns undefined on malformed input; we treat any parse error
18+
* as "deny" at the caller.
19+
*
20+
* @param {Buffer} msg
21+
* @returns {string | undefined}
22+
*/
23+
export function parseFirstQuestionName(msg) {
24+
if (msg.length < 12) return undefined;
25+
const qdcount = msg.readUInt16BE(4);
26+
if (qdcount < 1) return undefined;
27+
let offset = 12;
28+
const labels = [];
29+
// Hard-cap iteration to prevent infinite loops on malicious input.
30+
for (let i = 0; i < 128; i++) {
31+
if (offset >= msg.length) return undefined;
32+
const len = msg[offset];
33+
if (len === 0) {
34+
offset += 1;
35+
return labels.length === 0 ? "." : labels.join(".");
36+
}
37+
if ((len & 0xc0) === 0xc0) {
38+
// Compression pointer. In a question this is unusual; follow once
39+
// and treat a deeper chain as malformed.
40+
if (offset + 1 >= msg.length) return undefined;
41+
const ptr = ((len & 0x3f) << 8) | msg[offset + 1];
42+
if (ptr >= offset) return undefined; // must point backwards
43+
offset = ptr;
44+
continue;
45+
}
46+
if (len > 63) return undefined; // label length capped at 63
47+
if (offset + 1 + len > msg.length) return undefined;
48+
labels.push(msg.slice(offset + 1, offset + 1 + len).toString("ascii"));
49+
offset += 1 + len;
50+
}
51+
return undefined;
52+
}
53+
54+
/**
55+
* Synthesize a minimal NXDOMAIN response echoing the original query's
56+
* id + question section. Standards-compliant enough that resolvers
57+
* (getaddrinfo, dig) interpret it as "this name does not exist" and
58+
* give up rather than retrying forever.
59+
*
60+
* @param {Buffer} query
61+
* @returns {Buffer}
62+
*/
63+
export function synthesizeNxdomain(query) {
64+
if (query.length < 12) return Buffer.alloc(0);
65+
// Find the end of the question section so we can copy it into the
66+
// response verbatim. We parsed qdcount=1 questions in the caller;
67+
// we only echo the first one here.
68+
let offset = 12;
69+
let labelSafetyCap = 0;
70+
while (offset < query.length && labelSafetyCap < 128) {
71+
const len = query[offset];
72+
if (len === 0) {
73+
offset += 1;
74+
break;
75+
}
76+
if ((len & 0xc0) === 0xc0) {
77+
offset += 2;
78+
break;
79+
}
80+
if (offset + 1 + len > query.length) {
81+
// Malformed — best-effort echo of the whole buffer.
82+
offset = query.length;
83+
break;
84+
}
85+
offset += 1 + len;
86+
labelSafetyCap += 1;
87+
}
88+
// qtype (2) + qclass (2).
89+
const questionEnd = Math.min(offset + 4, query.length);
90+
const resp = Buffer.alloc(questionEnd);
91+
query.copy(resp, 0, 0, questionEnd);
92+
// Flags: QR=1 (response), OPCODE=0, AA=0, TC=0, RD preserved, RA=1,
93+
// Z=0, RCODE=3 (NXDOMAIN).
94+
// Read original flags to preserve RD (recursion desired) echo.
95+
const origFlags = resp.readUInt16BE(2);
96+
const rd = (origFlags >> 8) & 0x01;
97+
const newFlags = 0x8000 | (rd << 8) | 0x0080 | 0x0003;
98+
resp.writeUInt16BE(newFlags, 2);
99+
// qdcount stays at 1 (we echoed the question); ancount/nscount/arcount = 0.
100+
resp.writeUInt16BE(1, 4);
101+
resp.writeUInt16BE(0, 6);
102+
resp.writeUInt16BE(0, 8);
103+
resp.writeUInt16BE(0, 10);
104+
return resp;
105+
}

0 commit comments

Comments
 (0)