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: 0 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ GO_OSES := linux darwin

## Location to install dependencies to
LOCALBIN ?= $(shell pwd)/bin
$(LOCALBIN):
mkdir -p $(LOCALBIN)

## Tool versions
GOLANGCILINT_VERSION ?= v2.13.2
Expand Down
8 changes: 4 additions & 4 deletions docs/advanced-network.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ answers.
| --- | --- | --- | --- | --- |
| 0 | 4 | magic | `SLKT` | `SLKT` |
| 4 | 2 | version | highest it speaks (1) | the version both use; ≤ the guest's |
| 6 | 2 | offloads | offloads its tun accepts on write | offloads its tun accepts on write |
| 6 | 2 | offloads | offloads its tun accepts on write | CSUM, TSO4, TSO_ECN and USO4, always |
| 8 | 2 | mtu | 0 | MTU for `tun0`; 0 keeps the current one |
| 10 | 2 | flags | 0 | bit 0 `ON`: packet mode is on for this box |

Expand All @@ -77,11 +77,11 @@ closing silently. A guest that does not speak the host's version closes the
stream. The guest gives up on a hello that does not arrive in 5 seconds.

Offload bits are the kernel's `TUN_F_*` values: `0x01` CSUM, `0x02` TSO4,
`0x04` TSO6, `0x08` TSO_ECN, `0x20` USO4, `0x40` USO6. Each side probes its
`0x04` TSO6, `0x08` TSO_ECN, `0x20` USO4, `0x40` USO6. The guest probes its
kernel (USO needs Linux 6.2) and then enables, with `TUNSETOFFLOAD`, only the
offloads both hellos carry; segmentation offloads are dropped when CSUM is not
common, and USO is enabled only as the USO4 and USO6 pair. The host offers
USO4 alone, so a guest never enables USO. A side never builds a frame whose GSO type the peer did not offer;
common, and USO is enabled only as the USO4 and USO6 pair. The host has no TUN
and segments in software; it offers USO4 alone, so a guest never enables USO. A side never builds a frame whose GSO type the peer did not offer;
packets its kernel queued before a reconnect keep their old type, and a
receiver may drop one it cannot take.

Expand Down
11 changes: 6 additions & 5 deletions docs/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ A claim always enters at whatever node the client dialed:
`no_redirect` set, so the target warms-or-provisions locally and a stale
view can never ping-pong. The same redirect fires when the node lacks a
golden for the key but gossip names a template owner, and when the node
sits at `max_claims` or `max_running`: a warm peer first, else a peer that
serves the pool. A node that would refuse a claim stops gossiping its pools
refuses admission (any [admission refusal](sandboxd-api.md#admission-refusals)):
a warm peer first, else a peer that serves the pool. A node that would refuse a claim stops gossiping its pools
within a tick. A stale view can still name one; it answers 429, and the SDK
then tries the next one and the origin.
3. **No candidates** — the node provisions locally: a golden clone (tens of
Expand Down Expand Up @@ -151,7 +151,7 @@ claim placement resolves the holder.

## Querying members

`GET /v1/info` (root `api_token`) reports this node's pools plus the peer
`GET /v1/info` (root or operator token) reports this node's pools plus the peer
data-plane addresses it currently sees:

```bash
Expand All @@ -160,8 +160,9 @@ curl -s -H "Authorization: Bearer $TOKEN" http://node-a:7777/v1/info | jq .

The [API page](sandboxd-api.md#get-v1info) describes the payload.

`peers`, served by `GET /v1/peers` (root or tenant token), is what the SDK
scatters across in `Lookup`. An empty/absent `peers` means a mesh of one.
`peers`, served by `GET /v1/peers` (root, operator or tenant token), is what the SDK
scatters across in `Lookup`; a node with `client_advertise` set answers the
client addresses. An empty/absent `peers` means a mesh of one.

## Relocating a handle

Expand Down
4 changes: 3 additions & 1 deletion docs/egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,9 @@ on, written to `audit.jsonl` (`op:"egress"`, `dest`, `port`, `method`,
`decision`, and the secret **name** in `secret`); an intercepted `CONNECT` is
recorded as a decision of its own before its inner requests. The HTTP and
CONNECT doors deny a host longer than 253 bytes (the DNS limit); the audit
record clips it. A sandbox records at most 10
record clips it. They also deny a host with a zone (`%`) or a non-ASCII byte,
which the dialer would resolve to another target than the rule matched; the
SOCKS5 door denies such a name the same way, and every door records the deny. A sandbox records at most 10
`deny` and `rate_limited` decisions per second after a burst of 100, dropping
the rest, so a guest cannot flood the journals. `rate_limited` comes from
[`egress_conn_rate`](deploy.md#configuration).
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ in the guest, which can hold its reply up to 5 s past the cap.
| `hibernate` | snapshot + stop, freeing memory while keeping id, files and processes; the next call that reaches the guest wakes it |
| `promote` | publish the sandbox as the template `template_name` on its node; re-promoting replaces it |
| `release` | destroy the sandbox and its files; the session forgets the id, so a second release is rejected as unknown |
| `node_info` | warm pools, promoted templates, live claims, drain state, capacity and mesh peers; needs the operator token |
| `node_info` | warm pools, promoted templates, live claims, drain state, capacity and mesh peers; needs the root or operator token |

An egress-lane sandbox cannot `fork`, `checkpoint`, `hibernate` or `promote`.

Expand Down
6 changes: 3 additions & 3 deletions docs/sandboxd-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@ destroyed and the pool entry retires. Once such a pool is idle, its golden
is deleted; a pool kept with `warm: 0` keeps its golden.
`net`/`size` default like a claim's.
Answers the fresh `GET /v1/info` payload. 400 bad key, negative warm/idle,
`warm_max` below `warm`, `idle_hibernate_seconds` on an egress pool, a
`warm_max` below `warm`, a reader pool with `warm` below 1, `idle_hibernate_seconds` on an egress pool, a
negative archive duration or an `archive_after_seconds` not above the pool's
`idle_hibernate_seconds`, duplicate pool, or a config-owned `egress`/`warmup`/`capture_trim`/`storage`/`replicate_seconds`/`reader`/`egress_upstream_env`/`tun`
field; 401 bad api
Expand Down Expand Up @@ -970,7 +970,7 @@ Auth: node API token; body `{"token": "<sandbox token>", "name": "..."}`
(name optional); the root `api_token` with an empty body `token` acts by id as
root. Captures the sandbox's full state without stopping it and
answers `200 {"checkpoint": {id, name?, sandbox_id, key, tenant?, created_at,
policy_source?, no_egress?, guest_env?}}` — `tenant` records the calling tenant, absent for root.
policy_source?, no_egress?, tun_off?, tun_profile?, egress_limit?, guest_env?}}` — `tenant` records the calling tenant, absent for root.
400 bad body or name, 401 bad api token, 404 unknown id or wrong sandbox
token, 409 egress-lane sandbox, one with volumes attached (see
[egress](egress.md)), a failed one, or an archived one (`POST /wake` or an exec
Expand Down Expand Up @@ -1033,7 +1033,7 @@ part of the public API; an SDK caller has no reason to call it directly.
Auth: node API token. Lists this node's checkpoints, newest first. A tenant
sees only its own records; root sees everything. A checkpoint backing an
archived claim is that claim's wake image, not a listable record. Answers
`200 {"checkpoints": [{id, name?, sandbox_id, key, tenant?, created_at, policy_source?, no_egress?, guest_env?}]}`, the
`200 {"checkpoints": [{id, name?, sandbox_id, key, tenant?, created_at, policy_source?, no_egress?, tun_off?, tun_profile?, egress_limit?, guest_env?}]}`, the
record shape `POST /v1/sandboxes/{id}/checkpoint` returns.

## DELETE /v1/checkpoints/{id}
Expand Down
11 changes: 6 additions & 5 deletions docs/sdk-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ The rest of this guide is the per-method reference.
client = Client("10.0.0.5:7777", api_token="...", timeout=120.0)
```

`api_token` is the node token — a root `api_token` (full access) or a
tenant token (resource-creating verbs only; operator surfaces answer it
403). Every node of a cell shares the root token and the same tenants
`api_token` is the node token — a root `api_token` (full access), the
[`operator_token`](deploy.md#configuration) (the claim lifecycle,
`sandboxes`, `info` and `lookup`), or a tenant token (resource-creating
verbs only; operator surfaces answer it 403). Every node of a cell shares the root token and the same tenants
set. `timeout` bounds every control-plane request and the data-plane dial
and upgrade; a guest stream then lives until the guest ends it. Unlike the
Go SDK, where a canceled context closes any call, only `run`/`exec` take a
Expand Down Expand Up @@ -205,8 +206,8 @@ The caller mounts, flushes, and unmounts attach-only devices. See the
[live volume contract](sandboxd-api.md#put-v1sandboxesidvolumes) for
writer exclusion and dirty-marker limits.
Each method takes `timeout=` (default: the client timeout, 120 s); a large
replacement can need longer, and a call that times out cancels the server run,
so retry the same target.
replacement can need longer, and a call that times out raises `APIError` with
status 0 and cancels the server run, so retry the same target.

Responses include `volumes_want`, `volumes`, `states`, and `converging`.
`VolumePhase` and `VOLUME_ATTACHED`, `VOLUME_PENDING`, and `VOLUME_DETACHING`
Expand Down
11 changes: 6 additions & 5 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,10 @@ client, err := sandbox.Connect("10.0.0.5:7777",
```

- `Connect(addr, opts...)` — `addr` is one node's address or origin.
- `WithAPIToken(token)` — the node token: a root `api_token` (full access)
or a tenant token (resource-creating verbs only; operator surfaces like
`Info` answer it 403). Every node of a cell shares the same root token
- `WithAPIToken(token)` — the node token: a root `api_token` (full access),
the [`operator_token`](deploy.md#configuration) (the claim lifecycle, the
tenant verbs, `Info` and `Lookup`), or a tenant token (resource-creating
verbs only; operator surfaces like `Info` answer it 403). Every node of a cell shares the same root token
and the same tenants set.
- `WithHTTPClient(client)` — replace the control-plane HTTP client when the
caller needs a custom transport, proxy, or timeout. An `http.Transport`'s
Expand Down Expand Up @@ -687,8 +688,8 @@ ctx) tears the shell down.

## Node operations

Verbs for operating a node — `Drain`, `Undrain`, `Cordon`, `Uncordon`, `SetPools`,
`SetPoolsCluster` and the tenant verbs need the root token — plus the reference
Verbs for operating a node — `Drain`, `Undrain`, `Cordon`, `Uncordon`, `SetPools`
and `SetPoolsCluster` need the root token, the tenant verbs the root or operator token — plus the reference
the aggregated apiserver claims under:

```go
Expand Down
2 changes: 1 addition & 1 deletion e2e/cmd/androidsmoke/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func run(addr, token, template string) error {
if err != nil {
return fmt.Errorf("checkpoint: %w", err)
}
defer func() { _ = ckpt.Delete(ctx) }()
defer func() { _ = ckpt.Delete(context.WithoutCancel(ctx)) }()
branch, err := ckpt.New(ctx)
if err != nil {
return fmt.Errorf("branch: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion e2e/cmd/browsersmoke/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func run(addr, token, template string) error {
if err != nil {
return fmt.Errorf("checkpoint: %w", err)
}
defer func() { _ = ckpt.Delete(ctx) }()
defer func() { _ = ckpt.Delete(context.WithoutCancel(ctx)) }()
branch, err := ckpt.New(ctx)
if err != nil {
return fmt.Errorf("branch: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ var (
schema(props{"sandbox_id": str(sandboxIDDesc), "template_name": str("template name to publish as")}, "sandbox_id", "template_name"), toolPromote,
},
{"release", "Destroy a sandbox and free its resources; files and processes inside it are lost. This session forgets the id, so a second release of it is rejected as unknown.", schema(props{"sandbox_id": str(sandboxIDDesc)}, "sandbox_id"), toolRelease},
{"node_info", "Report the connected node's warm pools, promoted templates, live claims, drain state, capacity, and mesh peers as JSON. Needs the operator token; a tenant token is refused.", schema(props{}), toolNodeInfo},
{"node_info", "Report the connected node's warm pools, promoted templates, live claims, drain state, capacity, and mesh peers as JSON. Needs the root or operator token; a tenant token is refused.", schema(props{}), toolNodeInfo},
}
)

Expand Down
3 changes: 2 additions & 1 deletion protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ implementations of the silkd protocol:
`sdk/python/tests/test_fixtures.py`.

`req_*.json` are client→server frames, `resp_*.json` server→client. A frame
that the Rust or Go side cannot round-trip is a protocol drift bug. The Rust side also
that the Go side cannot round-trip, or that the Rust side cannot parse (a
request) or round-trip (a response), is a protocol drift bug. The Rust side also
pins the corpus size, so adding a verb without its fixture fails CI.

`enums.json` pins each wire enum's full value set (error/event/file kinds,
Expand Down
13 changes: 9 additions & 4 deletions sandboxd/egress/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode/utf8"

"golang.org/x/time/rate"
)
Expand Down Expand Up @@ -737,20 +738,24 @@ func denied(w http.ResponseWriter, host string) {
http.Error(w, fmt.Sprintf("egress denied: %s", host), http.StatusForbidden)
}

// hostPort splits an authority; a bare host takes def, a port outside 1-65535, a host past 253 bytes or a malformed authority is refused rather than defaulted.
// hostPort splits an authority; a bare host takes def, a port outside 1-65535, a host past 253 bytes, a zone or non-ASCII byte, or a malformed authority is refused rather than defaulted.
func hostPort(authority string, def uint16) (host string, port uint16, ok bool) {
host, text, err := net.SplitHostPort(authority)
if err != nil {
literal, bracketed := strings.CutPrefix(authority, "[")
if !bracketed {
return authority, def, authority != "" && len(authority) <= maxHostLen && !strings.ContainsAny(authority, ":[]")
return authority, def, plainHost(authority) && !strings.ContainsAny(authority, ":[]")
}
literal, closed := strings.CutSuffix(literal, "]")
addr, parseErr := netip.ParseAddr(literal)
return literal, def, closed && parseErr == nil && addr.Is6() && len(literal) <= maxHostLen
return literal, def, closed && parseErr == nil && addr.Is6() && plainHost(literal)
}
n, err := strconv.ParseUint(text, 10, 16)
return host, uint16(n), err == nil && n != 0 && host != "" && len(host) <= maxHostLen
return host, uint16(n), err == nil && n != 0 && plainHost(host)
}

func plainHost(host string) bool {
return host != "" && len(host) <= maxHostLen && !strings.ContainsFunc(host, func(r rune) bool { return r == '%' || r >= utf8.RuneSelf })
}

func tcpAddrPort(a net.Addr) netip.AddrPort {
Expand Down
18 changes: 18 additions & 0 deletions sandboxd/egress/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,23 @@ func TestADeniedForwardIs403AndAuditedAsDeny(t *testing.T) {
}
}

func TestANonASCIIForwardHostIsDeniedBeforeMatching(t *testing.T) {
policy := Policy{Allow: []Rule{{Host: "*.api.internal", Secret: "gh"}}}
secrets := fakeSecrets{"gh": {"Authorization", "Bearer SECRET"}}
events := make(chan Event, 4)
p := New(policy, secrets, nil, fixedDial("127.0.0.1:1"), func(ev Event) { events <- ev }, nil)
front := serveFront(t, p)

conn := dialProxy(t, front.Listener.Addr().String(), "GET http://x.ap%C4%B0.internal/ HTTP/1.1\r\nHost: x.api.internal\r\n\r\n")
defer func() { _ = conn.Close() }()
if status := readStatus(t, bufio.NewReader(conn)); status != "HTTP/1.1 403 Forbidden" {
t.Fatalf("forward to a host that lowercases into the allow-list = %q, want 403", status)
}
if ev := recvEvent(t, events); ev.Decision != DecisionDeny || ev.Injected != "" {
t.Errorf("audit event = %+v, want deny with nothing injected", ev)
}
}

func TestConnectAllowTunnels(t *testing.T) {
echo := echoServer(t)
policy := Policy{Allow: []Rule{{Host: "echo.internal"}}}
Expand Down Expand Up @@ -484,6 +501,7 @@ func TestBracketedIPv6AuthorityIsUnwrapped(t *testing.T) {
{"[not-an-address]", "HTTP/1.1 403 Forbidden", DecisionDeny},
{"[2606:4700:4700::1111", "HTTP/1.1 403 Forbidden", DecisionDeny},
{"echo.internal]", "HTTP/1.1 403 Forbidden", DecisionDeny},
{"[2606:4700:4700::1111%25.example.com]:443", "HTTP/1.1 403 Forbidden", DecisionDeny},
{"", "HTTP/1.1 403 Forbidden", DecisionDeny},
} {
conn := dialProxy(t, front.Listener.Addr().String(), "CONNECT /x HTTP/1.1\r\nHost: "+tt.authority+"\r\n\r\n")
Expand Down
2 changes: 1 addition & 1 deletion sandboxd/egress/socks.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (p *Proxy) serveSocks(ctx context.Context, conn net.Conn) {
return
}
decision, intercept := p.tunnelDecision(host, port, false)
if intercept || port == 0 {
if intercept || port == 0 || !plainHost(host) {
decision = DecisionDeny
}
ev := Event{Method: methodSOCKS, Host: host, Port: port, Decision: decision}
Expand Down
13 changes: 13 additions & 0 deletions sandboxd/egress/socks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ func TestSocksRefusesWhatItCannotServe(t *testing.T) {
}
}

func TestSocksDeniesAndRecordsAZonedOrNonASCIIName(t *testing.T) {
for _, name := range []string{"2606:4700:4700::1111%.example.com", "x.exampl\u0130.com"} {
events := make(chan Event, 4)
_, addr := socksProxy(t, Policy{Allow: []Rule{{Host: "*.example.com"}}}, nil, fixedDial(echoServer(t)), events, nil)
if _, reply := socksExchange(t, addr, socksGreeting, socksNameRequest(name, 443)); reply[1] != socksDenied {
t.Errorf("reply code for %q = %#x, want denied", name, reply[1])
}
if ev := recvEvent(t, events); ev.Method != methodSOCKS || ev.Host != name || ev.Decision != DecisionDeny {
t.Errorf("audit event for %q = %+v, want SOCKS5 deny", name, ev)
}
}
}

func TestSocksIPv4TargetMatchesLiteralRule(t *testing.T) {
events := make(chan Event, 4)
_, addr := socksProxy(t, Policy{Allow: []Rule{{Host: "127.0.0.1"}}}, nil, fixedDial(echoServer(t)), events, nil)
Expand Down
9 changes: 1 addition & 8 deletions sandboxd/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ func (e *Engine) DialGuestPort(ctx context.Context, vsockSocket string, port uin
}
if _, ok := resp.(*wire.Ready); !ok {
_ = conn.Close()
return nil, fmt.Errorf("port_forward %d: %s", port, respFail(resp))
return nil, fmt.Errorf("port_forward %d: %w", port, frameErr(resp))
}
return &guestPortConn{Conn: conn, r: r}, nil
}
Expand Down Expand Up @@ -589,13 +589,6 @@ func isVMNotFound(err error) bool {
return strings.Contains(err.Error(), vmNotFound)
}

func respFail(resp wire.Response) string {
if errResp, ok := resp.(*wire.ErrorResp); ok {
return errResp.Error()
}
return "unexpected frame " + resp.RespType()
}

// belowFloor reports whether v is below floor; known is false for a dev build.
func belowFloor(v, floor string) (below, known bool) {
v = "v" + strings.TrimPrefix(v, "v")
Expand Down
22 changes: 11 additions & 11 deletions sandboxd/pool/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,18 +151,18 @@ func (m *Manager) sweepOrphanSnaps(ctx context.Context, snaps []string, snapsErr
// An unreferenced hibernate snapshot is an orphan; fork and golden snapshots never span a restart
if snapsErr != nil {
logger.Warnf(ctx, "snapshot sweep skipped: %v", snapsErr)
} else {
orphans := slices.DeleteFunc(snaps, func(snap string) bool {
if strings.HasPrefix(snap, hibernatePrefix) {
return referenced[snap]
}
return !strings.HasPrefix(snap, forkPrefix) && !strings.HasPrefix(snap, goldenPrefix)
})
m.runBounded(ctx, len(orphans), func(ctx context.Context, i int) {
m.dropSnap(ctx, orphans[i])
logger.Infof(ctx, "removed orphan snapshot %s", orphans[i])
}).Wait()
return
}
orphans := slices.DeleteFunc(snaps, func(snap string) bool {
if strings.HasPrefix(snap, hibernatePrefix) {
return referenced[snap]
}
return !strings.HasPrefix(snap, forkPrefix) && !strings.HasPrefix(snap, goldenPrefix)
})
m.runBounded(ctx, len(orphans), func(ctx context.Context, i int) {
m.dropSnap(ctx, orphans[i])
logger.Infof(ctx, "removed orphan snapshot %s", orphans[i])
}).Wait()
}

// pendingMovesLocked marks the journaled moves of loaded claims: a source keeps its claim gated until the record resolves; callers hold m.mu.
Expand Down
5 changes: 2 additions & 3 deletions sandboxd/pool/setpools.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,9 @@ func (m *Manager) applyPools(ctx context.Context, desired map[types.PoolKey]conf
}
}
for key, spec := range desired {
if p := m.pools[key]; p != nil {
continue
if m.pools[key] == nil {
m.pools[key] = newPool(spec)
}
m.pools[key] = newPool(spec)
}
m.mu.Unlock()

Expand Down
Loading
Loading