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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ performance) — source in
`desktop`, and `android`
- `scripts/` — `boot-bench.sh` (boot phase timing), `bench.sh` (the published
benchmark procedure), `sandboxd-e2e.sh` (bare-metal e2e, below), plus the
`archive`/`egress`/`intercept` e2e drivers
`archive`/`egress`/`intercept`/`socks` e2e drivers

## Build & test

Expand Down
12 changes: 12 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ volume claim has finalized, do not roll a node back to an older sandboxd until
all volume claims are gone; the older daemon cannot preserve the volume
claims' admission holds and marker bookkeeping recorded in `claims.json`.

The guest images and sandboxd move together on the egress lane: sandboxd tells
a NIC-bearing guest how its traffic leaves through `/etc/silkd-lane`, and a
guest image from another release reads a different file or none, which on a
locked bridge lane leaves its execs routing into the lock. Roll the images
with the daemon. A config that uses the `socks5` or `ports` policy keys does
not load on an older sandboxd (unknown keys are rejected), so a rollback takes
the config with it. The SOCKS5 door is opt-in and the pool policy owns it: a
pool that relied on the door binding for any bare host rule adds
`"socks5": true` to its policy or loses the door silently, since its config
still loads; a tenant policy's flag counts only on a claim outside any
configured pool, where it is the whole policy.

## Configuration

sandboxd reads one JSON file (`-config`, default
Expand Down
16 changes: 10 additions & 6 deletions docs/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ up a few seconds later — poll `GET /screenshot` until it returns 200.
with an [egress policy](egress.md) when tasks visit the OSWorld mocked
websites or the real web; the session's browsers reach the web through the
relay. Thunderbird's mail policy is pinned to the SOCKS5 door, so a pool that
runs mail tasks sets `"socks5": true` in its policy, and so does the tenant
policy when the claim carries one. `net=egress` on a
runs mail tasks sets `"socks5": true` in its policy. `net=egress` on a
guarded bridge works the same way: the session's proxy setup waits for the
host's lane verdict in `/etc/silkd-lane` and follows it, so the locked NIC
costs the desktop nothing (a CNI-backed lane is told `direct` and routes).
Expand Down Expand Up @@ -79,8 +78,13 @@ Configure it with `SANDBOXD_ADDR`, `SANDBOXD_TOKEN`, `COCOON_TEMPLATE`
needs a browser the tasks do not pkill and rebind. It takes the caller's
profile and debugging port.
- `guest-proxy.service` points the session's browsers and `osworld-server`'s
commands at silkd's loopback relay on a guest with no NIC, and on a guest
whose NIC the host reports as `relay` in `/etc/silkd-lane`; it waits up to
30 s for that verdict before the session starts. A NIC the host calls
`direct`, or a host that writes nothing, leaves the unit idle.
commands at silkd's loopback relay on a guest with no device-backed NIC, and
on a guest whose NIC the host reports as `relay` in `/etc/silkd-lane`; on a
NIC-bearing guest it holds the session until that verdict lands, up to 300 s,
longer than the host's own probe and write timeouts, so a verdict that never
comes means a host that already gave the boot up or an old sandboxd that
writes none: the unit then fails visibly and the session routes directly. A
verdict that changes under a running desktop (a node moved from CNI networks
to a bridge after the claim) reaches silkd's execs at once but the session
only through a re-claim.
- x86_64 only.
20 changes: 11 additions & 9 deletions docs/egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,16 @@ through the same code: a rule with `methods` that does not name `CONNECT`
denies it, and a rule with a `secret` opens it without injecting anything, on
both ports. The one difference is `intercept`: on 3128 such a rule terminates
the TLS and filters the requests inside, on 1080 there is no HTTP to filter, so
the tunnel is refused. The door is opt-in: the policy sets `"socks5": true`
and some rule of it can admit a tunnel, otherwise the host side stays unwired
and the guest's dial is refused like the HTTP one with no policy. Both sides of
a pool/tenant pair must opt in, each checked on its own, so disjoint host sets
still bind a listener that denies everything. A policy that opts in with rules
that all carry `methods` without `CONNECT`, or all `intercept`, is rejected at
load. A policy that does not opt in pays nothing for the door on the claim
path. Audit lines carry `"method":"SOCKS5"`.
the tunnel is refused. The door is opt-in: the pool policy sets
`"socks5": true`, otherwise the host side stays unwired and the guest's dial
is refused like the HTTP one with no policy. The tenant policy's rules gate
every tunnel through the door as they gate `CONNECT`, so the tenant does not
opt in separately; its own `socks5` counts only on a claim outside any
configured pool, where the tenant policy is the whole policy. A policy that
opts in with rules that all carry `methods` without `CONNECT`, or all
`intercept`, is rejected at load. A pool whose policy does not opt in pays
nothing for the door on the claim path, whatever its tenants' policies say.
Audit lines carry `"method":"SOCKS5"`.

```sh
curl --socks5-hostname 127.0.0.1:1080 imaps://imap.example.com/ # allowed: {"socks5": true, "allow": [{"host": "imap.example.com"}]}
Expand Down Expand Up @@ -163,7 +165,7 @@ file.
"egress": { "allow": [{ "host": "api.github.com", "secret": "gh" }] } }
],
"tenants": [
{ "name": "acme", "token": "…", "egress": { "socks5": true, "allow": [{ "host": "api.github.com" }] } }
{ "name": "acme", "token": "…", "egress": { "allow": [{ "host": "api.github.com" }] } }
]
}
```
Expand Down
6 changes: 4 additions & 2 deletions docs/silkd.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ lane. `SILKD_NET=none|egress` overrides the probe for tests and operators.
On every lane silkd also binds `127.0.0.1:3128` and relays each connection to
the host's [guarded-egress](egress.md) proxy over vsock (`CID2:2049`), and
`127.0.0.1:1080` to its SOCKS5 door (`CID2:2050`) for clients whose protocol
has no HTTP-proxy form; when the host wired no policy the per-connection dial
is refused, so the ports are inert.
has no HTTP-proxy form; when the host wired no policy, or the door-owning
policy (the pool's, or the tenant's on a claim outside any pool) did not opt
into the SOCKS5 door, the per-connection dial is refused, so the ports are
inert.
Where nothing routes directly — the no-network lane, or a NIC whose host
verdict in `/etc/silkd-lane` reads `relay` because it is nft-locked — silkd
forwards the image-baked proxy variables (`http_proxy` and friends) into every
Expand Down
8 changes: 4 additions & 4 deletions e2e/cmd/desktopsmoke/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,19 @@ func clickAndReadCursor(ctx context.Context, sb *sandbox.Sandbox) error {

// proxyReachesSession proves the session's environment carries the relay and the relay reaches an allowed origin.
func proxyReachesSession(ctx context.Context, sb *sandbox.Sandbox, probe string) error {
script := `test -n "$http_proxy" && curl -sS -m 20 -o /dev/null -w '%{http_code}' ` + probe
out, err := execute(ctx, sb, []string{"sh", "-c", script}, 30)
script := `systemctl is-active guest-proxy.service >&2 || exit 1; [ -n "$http_proxy" ] || { echo http_proxy unset in the session >&2; exit 1; }; exec curl -sS -m 20 -o /dev/null -w '%{http_code}' "$1"`
out, err := execute(ctx, sb, []string{"sh", "-c", script, "sh", probe}, 30)
if err != nil {
return fmt.Errorf("proxy leg: %w", err)
}
if strings.TrimSpace(out) != "200" {
return fmt.Errorf("proxy leg: fetch through the session's proxy returned %q, want 200", strings.TrimSpace(out))
}
fmt.Println(" proxy: the session's environment carries the relay and an allowed origin answers 200")
fmt.Println(" proxy: guest-proxy.service active, the session's environment carries the relay, an allowed origin answers 200")
return nil
}

// execute runs one command through osworld-server and returns its output; a non-zero exit is an error.
// execute runs one command through osworld-server and returns its output.
func execute(ctx context.Context, sb *sandbox.Sandbox, command []string, timeout int) (string, error) {
req, _ := json.Marshal(map[string]any{"command": command, "shell": false, "timeout": timeout})
body, err := harness.HTTPOverPort(ctx, sb, serverPort, "POST", "/execute", req)
Expand Down
9 changes: 6 additions & 3 deletions e2e/cmd/sockssmoke/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ const (
socksProxy = "127.0.0.1:1080"
httpProxy = "http://127.0.0.1:3128"

curlLoginDenied = "exit=67"
curlLoginDenied = "exit=67"
curlProxyRefused = "exit=97"
)

func main() {
Expand Down Expand Up @@ -78,8 +79,10 @@ func run(addr, token, template, echo, getOnly, imap string) error {
return fmt.Errorf("IMAPS over SOCKS5 did not reach the login step: %q", strings.TrimSpace(out))
}
fmt.Println(" IMAPS handshake rides the tunnel; the server refused the probe login")
if code := curl(ctx, sb, "--socks5-hostname", socksProxy, "imap://"+imap+":143/"); code != "000" {
return fmt.Errorf("IMAP on the unlisted port 143 returned %q, want a refused tunnel", code)
out, _ = sb.Exec(ctx, "sh", "-c", fmt.Sprintf(
"curl -sS -m 30 --socks5-hostname %s imap://%s:143/ 2>&1; echo exit=$?", socksProxy, imap))
if !strings.Contains(out, curlProxyRefused) {
return fmt.Errorf("IMAP on the unlisted port 143 did not fail at the SOCKS5 handshake: %q", strings.TrimSpace(out))
}
fmt.Println(" IMAP on the unlisted port refused on 1080")
}
Expand Down
12 changes: 6 additions & 6 deletions os-image/desktop/24.04/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -151,19 +151,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
'host='"'"'127.0.0.1'"'"'' \
'port=1080' \
> /usr/local/share/guest-proxy.dconf \
# a NIC alone says nothing about routing: the host writes /etc/silkd-lane once silkd answers, so a NIC-bearing guest waits for the verdict
# a NIC alone says nothing about routing: the host writes /etc/silkd-lane once silkd answers; the wait outlives the host's
# probe (90 s) and write (120 s) timeouts, so a guest still waiting belongs to a boot the host already gave up on
&& printf '%s\n' \
'#!/bin/sh' \
'set -e' \
': > /run/guest-proxy.env' \
'nic=0' \
'for n in /sys/class/net/*; do case ${n##*/} in lo|sit0) ;; *) nic=1;; esac; done' \
'for n in /sys/class/net/*; do if [ -e "$n/device" ]; then nic=1; fi; done' \
'if [ $nic = 1 ]; then' \
' i=0' \
' while [ ! -e /etc/silkd-lane ] && [ $i -lt 300 ]; do sleep 0.1; i=$((i+1)); done' \
' if [ ! -e /etc/silkd-lane ]; then echo "guest-proxy: no lane verdict from the host; routing directly" >&2; exit 0; fi' \
' read -r lane < /etc/silkd-lane' \
' [ "$lane" = relay ] || exit 0' \
' while [ ! -e /etc/silkd-lane ] && [ $i -lt 3000 ]; do sleep 0.1; i=$((i+1)); done' \
' if [ ! -e /etc/silkd-lane ]; then echo "guest-proxy: no lane verdict from the host after 300 s; routing directly" >&2; exit 1; fi' \
' [ "$(cat /etc/silkd-lane)" = relay ] || exit 0' \
'fi' \
'cat /usr/local/share/guest-proxy.env > /run/guest-proxy.env' \
'cat /usr/local/share/guest-proxy.dconf > /etc/dconf/db/local.d/01-proxy' \
Expand Down
14 changes: 11 additions & 3 deletions os-image/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ plus a GNOME session (Ubuntu session, dock, Yaru) on an Xvfb `:1` display at
([xlang-ai/osworld-server](https://github.com/xlang-ai/osworld-server),
pinned commit) on guest loopback `5000`, the OSWorld Chrome CDP bridge on
loopback `9222`, and the OSWorld app set: Google Chrome, LibreOffice, GIMP,
VLC. The intended pool shape is `size: 2xlarge` (8 CPU / 16G); the session
idles at ~0.5 GB anonymous memory with gnome-shell around 290 MB RSS.
VLC, Thunderbird, VS Code, Zotero, Obsidian, Shotcut, FreeCAD and WPS Office.
The intended pool shape is `size: 2xlarge` (8 CPU / 16G); the session idles
at ~0.5 GB anonymous memory with gnome-shell around 290 MB RSS.

An OSWorld harness or the Sai driver claims a sandbox and reaches the guest
server through the silkd port relay (`DialPort`/`ProxyPort` 5000, 9222), so
the `none` lane works for local tasks; tasks that visit the OSWorld mocked
websites need the `egress` lane.
websites or the web add an egress policy, on the `none` lane or a guarded
bridge `egress` lane alike (see `docs/desktop.md`).

## Guest contract

Expand All @@ -27,6 +29,12 @@ websites need the `egress` lane.
`cdp-bridge` unit forwards loopback `9222` to Chrome's `1337`.
- dconf defaults turn on `toolkit-accessibility`, and turn off animations,
idle, screen lock, and the welcome dialog.
- `guest-proxy.service` writes the session's proxy environment, the dconf
proxy entry and the apt proxy when the guest has no device-backed NIC, or
when the host's verdict in `/etc/silkd-lane` reads `relay`; on a NIC-bearing
guest it holds the session for that verdict up to 300 s and fails visibly
without it. Thunderbird's policy pins mail to the SOCKS5 door on `1080`, so
a pool that runs mail tasks opts its policy into `socks5`.

## Build

Expand Down
5 changes: 3 additions & 2 deletions sandboxd/egress/intercept.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -53,7 +54,7 @@ func (p *Proxy) serveIntercept(w http.ResponseWriter, r *http.Request, host stri
_ = client.SetDeadline(time.Time{})
ln := &singleConnListener{conn: tlsConn, done: make(chan struct{})}
srv := &http.Server{
Handler: &interceptHandler{proxy: p, host: host, port: port, authority: r.Host},
Handler: &interceptHandler{proxy: p, host: host, port: port, authority: net.JoinHostPort(host, strconv.Itoa(int(port)))},
ReadHeaderTimeout: interceptTimeout,
}
_ = srv.Serve(ln)
Expand Down Expand Up @@ -93,7 +94,7 @@ func (h *interceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
out.URL.Scheme = "https"
out.URL.Host = h.authority
// keep the guest's Host when it names the CONNECT host: SigV4 signing breaks on a rewrite.
if !strings.EqualFold(hostOnly(r.Host), h.host) {
if inner, _, _ := hostPort(r.Host, 0); !strings.EqualFold(inner, h.host) {
out.Host = h.authority
}
})
Expand Down
10 changes: 5 additions & 5 deletions sandboxd/egress/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func (p *Policy) Intercepts() bool {
return p != nil && slices.ContainsFunc(p.Allow, func(r Rule) bool { return r.Intercept })
}

// Validate rejects an empty or bare-wildcard host, a zero or repeated port, and socks5 without a tunnel rule; an empty allow-list denies everything.
// Validate rejects a policy the proxy could never honor; an empty allow-list is valid and denies everything.
func (p Policy) Validate() error {
for i, r := range p.Allow {
switch r.Host {
Expand Down Expand Up @@ -131,9 +131,9 @@ func (p Policy) EvalInner(host, method string, port uint16) (Rule, Decision) {
return Rule{}, DecisionDeny
}

// ServesSocks reports whether the SOCKS5 door is bound: opted in, with some rule that can admit a tunnel.
// ServesSocks reports whether the policy opted into the SOCKS5 door; Validate holds the rule that makes the door usable.
func (p Policy) ServesSocks() bool {
return p.Socks5 && p.admitsTunnel()
return p.Socks5
}

func (p Policy) admitsTunnel() bool {
Expand Down Expand Up @@ -190,7 +190,7 @@ func (c composite) EvalInner(host, method string, port uint16) (Rule, Decision)
return rule, DecisionAllow
}

// ServesSocks is conservative: two host sets that never meet still bind a door that denies everything.
// ServesSocks is the pool's call: the tenant's rules already gate every tunnel through Eval.
func (c composite) ServesSocks() bool {
return c.pool.ServesSocks() && c.tenant.ServesSocks()
return c.pool.ServesSocks()
}
39 changes: 9 additions & 30 deletions sandboxd/egress/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,10 @@ func TestCompositeEvalInnerIntersectsTenant(t *testing.T) {
func TestWildcardApexIsNotMatched(t *testing.T) {
policy := Policy{Allow: []Rule{{Host: "*.example.com"}}}
if _, d := policy.Eval("example.com", "GET", 443); d != DecisionDeny {
t.Errorf("apex example.com matched *.example.com, want deny")
t.Error("apex example.com matched *.example.com, want deny")
}
if _, d := policy.Eval("a.example.com", "GET", 443); d != DecisionAllow {
t.Errorf("a.example.com did not match *.example.com, want allow")
t.Error("a.example.com did not match *.example.com, want allow")
}
}

Expand Down Expand Up @@ -131,37 +131,16 @@ func TestPolicyValidate(t *testing.T) {
}

func TestPolicyServesSocks(t *testing.T) {
tests := []struct {
name string
policy Policy
want bool
}{
{"opted in, no rules", Policy{Socks5: true}, false},
{"opted in, bare host", Policy{Socks5: true, Allow: []Rule{{Host: "a.internal"}}}, true},
{"bare host without opting in", Policy{Allow: []Rule{{Host: "a.internal"}}}, false},
{"opted in, methods without CONNECT", Policy{Socks5: true, Allow: []Rule{{Host: "a.internal", Methods: []string{"GET"}}}}, false},
{"opted in, methods with CONNECT", Policy{Socks5: true, Allow: []Rule{{Host: "a.internal", Methods: []string{"connect"}}}}, true},
{"opted in, intercept only", Policy{Socks5: true, Allow: []Rule{{Host: "a.internal", Intercept: true}}}, false},
{"opted in, secret", Policy{Socks5: true, Allow: []Rule{{Host: "a.internal", Secret: "s"}}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.policy.ServesSocks(); got != tt.want {
t.Errorf("ServesSocks() = %v, want %v", got, tt.want)
}
})
}
bare := Policy{Socks5: true, Allow: []Rule{{Host: "a.internal"}}}
getOnly := Policy{Socks5: true, Allow: []Rule{{Host: "a.internal", Methods: []string{"GET"}}}}
opted := Policy{Socks5: true, Allow: []Rule{{Host: "a.internal"}}}
silent := Policy{Allow: []Rule{{Host: "a.internal"}}}
if Compose(bare, getOnly).ServesSocks() {
t.Error("composite ServesSocks() = true with a GET-only tenant, want false")
if !opted.ServesSocks() || silent.ServesSocks() {
t.Errorf("ServesSocks() opted=%v silent=%v, want true/false", opted.ServesSocks(), silent.ServesSocks())
}
if Compose(bare, silent).ServesSocks() {
t.Error("composite ServesSocks() = true with a tenant that did not opt in, want false")
if !Compose(opted, silent).ServesSocks() {
t.Error("composite ServesSocks() = false with an opted-in pool and a silent tenant; the pool owns the door")
}
if !Compose(bare, bare).ServesSocks() {
t.Error("composite ServesSocks() = false with two opted-in bare policies, want true")
if Compose(silent, opted).ServesSocks() {
t.Error("composite ServesSocks() = true with a silent pool; a tenant cannot open the door alone")
}
}

Expand Down
Loading