diff --git a/.github/workflows/build-boot.yml b/.github/workflows/build-boot.yml index 6037132c..9a16e257 100644 --- a/.github/workflows/build-boot.yml +++ b/.github/workflows/build-boot.yml @@ -62,7 +62,7 @@ jobs: run: echo "kernel=$(cat boot/kernel/VERSION)" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.3.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to GitHub Container Registry uses: docker/login-action@v4.6.0 @@ -73,7 +73,7 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@v7.3.0 + uses: docker/build-push-action@v7.4.0 with: context: boot file: boot/Dockerfile @@ -126,7 +126,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.3.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to GitHub Container Registry uses: docker/login-action@v4.6.0 diff --git a/.github/workflows/build-os-images.yml b/.github/workflows/build-os-images.yml index c566c451..de9f82c0 100644 --- a/.github/workflows/build-os-images.yml +++ b/.github/workflows/build-os-images.yml @@ -174,10 +174,10 @@ jobs: echo "silkd=$(sed -n 's/^version = "\(.*\)"/\1/p' silkd/Cargo.toml | head -1)" >> "$GITHUB_OUTPUT" - name: Set up QEMU - uses: docker/setup-qemu-action@v4.3.0 + uses: docker/setup-qemu-action@v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.3.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to GitHub Container Registry uses: docker/login-action@v4.6.0 @@ -225,7 +225,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@v7.3.0 + uses: docker/build-push-action@v7.4.0 with: context: os-image/${{ matrix.name }} file: os-image/${{ matrix.name }}/${{ matrix.tag }}/Dockerfile @@ -265,10 +265,10 @@ jobs: run: echo "silkd=$(sed -n 's/^version = "\(.*\)"/\1/p' silkd/Cargo.toml | head -1)" >> "$GITHUB_OUTPUT" - name: Set up QEMU - uses: docker/setup-qemu-action@v4.3.0 + uses: docker/setup-qemu-action@v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.3.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to GitHub Container Registry uses: docker/login-action@v4.6.0 @@ -316,7 +316,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@v7.3.0 + uses: docker/build-push-action@v7.4.0 with: context: os-image/${{ matrix.name }} file: os-image/${{ matrix.name }}/${{ matrix.tag }}/Dockerfile diff --git a/.github/workflows/build-silkd.yml b/.github/workflows/build-silkd.yml index fcde41a1..a0323129 100644 --- a/.github/workflows/build-silkd.yml +++ b/.github/workflows/build-silkd.yml @@ -54,7 +54,7 @@ jobs: uses: actions/checkout@v7.0.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.3.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to GitHub Container Registry uses: docker/login-action@v4.6.0 @@ -65,7 +65,7 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@v7.3.0 + uses: docker/build-push-action@v7.4.0 with: context: silkd file: silkd/Dockerfile @@ -115,7 +115,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.3.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to GitHub Container Registry uses: docker/login-action@v4.6.0 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index f0812f54..6e4417c1 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -58,7 +58,7 @@ jobs: # The in-repo base SDK installs first, so an adapter release never # resolves (or fails to resolve) cocoonstack-sandbox from PyPI. python -m pip install -q ./sdk/python - python -m pip install -q "${{ steps.pkg.outputs.dir }}" ruff==0.15.20 pytest==8.4.2 + python -m pip install -q "${{ steps.pkg.outputs.dir }}" ruff==0.15.22 pytest==8.4.2 (cd "${{ steps.pkg.outputs.dir }}" && ruff check . && python -m pytest -q) - name: Build sdist + wheel diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 4effbbe4..41fa7e06 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -33,7 +33,7 @@ jobs: with: python-version: "3.12" - name: Install tooling - run: pip install ruff==0.15.20 pytest==8.4.2 mypy==1.19.1 && pip install -e sdk/python -e sdk/openai -e sdk/langchain + run: pip install ruff==0.15.22 pytest==8.4.2 mypy==1.20.2 && pip install -e sdk/python -e sdk/openai -e sdk/langchain - name: Lint run: ruff format --check . && ruff check . - name: Test cocoonsandbox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bd00b78..c9c1e4a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,9 +33,9 @@ jobs: tar -C silkd/target/x86_64-unknown-linux-musl/release \ -czf "dist-extra/silkd_Linux_x86_64.tar.gz" silkd - name: Set up QEMU - uses: docker/setup-qemu-action@v4.3.0 + uses: docker/setup-qemu-action@v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.3.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Build silkd (musl static, arm64) # Emulated cargo build via the carrier Dockerfile — byte-consistent # with the image artifact; slow but release-rare. @@ -49,6 +49,7 @@ jobs: - name: goreleaser uses: goreleaser/goreleaser-action@v7.2.3 with: + version: v2.18.2 args: release --clean env: GITHUB_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index c3fa0f9f..54fea867 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ working — images built here boot with an unmodified cocoon. The server stack — sandboxd, silkd, the boot chain, the OS images, and the MCP server — is licensed under [AGPL-3.0](LICENSE). The client SDKs -(`sdk/go`, `sdk/python`, `sdk/openai`, `sdk/langchain`) are licensed under -Apache-2.0 (see the `LICENSE` file in each directory), so embedding a -client in a proprietary agent stack carries no copyleft obligation. +(`sdk/go`, `sdk/python`, `sdk/openai`, `sdk/langchain`) and the +`protocol/wire` module the Go SDK requires are licensed under Apache-2.0 +(see the `LICENSE` file in each directory), so embedding a client in a +proprietary agent stack carries no copyleft obligation. diff --git a/boot/Dockerfile b/boot/Dockerfile index c24c27c1..559e4b69 100644 --- a/boot/Dockerfile +++ b/boot/Dockerfile @@ -4,7 +4,7 @@ # under /boot/ for os-image builds to COPY --from. Built per-platform on # native runners (a kernel build under QEMU emulation takes hours). -FROM debian:12.15-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 AS kbuild +FROM debian:12.15-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS kbuild RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential bc bison flex libelf-dev libssl-dev \ xz-utils curl ca-certificates python3 \ @@ -84,7 +84,7 @@ COPY init/Cargo.toml init/Cargo.lock ./ COPY init/src ./src RUN cargo build --release --locked && cp target/release/sandbox-init /sandbox-init -FROM busybox:musl@sha256:32b5cdad7cce41dfd53d0ae06baebcf8357a147ee7694dc706911c373bc30c37 AS bb +FROM busybox:musl@sha256:ea2b9914a16a4ac1981994af97b318f7c7d4db76b580c56177f08bf76f4a0be8 AS bb FROM kbuild AS pack ARG INITRD_DEBUG=0 diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 4a7774c3..e733dace 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -66,11 +66,10 @@ builds one warm pool (small, the warm tier), one golden-only pool (medium, warm=0 — every claim is a clone), claims the cold tier from an unpooled template, then measures the data plane (`fs_stat` round-trips via `rpcbench`, `fs_pull` throughput via `pullbench`). `rpcbench` prints A -(dial per RPC), C (the current SDK keep-alive path), and B (a pre-dialed -spare); `make bench` keeps A in its table so new runs remain comparable with -the dated log below. It prints a markdown table stamped with the host evidence: -virtualization (`systemd-detect-virt`), CPU, kernel, cocoon version, image -digest. +(dial per RPC) and C (the current SDK keep-alive path); `make bench` keeps A +in its table so new runs remain comparable with the dated log below. It +prints a markdown table stamped with the host evidence: virtualization +(`systemd-detect-virt`), CPU, kernel, cocoon version, image digest. Knobs (environment variables): `WARM`/`WARM_N` (warm-pool depth and burst size — the burst must stay within the depth, or refill loses the race and diff --git a/docs/browser.md b/docs/browser.md index 579d381c..6927e8ac 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -52,9 +52,9 @@ through an authorized claim and never sits on a guest NIC. ## Checkpoint / branch a live browser -`sb.Checkpoint` + `ck.New` fork a warmed browser — in-memory tabs, -cookies, localStorage — in checkpoint-restore time; the branch answers -`/json/version` without relaunching Chrome. Hosted browser services +On the `none` lane, `sb.Checkpoint` + `ck.New` fork a warmed browser — +in-memory tabs, cookies, localStorage — in checkpoint-restore time; the +branch answers `/json/version` without relaunching Chrome. Hosted browser services cold-start a browser per session; here a warmed profile is a template you branch from. diff --git a/docs/egress.md b/docs/egress.md index 19bb1c82..1ccc8063 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -296,17 +296,19 @@ baked into a guest **when the guest is created** — at golden build, or at a pre-golden cold claim's provision (both via silkd; only the golden-build install is off the claim path). It is **not** re-installed on re-claim: a clone, checkpoint restore, archive wake, or -reconcile adopts the guest with whatever root it was born with. A `.cafp` -sidecar ties golden adoption to the baked bytes, so a changed root rebuilds -goldens — but nothing rebuilds an existing checkpoint, archive, promoted -template, or live/hibernated claim. Because the baked cert is the shared cluster -root, promote/checkpoint/archive carry no node-private material and stay -unrestricted. +reconcile adopts the guest with whatever root it was born with. Each golden's +`.stamp` file records what it baked (the root fingerprint, the lane verdict, +and the warmup argv), and a golden is adopted only when its stamp exists and +matches, so a changed root rebuilds goldens; a golden from before the stamp +has none and is rebuilt once. Nothing rebuilds an existing checkpoint, +archive, promoted template, or live/hibernated claim. Because the baked cert +is the shared cluster root, promote/checkpoint/archive carry no node-private +material and stay unrestricted. **Intermediate rotation is seamless.** Issue a fresh intermediate from the same root, point the node's config at it, restart: leaves still chain to the root -every guest already trusts, and `.cafp` (the root fingerprint) is unchanged, so -no golden rebuilds. +every guest already trusts, and the root fingerprint in each golden's `.stamp` +is unchanged, so no golden rebuilds. **Root rotation needs a drain, not a hot swap.** A guest verifies leaves only against the root(s) it was born with, so the node's leaves must chain to a root diff --git a/docs/performance.md b/docs/performance.md index 177d3ebb..1721e13e 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -74,7 +74,8 @@ SDKs keep one relay connection per handle with a 30s idle window. `e2e/cmd/rpcbench`, warm `rt:24.04` sandbox, `net=none`, n=200 `fs_stat` RPCs per arm, cocoon-test1 bare metal, client on the node, 2026-09-16 at 5158a14. The two arms interleave sample by sample and swap which one leads, -so drift cannot land on one of them. +so drift cannot land on one of them. The pre-dialed spare row is a third arm +that rpcbench ran after them at that commit and no longer carries. | per-RPC wall | plain p50 | plain p90 | TLS edge p50 | TLS edge p90 | | --- | --- | --- | --- | --- | @@ -173,11 +174,11 @@ That experiment rejected the background pre-dialer because its ~0.2ms p50 win did not survive the tail. Current main instead implements protocol-level sequential reuse: silkd proto 2 serves RPCs back to back and each SDK handle parks completed relay connections for its next call. This is neither the -pre-dialer nor concurrent multiplexing. `e2e/cmd/rpcbench` reports all three -paths as A (dial per RPC), C (SDK keep-alive), and B (pre-dialed spare); the -current numbers are in Relay connection reuse above, and they keep the -pre-dialer declined — behind a TLS edge it tracks the dial arm, because it -hides a handshake only while RPCs arrive slower than a dial completes. +pre-dialer nor concurrent multiplexing. `e2e/cmd/rpcbench` reports A (dial +per RPC) and C (SDK keep-alive); the numbers in Relay connection reuse above, +the pre-dialed spare row included, keep the pre-dialer declined — behind a TLS +edge it tracks the dial arm, because it hides a handshake only while RPCs +arrive slower than a dial completes. **Template pre-check meta GET**: a single `ReadMeta` against MinIO measured 4.76ms cross-host (sub-ms node-local, 20-50ms on WAN S3; a diff --git a/docs/silkd.md b/docs/silkd.md index a3e1020a..29e4b29a 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -44,7 +44,7 @@ suites — a frame only one side can parse fails CI. | group | ops | response flow | |---|---|---| | exec | `exec {argv, cwd?, env?, user?, detach?, session?}` | `started{pid}` → `stdout/stderr{data}`… → `exit{code}`; the client may stream `stdin{data}` / `stdin_close`. `detach` returns after `started`; the process keeps a bounded output ring for later `logs`/`attach` and leaves the table 5 minutes after it exits, after which its pid answers `not_found`; not combinable with `session`. With `session` only `argv` is used — `cwd`, `env`, `user` and `stdin` frames are ignored (the session owns them, and its commands read `/dev/null`) — no `started` is sent, and stderr arrives merged into `stdout`. A child's environment is cleared to `PATH`, `TERM` and the lane's proxy variables plus `env`; `HOME` and `USER` are set only with `user` | -| procs | `ps` / `kill {pid, signal?}` / `attach {pid}` / `logs {pid}` | `ps` answers one `procs{procs}` frame, `kill` a bare `done`; `logs` and `attach` stream `stdout/stderr{data}`… then `exit{code}` (`logs` closes with `done`, and so does `attach` when the process is gone before its exit). Handles are guest pids; any connection can list, signal, replay, or re-attach live | +| procs | `ps` / `kill {pid, signal?}` / `attach {pid}` / `logs {pid}` | `ps` answers one `procs{procs}` frame, `kill` a bare `done`; `logs` replays `stdout/stderr{data}`…, adds `exit{code}` once the process has exited, and closes with `done`; `attach` replays and follows `stdout/stderr{data}`… to `exit{code}`, or to `done` when the process is gone before its exit. Handles are guest pids; any connection can list, signal, replay, or re-attach live | | sessions | `session_create {id?, cwd?, env?}` / `session_list` / `session_rm {id}` | `session_created{id}` / `sessions{sessions}` / `done`. A session is a real persistent bash; `exec` with `session` runs inside it. Idle sessions are reaped after 30 minutes | | fs | `fs_write {path, mode?}` (+`data`/`data_end` frames) / `fs_read` / `fs_list` / `fs_stat` / `fs_mkdir {parents?}` / `fs_rm {recursive?}` / `fs_rename {from, to}` | `fs_read` streams `data{data}`… → `done` for a regular file and answers `bad_request` for a directory, device or FIFO, `fs_list` streams 4096-entry `entries{entries}` batches → `done`, `fs_stat` answers one `stat{info}`, and the mutating verbs terminate with `done`. Streaming runs both directions; write commits atomically via temp+rename and inherits an overwritten file's mode | | tree | `fs_push {dest}` (+tar as `data` frames) / `fs_pull {path}` | whole trees as tar streams through the guest tar: `fs_pull` streams `data{data}`… → `done`, `fs_push` terminates with `done` | diff --git a/e2e/cmd/egresssmoke/main.go b/e2e/cmd/egresssmoke/main.go index 270c3d58..7c92254d 100644 --- a/e2e/cmd/egresssmoke/main.go +++ b/e2e/cmd/egresssmoke/main.go @@ -36,7 +36,7 @@ func main() { template := flag.String("template", "rt:24.04", "template with an egress policy") wantToken := flag.String("secret", "", "the value the origin should observe injected") netShape := flag.String("net", "none", "claim lane: none|egress") - reach := flag.String("reach", "127.0.0.1", "host the guest reaches the origin at (policy must allow it)") + reach := flag.String("reach", "127.0.0.1", "host the guest reaches the origin at") nicAddr := flag.String("nicaddr", "", "static CIDR to bring the egress-lane NIC up with (default route via -reach)") guarded := flag.Bool("guarded", true, "pool has an egress policy; false asserts the unlocked NIC reaches directly (negative control)") echo := flag.String("echo", "postman-echo.com", "public HTTP host echoing request headers at /get, for the injection check") @@ -122,18 +122,13 @@ func run(addr, token, template, wantToken, netShape, reach, nicAddr, echo string return nil } -// startOrigin serves one echo endpoint that returns the credential header the -// proxy injected, on a host-local port. +// startOrigin serves the host-local negative control, answering REACHED to a guest that dials it directly. func startOrigin() (*http.Server, int, error) { ln, err := net.Listen("tcp", "0.0.0.0:0") //nolint:gosec // test origin the egress-lane guest reaches via the bridge if err != nil { return nil, 0, err } - srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if v := r.Header.Get("X-Egress-Token"); v != "" { - _, _ = io.WriteString(w, v) //nolint:gosec // test origin echoing the token we injected - return - } + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "REACHED") }), ReadHeaderTimeout: 10 * time.Second} go func() { _ = srv.Serve(ln) }() diff --git a/e2e/cmd/portsmoke/main.go b/e2e/cmd/portsmoke/main.go index 442a6e50..b81c529d 100644 --- a/e2e/cmd/portsmoke/main.go +++ b/e2e/cmd/portsmoke/main.go @@ -34,8 +34,6 @@ const ( claimTTL = 30 * time.Minute ) -var guestServerMode = uint32(0o755) - func main() { addr := flag.String("addr", "127.0.0.1:7777", "sandboxd address") token := flag.String("token", "", "node api token") @@ -111,7 +109,7 @@ func startGuestListener(ctx context.Context, sb *sandbox.Sandbox, listener strin if err != nil { return fmt.Errorf("read listener: %w", err) } - if err := sb.WriteFile(ctx, guestServerPath, bin, &guestServerMode); err != nil { + if err := sb.WriteFile(ctx, guestServerPath, bin, new(uint32(0o755))); err != nil { return fmt.Errorf("upload listener: %w", err) } argv := []string{ diff --git a/e2e/cmd/rpcbench/main.go b/e2e/cmd/rpcbench/main.go index 4da4157e..feca2b42 100644 --- a/e2e/cmd/rpcbench/main.go +++ b/e2e/cmd/rpcbench/main.go @@ -1,14 +1,12 @@ -// rpcbench measures the one-connection-per-RPC overhead on a live node and -// what the alternatives buy: mode A dials+upgrades per RPC by hand; mode C -// is the SDK's own path, one kept connection serving RPCs back to back; mode -// B keeps one hand-dialed connection ahead, hiding the handshake behind the -// previous call (H-4's decision data). A and C interleave sample by sample +// rpcbench measures the one-connection-per-RPC overhead on a live node: mode +// A dials+upgrades per RPC by hand; mode C is the SDK's own path, one kept +// connection serving RPCs back to back. A and C interleave sample by sample // and swap which one leads, so node drift cannot land on one arm. Run by // hand against a claimed sandbox: // // rpcbench -addr -token -template -n 200 // -// An https:// address drives both hand-dialed modes through the TLS edge the +// An https:// address drives the hand-dialed mode through the TLS edge the // SDK already uses, so A pays a handshake per RPC the way a real client does. package main @@ -108,40 +106,6 @@ func run(addr, token, template, caCert string, n int) error { } report("A dial-per-RPC", a) report("C SDK keep-alive", c) - - spare := make(chan net.Conn, 1) - errs := make(chan error, 1) - go func() { - for { - conn, err := dial() - if err != nil { - errs <- err - return - } - select { - case spare <- conn: - case <-ctx.Done(): - _ = conn.Close() - return - } - } - }() - b := make([]time.Duration, 0, n) - for range n { - start := time.Now() - var conn net.Conn - select { - case conn = <-spare: - case err := <-errs: - return err - } - if err := statRPC(conn); err != nil { - return err - } - b = append(b, time.Since(start)) - } - cancel() - report("B pre-dialed spare", b) return nil } diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index bf9b346c..40819efa 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -12,6 +12,7 @@ import ( "maps" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "slices" @@ -468,6 +469,69 @@ func TestClaimRefRoundTrip(t *testing.T) { } } +func TestForkClaimRefPrefixWireShape(t *testing.T) { + st := startStack(t, "node-token") + status, body := rawJSON(t, st, http.MethodPost, "/v1/claim", `{"template":"rt:24.04"}`) + if status != http.StatusOK { + t.Fatalf("claim: %d %s", status, body) + } + var parent rawClaimResponse + if err := json.Unmarshal(body, &parent); err != nil { + t.Fatalf("decode claim %s: %v", body, err) + } + + fork := func(extra string) []rawClaimResponse { + t.Helper() + status, body := rawJSON(t, st, http.MethodPost, "/v1/sandboxes/"+parent.ID+"/fork", + fmt.Sprintf(`{"token":%q,"count":2,"ttl_seconds":60%s}`, parent.Token, extra)) + if status != http.StatusOK { + t.Fatalf("fork: %d %s", status, body) + } + var resp struct { + Children []rawClaimResponse `json:"children"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode fork %s: %v", body, err) + } + if len(resp.Children) != 2 { + t.Fatalf("fork children %s, want 2", body) + } + return resp.Children + } + row := func(route string) map[string]any { + t.Helper() + status, body := rawJSON(t, st, http.MethodGet, route, "") + if status != http.StatusOK { + t.Fatalf("GET %s: %d %s", route, status, body) + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decode %s: %v", body, err) + } + return out + } + + for _, child := range fork(`,"claim_ref_prefix":"ns/"`) { + want := "ns/" + child.ID + list, ok := row("/v1/sandboxes?claim_ref=" + url.QueryEscape(want))["sandboxes"].([]any) + if !ok || len(list) != 1 { + t.Fatalf("claim_ref=%s lists %v, want exactly child %s", want, list, child.ID) + } + listed, _ := list[0].(map[string]any) + if listed["id"] != child.ID || listed["claim_ref"] != want { + t.Errorf("claim_ref=%s row %v, want id %s claim_ref %s", want, listed, child.ID, want) + } + if got := row("/v1/sandboxes/" + child.ID)["claim_ref"]; got != want { + t.Errorf("by-id claim_ref %v, want %s", got, want) + } + } + for _, child := range fork("") { + if got, ok := row("/v1/sandboxes/" + child.ID)["claim_ref"]; ok { + t.Errorf("unprefixed fork child %s carries claim_ref %v", child.ID, got) + } + } +} + func TestAttachOnlyVolumeEndToEnd(t *testing.T) { scratch := writeVolumeImage(t, "scratch.img", "scratch-bytes") stack := startTenantStack(t, "node-token", nil, diff --git a/e2e/go.mod b/e2e/go.mod index 2760a6aa..f2eb5617 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -34,8 +34,8 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect - github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect - github.com/mdlayher/socket v0.5.1 // indirect + github.com/mdlayher/netlink v1.11.2 // indirect + github.com/mdlayher/socket v0.7.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/projecteru2/core v0.1.5 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect @@ -46,7 +46,7 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect - google.golang.org/grpc v1.83.2 // indirect + google.golang.org/grpc v1.84.0 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/e2e/go.sum b/e2e/go.sum index b080ead1..d4476e96 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -65,10 +65,10 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= -github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= -github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= +github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= +github.com/mdlayher/socket v0.7.0 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ= +github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -132,8 +132,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 h1:5t+ZydAFj5kGVLrgCvLmpmCf9ylGRd64hpEronfRaws= google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/mcp/go.mod b/mcp/go.mod index d94b1f32..fb2ec83e 100644 --- a/mcp/go.mod +++ b/mcp/go.mod @@ -22,10 +22,10 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/rs/zerolog v1.35.1 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect - google.golang.org/grpc v1.83.2 // indirect + google.golang.org/grpc v1.84.0 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/mcp/go.sum b/mcp/go.sum index db3529e9..9bc5e3d1 100644 --- a/mcp/go.sum +++ b/mcp/go.sum @@ -66,12 +66,12 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -82,8 +82,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 h1:5t+ZydAFj5kGVLrgCvLmpmCf9ylGRd64hpEronfRaws= google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/os-image/android/15/Dockerfile b/os-image/android/15/Dockerfile index b958ed3b..591cc594 100644 --- a/os-image/android/15/Dockerfile +++ b/os-image/android/15/Dockerfile @@ -9,7 +9,7 @@ ARG ANDROID_BASE_IMAGE=ghcr.io/cocoonstack/cocoon/android:15.0 # silkd carrier image (built from silkd/Dockerfile); CI passes the # version-pinned tag. -ARG SILKD_IMAGE=ghcr.io/cocoonstack/sandbox/silkd:0.1.12 +ARG SILKD_IMAGE=ghcr.io/cocoonstack/sandbox/silkd:0.1.13 FROM ${SILKD_IMAGE} AS silkd diff --git a/os-image/base/24.04/Dockerfile b/os-image/base/24.04/Dockerfile index 6a65305e..c4ff0d9f 100644 --- a/os-image/base/24.04/Dockerfile +++ b/os-image/base/24.04/Dockerfile @@ -10,7 +10,7 @@ ARG SILKD_IMAGE=ghcr.io/cocoonstack/sandbox/silkd:0.1.13 FROM ${BOOT_IMAGE} AS boot FROM ${SILKD_IMAGE} AS silkd -FROM ubuntu:24.04@sha256:224a1869083a311ef3f13648a154ba79832fbef6364d31493642ca03082da254 +FROM ubuntu:24.04@sha256:008173c23f95b170204355c12626cb5a965d779a7e1283b09e9cffbb1bf33ca3 ARG TARGETARCH ENV DEBIAN_FRONTEND=noninteractive # Content hash of the image dir's non-Dockerfile inputs (CI computes it). @@ -55,9 +55,9 @@ RUN --mount=type=secret,id=sandbox_install_agent \ # the agent — this is the sandbox product's readiness signal. Same # measured-fast ordering as the agent unit (no local-fs wait). # - # 3128 is silkd's loopback relay over vsock to the host proxy, the none - # lane's only way out. silkd forwards these into execs only on the - # no-network lane, so a lane with its own NIC never dials a closed relay. + # 3128 is silkd's loopback relay over vsock to the host proxy. silkd + # forwards these into execs only when nothing routes directly (no NIC, or + # a relay verdict in /etc/silkd-lane), so a routed NIC never dials a closed relay. printf '%s\n' \ '[Unit]' \ 'Description=silkd (sandbox product daemon: exec, files, sessions over vsock)' \ diff --git a/protocol/wire/LICENSE b/protocol/wire/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/protocol/wire/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/protocol/wire/frame.go b/protocol/wire/frame.go index d2fe575f..c6e1e549 100644 --- a/protocol/wire/frame.go +++ b/protocol/wire/frame.go @@ -176,7 +176,7 @@ func (Exec) Op() string { return "exec" } // Info asks for the daemon's identity and counters — the readiness probe. type Info struct{} -func (Info) Op() string { return "info" } //nolint:goconst // wire tag shared with the response type by design +func (Info) Op() string { return "info" } // Ps lists tracked processes. type Ps struct{} @@ -453,7 +453,7 @@ type Data struct { Data B64 `json:"data"` } -func (Data) Op() string { return "data" } //nolint:goconst // wire tag shared with the response type by design +func (Data) Op() string { return "data" } // DataEnd terminates an upload stream. type DataEnd struct{} diff --git a/sandboxd/ca.go b/sandboxd/ca.go index 488a1953..64df934d 100644 --- a/sandboxd/ca.go +++ b/sandboxd/ca.go @@ -88,7 +88,7 @@ func writeCAFiles(dir, name string, certPEM, keyPEM []byte, force bool) error { } } } - if err := writeKeyMaterial(certPath, certPEM, 0o644, force); err != nil { //nolint:gosec // public cert + if err := writeKeyMaterial(certPath, certPEM, 0o644, force); err != nil { return fmt.Errorf("write cert: %w", err) } if err := writeKeyMaterial(keyPath, keyPEM, 0o600, force); err != nil { diff --git a/sandboxd/config/config.go b/sandboxd/config/config.go index 3bbab9be..b7c4046b 100644 --- a/sandboxd/config/config.go +++ b/sandboxd/config/config.go @@ -112,7 +112,7 @@ func (e *EgressCAConfig) Set() bool { // TenantSpec declares one tenant: its bearer token and its live-claim quota. type TenantSpec struct { Name string `json:"name"` - Token string `json:"token"` //nolint:gosec // config field, not a hardcoded credential + Token string `json:"token"` MaxClaims int `json:"max_claims,omitzero"` Egress *egress.Policy `json:"egress,omitempty"` @@ -184,7 +184,7 @@ type Config struct { NoBalloon bool `json:"no_balloon,omitzero"` SyncClaims bool `json:"sync_claims,omitzero"` - APIToken string `json:"api_token,omitempty"` //nolint:gosec // config field, not a hardcoded credential + APIToken string `json:"api_token,omitempty"` Tenants []TenantSpec `json:"tenants,omitempty"` Secrets []egress.SecretSpec `json:"secrets,omitempty"` @@ -193,7 +193,7 @@ type Config struct { ArchiveDeleteAfterSeconds int `json:"archive_delete_after_seconds,omitzero"` PreviewListen string `json:"preview_listen,omitempty"` - PreviewSecret string `json:"preview_secret,omitempty"` //nolint:gosec // config field, not a hardcoded credential + PreviewSecret string `json:"preview_secret,omitempty"` PreviewAdvertise string `json:"preview_advertise,omitempty"` CheckpointDir string `json:"checkpoint_dir,omitempty"` diff --git a/sandboxd/egress/bench_test.go b/sandboxd/egress/bench_test.go index fb925f41..7786d298 100644 --- a/sandboxd/egress/bench_test.go +++ b/sandboxd/egress/bench_test.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "crypto/x509" "io" - "net" "net/http" "net/http/httptest" "testing" @@ -22,7 +21,7 @@ func BenchmarkSignLeaf(b *testing.B) { func BenchmarkInterceptedRequest(b *testing.B) { addr, roots := benchFront(b, true) - tc := benchSession(b, addr, roots) + tc := connectTLS(b, addr, "example.com:443", "example.com", roots) defer func() { _ = tc.Close() }() br := bufio.NewReader(tc) for b.Loop() { @@ -32,7 +31,7 @@ func BenchmarkInterceptedRequest(b *testing.B) { func BenchmarkSplicedRequest(b *testing.B) { addr, roots := benchFront(b, false) - tc := benchSession(b, addr, roots) + tc := connectTLS(b, addr, "example.com:443", "example.com", roots) defer func() { _ = tc.Close() }() br := bufio.NewReader(tc) for b.Loop() { @@ -43,7 +42,7 @@ func BenchmarkSplicedRequest(b *testing.B) { func BenchmarkInterceptedHandshake(b *testing.B) { addr, roots := benchFront(b, true) for b.Loop() { - tc := benchSession(b, addr, roots) + tc := connectTLS(b, addr, "example.com:443", "example.com", roots) _ = tc.Close() } } @@ -51,7 +50,7 @@ func BenchmarkInterceptedHandshake(b *testing.B) { func BenchmarkSplicedHandshake(b *testing.B) { addr, roots := benchFront(b, false) for b.Loop() { - tc := benchSession(b, addr, roots) + tc := connectTLS(b, addr, "example.com:443", "example.com", roots) _ = tc.Close() } } @@ -75,44 +74,13 @@ func benchFront(b *testing.B, intercept bool) (proxyAddr string, roots *x509.Cer } p := New(Policy{Allow: []Rule{rule}}, nil, ca, fixedDial(upstream.Listener.Addr().String()), nil, nil) if ca != nil { - p.mitmTr.TLSClientConfig.RootCAs = func() *x509.CertPool { - pool := x509.NewCertPool() - pool.AddCert(upstream.Certificate()) - return pool - }() + p.mitmTr.TLSClientConfig.RootCAs = trustUpstream(upstream) } front := httptest.NewServer(p) b.Cleanup(front.Close) return front.Listener.Addr().String(), roots } -func benchSession(b *testing.B, proxyAddr string, roots *x509.CertPool) *tls.Conn { - b.Helper() - conn, err := net.Dial("tcp", proxyAddr) - if err != nil { - b.Fatalf("dial proxy: %v", err) - } - if _, err := io.WriteString(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"); err != nil { - b.Fatalf("write CONNECT: %v", err) - } - buf := make([]byte, 1) - preamble := make([]byte, 0, 64) - for { - if _, err := io.ReadFull(conn, buf); err != nil { - b.Fatalf("read CONNECT reply: %v", err) - } - preamble = append(preamble, buf[0]) - if len(preamble) >= 4 && string(preamble[len(preamble)-4:]) == "\r\n\r\n" { - break - } - } - tc := tls.Client(conn, &tls.Config{ServerName: "example.com", RootCAs: roots}) - if err := tc.Handshake(); err != nil { - b.Fatalf("handshake: %v", err) - } - return tc -} - func benchRoundTrip(b *testing.B, tc *tls.Conn, br *bufio.Reader) { b.Helper() req, err := http.NewRequestWithContext(b.Context(), http.MethodGet, "https://example.com/x", nil) diff --git a/sandboxd/egress/intercept_test.go b/sandboxd/egress/intercept_test.go index 7a9c42fb..f7c31e11 100644 --- a/sandboxd/egress/intercept_test.go +++ b/sandboxd/egress/intercept_test.go @@ -258,21 +258,21 @@ func trustUpstream(upstream *httptest.Server) *x509.CertPool { return pool } -func connectTLS(t *testing.T, proxyAddr, target, serverName string, roots *x509.CertPool) *tls.Conn { - t.Helper() +func connectTLS(tb testing.TB, proxyAddr, target, serverName string, roots *x509.CertPool) *tls.Conn { + tb.Helper() conn, err := net.Dial("tcp", proxyAddr) if err != nil { - t.Fatalf("dial proxy: %v", err) + tb.Fatalf("dial proxy: %v", err) } if _, err := io.WriteString(conn, "CONNECT "+target+" HTTP/1.1\r\nHost: "+target+"\r\n\r\n"); err != nil { - t.Fatalf("write CONNECT: %v", err) + tb.Fatalf("write CONNECT: %v", err) } - if status := readPreamble(t, conn); !strings.Contains(status, "200") { - t.Fatalf("CONNECT status = %q, want 200", status) + if status := readPreamble(tb, conn); !strings.Contains(status, "200") { + tb.Fatalf("CONNECT status = %q, want 200", status) } tc := tls.Client(conn, &tls.Config{ServerName: serverName, RootCAs: roots}) if err := tc.Handshake(); err != nil { - t.Fatalf("guest tls handshake: %v", err) + tb.Fatalf("guest tls handshake: %v", err) } return tc } @@ -317,13 +317,13 @@ func roundTripTLSHost(t *testing.T, tc *tls.Conn, method, host string) *http.Res return resp } -func readPreamble(t *testing.T, conn net.Conn) string { - t.Helper() +func readPreamble(tb testing.TB, conn net.Conn) string { + tb.Helper() var sb strings.Builder var b [1]byte for sb.Len() < 512 { if _, err := io.ReadFull(conn, b[:]); err != nil { - t.Fatalf("read CONNECT preamble: %v", err) + tb.Fatalf("read CONNECT preamble: %v", err) } sb.WriteByte(b[0]) if strings.HasSuffix(sb.String(), "\r\n\r\n") { diff --git a/sandboxd/egress/policy.go b/sandboxd/egress/policy.go index fdca0dc3..ab4da0fc 100644 --- a/sandboxd/egress/policy.go +++ b/sandboxd/egress/policy.go @@ -24,7 +24,7 @@ type Rule struct { Host string `json:"host"` Methods []string `json:"methods,omitempty"` Ports []uint16 `json:"ports,omitempty"` - Secret string `json:"secret,omitempty"` //nolint:gosec // reference name of a node-side secret, never a value + Secret string `json:"secret,omitempty"` Intercept bool `json:"intercept,omitzero"` } diff --git a/sandboxd/egress/secrets.go b/sandboxd/egress/secrets.go index 097210d8..7a4711de 100644 --- a/sandboxd/egress/secrets.go +++ b/sandboxd/egress/secrets.go @@ -17,7 +17,7 @@ type SecretSpec struct { Name string `json:"name"` Header string `json:"header"` Value *string `json:"value,omitempty"` // rejected whenever present — value_env is the only source - ValueEnv string `json:"value_env"` //nolint:gosec // env var name, not a value + ValueEnv string `json:"value_env"` } func (s SecretSpec) Validate() error { diff --git a/sandboxd/engine/engine.go b/sandboxd/engine/engine.go index cc26fe80..3696cbe1 100644 --- a/sandboxd/engine/engine.go +++ b/sandboxd/engine/engine.go @@ -470,7 +470,7 @@ func readLine(conn net.Conn, max int) (string, error) { if b[0] == '\n' { return sb.String(), nil } - sb.WriteByte(b[0]) //nolint:gosec // G602 false positive on [1]byte in gosec ≤ v2.9.0 + sb.WriteByte(b[0]) } return "", fmt.Errorf("reply exceeds %d bytes", max) } diff --git a/sandboxd/engine/portconn.go b/sandboxd/engine/portconn.go index f5af9f6a..ca88ec55 100644 --- a/sandboxd/engine/portconn.go +++ b/sandboxd/engine/portconn.go @@ -48,7 +48,7 @@ func (g *guestPortConn) Read(p []byte) (int, error) { return 0, fmt.Errorf("port frame: %w", err) } switch frame.Type { - case "data": //nolint:goconst // wire tag, kept literal by design + case "data": g.pending = frame.Data case "done", "": return 0, io.EOF // the guest closed the forwarded port diff --git a/sandboxd/go.mod b/sandboxd/go.mod index c10f2b8f..48c3ed6a 100644 --- a/sandboxd/go.mod +++ b/sandboxd/go.mod @@ -53,8 +53,8 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect - github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect - github.com/mdlayher/socket v0.5.1 // indirect + github.com/mdlayher/netlink v1.11.2 // indirect + github.com/mdlayher/socket v0.7.0 // indirect github.com/miekg/dns v1.1.73 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect @@ -62,7 +62,7 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect golang.org/x/text v0.42.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect - google.golang.org/grpc v1.83.2 // indirect + google.golang.org/grpc v1.84.0 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/sandboxd/go.sum b/sandboxd/go.sum index 3fab3a3d..76aaa801 100644 --- a/sandboxd/go.sum +++ b/sandboxd/go.sum @@ -87,10 +87,10 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= -github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= -github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= +github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= +github.com/mdlayher/socket v0.7.0 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ= +github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4= github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -158,8 +158,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 h1:5t+ZydAFj5kGVLrgCvLmpmCf9ylGRd64hpEronfRaws= google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/sandboxd/mesh/mesh.go b/sandboxd/mesh/mesh.go index 85dd4c26..8a99d243 100644 --- a/sandboxd/mesh/mesh.go +++ b/sandboxd/mesh/mesh.go @@ -55,7 +55,7 @@ type Mesh struct { // New starts a mesh member listening per cfg. func New(ctx context.Context, cfg *memberlist.Config, nodeID, selfAddr string, secretKey []byte, dataDir string) (*Mesh, error) { epochPath := filepath.Join(dataDir, "mesh-epoch") - epoch := max(uint64(time.Now().UnixNano()), loadEpoch(epochPath)+1) //nolint:gosec // UnixNano is positive for current times + epoch := max(uint64(time.Now().UnixNano()), loadEpoch(epochPath)+1) m := &Mesh{ ctx: ctx, epochPath: epochPath, diff --git a/sandboxd/netfilter/nft_linux.go b/sandboxd/netfilter/nft_linux.go index 3c493919..92805d55 100644 --- a/sandboxd/netfilter/nft_linux.go +++ b/sandboxd/netfilter/nft_linux.go @@ -21,14 +21,13 @@ func Lock(tap string) error { return fmt.Errorf("nft conn: %w", err) } t := c.AddTable(&nftables.Table{Family: nftables.TableFamilyNetdev, Name: tablePrefix + tap}) - policy := nftables.ChainPolicyAccept ch := c.AddChain(&nftables.Chain{ Name: "ingress", Table: t, Type: nftables.ChainTypeFilter, Hooknum: nftables.ChainHookIngress, Priority: nftables.ChainPriorityFilter, - Policy: &policy, + Policy: new(nftables.ChainPolicyAccept), Device: tap, }) // accept only IPv4 broadcast DHCP; egress VMs must not share a broadcast domain. diff --git a/sandboxd/pool/checkpoint.go b/sandboxd/pool/checkpoint.go index 8f8fa34a..da1e9f72 100644 --- a/sandboxd/pool/checkpoint.go +++ b/sandboxd/pool/checkpoint.go @@ -233,7 +233,7 @@ func (m *Manager) healCheckpoint(ctx context.Context, ckptID string) (types.Chec return ckpt, nil } resCh := m.healFlights.DoChan(ckptID, func() (any, error) { - return m.runHeal(ckptID) + return m.runHeal(context.WithoutCancel(ctx), ckptID) }) select { case res := <-resCh: @@ -247,7 +247,7 @@ func (m *Manager) healCheckpoint(ctx context.Context, ckptID string) (types.Chec } // runHeal stages and pulls without ckptID's record lock: a pull runs up to 30 minutes. -func (m *Manager) runHeal(ckptID string) (types.Checkpoint, error) { +func (m *Manager) runHeal(ctx context.Context, ckptID string) (types.Checkpoint, error) { select { case m.healSem <- struct{}{}: defer func() { <-m.healSem }() @@ -261,7 +261,6 @@ func (m *Manager) runHeal(ckptID string) (types.Checkpoint, error) { return types.Checkpoint{}, fmt.Errorf("stage healed checkpoint: %w", err) } defer func() { _ = os.RemoveAll(staging) }() - ctx := context.Background() validate := func(dir string) error { return validateHealedCheckpoint(dir, ckptID) } if err := m.healer.Pull(ctx, ckptID, staging, validate); err != nil { m.clearHealPending(ckptID) diff --git a/sandboxd/pool/claim.go b/sandboxd/pool/claim.go index 3487f27b..0fd883b5 100644 --- a/sandboxd/pool/claim.go +++ b/sandboxd/pool/claim.go @@ -360,7 +360,7 @@ func (m *Manager) finalizeBatch(ctx context.Context, sbs []*types.Sandbox, ttl t // usage lands only after the batch armed, so a rollback leaves no unterminated claim event for _, sb := range sbs { m.recordUsage(ctx, usageEvent{ - Event: "claim", //nolint:goconst // event name; other occurrences are test assertions + Event: "claim", ID: sb.ID, VMName: sb.VMName, KeyHash: sb.Key.Hash(), Tenant: sb.Tenant, Volumes: types.VolumeNames(sb.Volumes), VolumesRW: types.VolumeRWNames(sb.Volumes), diff --git a/sandboxd/pool/drain_test.go b/sandboxd/pool/drain_test.go index 76aa5918..fcbbbba1 100644 --- a/sandboxd/pool/drain_test.go +++ b/sandboxd/pool/drain_test.go @@ -2,8 +2,6 @@ package pool import ( "errors" - "os" - "path/filepath" "testing" "testing/synctest" @@ -14,10 +12,7 @@ func TestDrainRefusesClaimsTrimsWarmAndUncordonRefills(t *testing.T) { synctest.Test(t, func(t *testing.T) { eng := newFakeEngine() m := newTestManager(t, eng) - goldenDir := filepath.Join(m.goldensDir(), testKey.Hash()) - if err := os.MkdirAll(goldenDir, 0o750); err != nil { - t.Fatalf("setup golden: %v", err) - } + seedGolden(t, m) if err := m.SetPools(t.Context(), []config.PoolSpec{{PoolKey: testKey, Warm: 2}}); err != nil { t.Fatalf("SetPools: %v", err) } diff --git a/sandboxd/pool/egress.go b/sandboxd/pool/egress.go index fa06ed4e..bfb3e48a 100644 --- a/sandboxd/pool/egress.go +++ b/sandboxd/pool/egress.go @@ -363,7 +363,7 @@ func reached(ln net.Listener) bool { _ = rc.Control(func(fd uintptr) { syscall.ForkLock.RLock() defer syscall.ForkLock.RUnlock() - nfd, _, err := syscall.Accept(int(fd)) //nolint:gosec // a descriptor, not arithmetic + nfd, _, err := syscall.Accept(int(fd)) if err == nil { _ = syscall.Close(nfd) return diff --git a/sandboxd/pool/egress_test.go b/sandboxd/pool/egress_test.go index 4442061f..15bf8382 100644 --- a/sandboxd/pool/egress_test.go +++ b/sandboxd/pool/egress_test.go @@ -142,23 +142,26 @@ func TestLaneVerdictReachesGuestOnColdBoots(t *testing.T) { } } -func TestGoldenNICSidecarGatesAdoption(t *testing.T) { +func TestGoldenStampGatesAdoptionOnLane(t *testing.T) { m := egressManager(t, newFakeEngine(), config.PoolSpec{PoolKey: egKey, Warm: 1, Egress: egPolicy}) p := m.pools[egKey] g := filepath.Join(m.goldensDir(), egKey.Hash()) if err := os.MkdirAll(g, 0o750); err != nil { t.Fatalf("stage golden: %v", err) } + if err := os.WriteFile(g+goldenStampSuffix, []byte(m.goldenStamp(testKey, false, nil)), 0o644); err != nil { + t.Fatalf("write stamp: %v", err) + } m.adoptGolden(p) if p.goldenDir != "" { t.Error("adopted an egress-lane golden that never marked its guest; want rebuild") } - if err := writeGoldenSidecar(g+nicSidecarSuffix, string(engine.LaneRelay)); err != nil { - t.Fatalf("write sidecar: %v", err) + if err := os.WriteFile(g+goldenStampSuffix, []byte(m.goldenStamp(egKey, false, nil)), 0o644); err != nil { + t.Fatalf("write stamp: %v", err) } m.adoptGolden(p) if p.goldenDir != g { - t.Error("rejected an egress-lane golden whose sidecar says the guest was marked") + t.Error("rejected an egress-lane golden whose stamp says the guest was marked") } } @@ -488,6 +491,9 @@ func TestSetPoolsPreservesEgressPolicy(t *testing.T) { if err := os.MkdirAll(gd, 0o750); err != nil { t.Fatalf("golden dir: %v", err) } + if err := os.WriteFile(gd+goldenStampSuffix, []byte(m.goldenStamp(egKey, m.poolIntercepts(egKey), nil)), 0o644); err != nil { + t.Fatalf("golden stamp: %v", err) + } m.mu.Lock() m.pools[egKey].goldenDir = gd m.mu.Unlock() @@ -808,9 +814,7 @@ func dialDoors(t *testing.T, sb *types.Sandbox) { func refillWarmVM(t *testing.T, m *Manager) *types.Sandbox { t.Helper() - if err := os.MkdirAll(filepath.Join(m.goldensDir(), testKey.Hash()), 0o750); err != nil { - t.Fatalf("setup golden: %v", err) - } + seedGolden(t, m) m.mu.Lock() m.adoptGolden(m.pools[testKey]) m.mu.Unlock() diff --git a/sandboxd/pool/fork.go b/sandboxd/pool/fork.go index c6c211c6..6b2b906f 100644 --- a/sandboxd/pool/fork.go +++ b/sandboxd/pool/fork.go @@ -42,7 +42,7 @@ func (m *Manager) Fork(ctx context.Context, id string, cred Cred, count int, ttl return nil, fmt.Errorf("fork %s: %w", sb.ID, err) } m.counters.forks.Add(1) - m.counters.claimsClone.Add(uint64(len(children))) //nolint:gosec // count is bounded by maxFork + m.counters.claimsClone.Add(uint64(len(children))) ids := make([]string, len(children)) for i, c := range children { ids[i] = c.ID diff --git a/sandboxd/pool/intercept_test.go b/sandboxd/pool/intercept_test.go index 4a7655e7..6779e4a4 100644 --- a/sandboxd/pool/intercept_test.go +++ b/sandboxd/pool/intercept_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/cocoonstack/sandbox/sandboxd/config" @@ -26,12 +27,12 @@ func TestGoldenBuildInstallsCAForInterceptPool(t *testing.T) { if len(eng.caInstalls) != 1 { t.Fatalf("InstallCACert calls = %d, want 1", len(eng.caInstalls)) } - fp, err := os.ReadFile(final + caSidecarSuffix) + stamp, err := os.ReadFile(final + goldenStampSuffix) if err != nil { - t.Fatalf("read ca sidecar: %v", err) + t.Fatalf("read golden stamp: %v", err) } - if string(fp) != m.egressCA.Fingerprint() { - t.Errorf("sidecar fingerprint = %q, want %q", fp, m.egressCA.Fingerprint()) + if want := m.goldenStamp(interceptKey, true, nil); string(stamp) != want { + t.Errorf("stamp = %q, want %q", stamp, want) } } @@ -49,33 +50,42 @@ func TestGoldenBuildSkipsCAForPlainPool(t *testing.T) { if len(eng.caInstalls) != 0 { t.Errorf("InstallCACert called %d times for a plain pool", len(eng.caInstalls)) } - if _, err := os.Stat(final + caSidecarSuffix); !errors.Is(err, os.ErrNotExist) { - t.Errorf("ca sidecar present for a plain pool: %v", err) + if stamp, err := os.ReadFile(final + goldenStampSuffix); err != nil || string(stamp) != m.goldenStamp(interceptKey, false, nil) { + t.Errorf("stamp = %q (%v), want one without a CA fingerprint", stamp, err) } } -func TestGoldenCAMatchRebuildsOnMismatch(t *testing.T) { +func TestGoldenCAStampRebuildsOnMismatch(t *testing.T) { m := egressManager(t, newFakeEngine(), config.PoolSpec{PoolKey: interceptKey, Warm: 1, Egress: interceptPolicy()}) + p := m.pools[interceptKey] final := filepath.Join(m.goldensDir(), interceptKey.Hash()) if err := os.MkdirAll(final, 0o750); err != nil { t.Fatalf("stage golden: %v", err) } - if m.goldenCAMatches(final, true) { - t.Error("adopted an intercept golden with no CA sidecar; want rebuild") + m.adoptGolden(p) + if p.goldenDir != "" { + t.Error("adopted an intercept golden with no stamp; want rebuild") } - if err := os.WriteFile(final+caSidecarSuffix, []byte("deadbeef"), 0o644); err != nil { - t.Fatalf("write stale sidecar: %v", err) + baked := m.goldenStamp(interceptKey, true, nil) + stale := strings.Replace(baked, m.egressCA.Fingerprint(), "deadbeef", 1) + if err := os.WriteFile(final+goldenStampSuffix, []byte(stale), 0o644); err != nil { + t.Fatalf("write stale stamp: %v", err) } - if m.goldenCAMatches(final, true) { + m.adoptGolden(p) + if p.goldenDir != "" { t.Error("adopted an intercept golden with a stale CA fingerprint; want rebuild") } - if err := os.WriteFile(final+caSidecarSuffix, []byte(m.egressCA.Fingerprint()), 0o644); err != nil { - t.Fatalf("write matching sidecar: %v", err) + if err := os.WriteFile(final+goldenStampSuffix, []byte(baked), 0o644); err != nil { + t.Fatalf("write matching stamp: %v", err) } - if !m.goldenCAMatches(final, true) { + m.adoptGolden(p) + if p.goldenDir != final { t.Error("rejected an intercept golden whose CA fingerprint matches") } - if m.goldenCAMatches(final, false) { + p.goldenDir = "" + m.poolEgress[interceptKey] = &egress.Policy{Allow: []egress.Rule{{Host: "api.github.com", Secret: "gh"}}} + m.adoptGolden(p) + if p.goldenDir != "" { t.Error("adopted a CA-baked golden for a now-plain pool; want rebuild") } } diff --git a/sandboxd/pool/journal.go b/sandboxd/pool/journal.go index 11440635..fbbb31c4 100644 --- a/sandboxd/pool/journal.go +++ b/sandboxd/pool/journal.go @@ -17,12 +17,12 @@ type usageEvent struct { Event string `json:"ev"` ID string `json:"id"` VMName string `json:"vm,omitempty"` - KeyHash string `json:"key,omitempty"` // claim only - Tenant string `json:"tenant,omitempty"` // claim only - Volumes []string `json:"volumes,omitempty"` // claim only - VolumesRW []string `json:"volumes_rw,omitempty"` // claim only - Children []string `json:"children,omitempty"` // fork only - Reference string `json:"ref,omitempty"` // promote: template; checkpoint: ckpt id + KeyHash string `json:"key,omitempty"` + Tenant string `json:"tenant,omitempty"` + Volumes []string `json:"volumes,omitempty"` + VolumesRW []string `json:"volumes_rw,omitempty"` + Children []string `json:"children,omitempty"` + Reference string `json:"ref,omitempty"` } // journal is an append-only JSONL writer with size rotation; its lock only orders appends. @@ -66,7 +66,7 @@ func (j *journal) append(v any) error { // rotate moves the live file to .1 and reopens; the old descriptor closes last. func (j *journal) rotate() error { renameErr := os.Rename(j.path, j.path+".1") - f, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) //nolint:gosec // under data_dir + f, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return errors.Join(renameErr, err) } diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index 010cce1e..38077073 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -71,9 +71,7 @@ const ( vmStateRunning = "running" vmStateCreating = "creating" - caSidecarSuffix = ".cafp" - nicSidecarSuffix = ".nic" - warmupSidecarSuffix = ".warmup" + goldenStampSuffix = ".stamp" ) var ( @@ -136,7 +134,7 @@ type SandboxSummary struct { Archived bool `json:"archived,omitzero"` FromCheckpoint string `json:"from_checkpoint,omitempty"` Volumes []types.Volume `json:"volumes,omitempty"` - // ClaimRef echoes the caller reference; empty for fork and checkpoint-branch claims. + // ClaimRef echoes the caller reference; empty for checkpoint branches and unprefixed forks. ClaimRef string `json:"claim_ref,omitempty"` // Token is the sandbox's own bearer token; only the root by-id read carries it. Token string `json:"token,omitempty"` @@ -650,15 +648,15 @@ func summarize(sb *types.Sandbox) SandboxSummary { } func loadEgressCA(cfg *config.EgressCAConfig) (*egress.CA, error) { - root, err := os.ReadFile(cfg.RootCert) //nolint:gosec // operator-configured ca path + root, err := os.ReadFile(cfg.RootCert) if err != nil { return nil, fmt.Errorf("read root cert: %w", err) } - interCert, err := os.ReadFile(cfg.IntermediateCert) //nolint:gosec // operator-configured ca path + interCert, err := os.ReadFile(cfg.IntermediateCert) if err != nil { return nil, fmt.Errorf("read intermediate cert: %w", err) } - interKey, err := os.ReadFile(cfg.IntermediateKey) //nolint:gosec // operator-configured ca path + interKey, err := os.ReadFile(cfg.IntermediateKey) if err != nil { return nil, fmt.Errorf("read intermediate key: %w", err) } diff --git a/sandboxd/pool/pool_test.go b/sandboxd/pool/pool_test.go index 0acd7de5..80b17997 100644 --- a/sandboxd/pool/pool_test.go +++ b/sandboxd/pool/pool_test.go @@ -322,10 +322,7 @@ func TestReconcile(t *testing.T) { t.Fatalf("setup: %v", err) } m := newTestManagerAt(t, eng, dataDir, config.PoolSpec{PoolKey: testKey, Warm: 1}) - goldenDir := filepath.Join(dataDir, "goldens", testKey.Hash()) - if err := os.MkdirAll(goldenDir, 0o750); err != nil { - t.Fatalf("setup: %v", err) - } + goldenDir := seedGolden(t, m) if err := m.Reconcile(t.Context()); err != nil { t.Fatalf("Reconcile: %v", err) @@ -372,10 +369,7 @@ func TestSetPoolsGrowShrinkAndDrain(t *testing.T) { synctest.Test(t, func(t *testing.T) { eng := newFakeEngine() m := newTestManager(t, eng) - goldenDir := filepath.Join(m.goldensDir(), testKey.Hash()) - if err := os.MkdirAll(goldenDir, 0o750); err != nil { - t.Fatalf("setup golden: %v", err) - } + seedGolden(t, m) if err := m.SetPools(t.Context(), []config.PoolSpec{{PoolKey: testKey, Warm: 2}}); err != nil { t.Fatalf("SetPools grow: %v", err) @@ -1001,6 +995,18 @@ func waitFor(t *testing.T, cond func() bool) { t.Fatal("condition not met within 3s") } +func seedGolden(t *testing.T, m *Manager) string { + t.Helper() + g := filepath.Join(m.goldensDir(), testKey.Hash()) + if err := os.MkdirAll(g, 0o750); err != nil { + t.Fatalf("seed golden: %v", err) + } + if err := os.WriteFile(g+goldenStampSuffix, []byte(m.goldenStamp(testKey, false, nil)), 0o644); err != nil { + t.Fatalf("seed golden stamp: %v", err) + } + return g +} + type fakeEngine struct { mu sync.Mutex vms map[string]string diff --git a/sandboxd/pool/refill.go b/sandboxd/pool/refill.go index b25f9ae7..5373b717 100644 --- a/sandboxd/pool/refill.go +++ b/sandboxd/pool/refill.go @@ -225,13 +225,11 @@ func (m *Manager) buildGoldenSteps(ctx context.Context, key types.PoolKey, name, if err := m.exportGolden(ctx, snap, final); err != nil { return err } - if err := m.writeGoldenCASidecar(final, caBaked); err != nil { - return err - } - if err := writeGoldenSidecar(final+nicSidecarSuffix, string(m.laneOf(key))); err != nil { - return err + stamp := m.goldenStamp(key, caBaked, warmup) + if err := os.WriteFile(final+goldenStampSuffix, []byte(stamp), 0o644); err != nil { //nolint:gosec // public stamp + return fmt.Errorf("write golden stamp: %w", err) } - return writeGoldenSidecar(final+warmupSidecarSuffix, warmupStamp(warmup)) + return nil } func (m *Manager) runWarmup(ctx context.Context, key types.PoolKey, sock string) ([]string, error) { @@ -253,30 +251,22 @@ func (m *Manager) warmClone(ctx context.Context, sb *types.Sandbox) error { return nil } -// writeGoldenCASidecar records or clears the baked-CA fingerprint; a rotated CA forces a rebuild. -func (m *Manager) writeGoldenCASidecar(final string, caBaked bool) error { - var stamp string - if caBaked { - stamp = m.egressCA.Fingerprint() - } - return writeGoldenSidecar(final+caSidecarSuffix, stamp) -} - -// adoptGolden points p at an on-disk golden whose baked-CA state and warmup still fit. +// adoptGolden points p at an on-disk golden whose stamp matches what the pool bakes now. func (m *Manager) adoptGolden(p *pool) { g := filepath.Join(m.goldensDir(), p.hash) - if dirExists(g) && m.goldenCAMatches(g, m.poolEgress[p.key].Intercepts()) && - goldenSidecarMatches(g+nicSidecarSuffix, string(m.laneOf(p.key))) && - goldenSidecarMatches(g+warmupSidecarSuffix, warmupStamp(m.poolWarmups[p.key])) { + stamp, err := os.ReadFile(g + goldenStampSuffix) //nolint:gosec // node-local golden path + want := m.goldenStamp(p.key, m.poolEgress[p.key].Intercepts(), m.poolWarmups[p.key]) + if err == nil && string(stamp) == want && dirExists(g) { p.goldenDir = g } } -func (m *Manager) goldenCAMatches(final string, caNeeded bool) bool { - if !caNeeded { - return goldenSidecarMatches(final+caSidecarSuffix, "") +func (m *Manager) goldenStamp(key types.PoolKey, caBaked bool, warmup []string) string { + var fp string + if caBaked { + fp = m.EgressCAFingerprint() } - return m.egressCA != nil && goldenSidecarMatches(final+caSidecarSuffix, m.egressCA.Fingerprint()) + return strings.Join(append([]string{fp, string(m.laneOf(key))}, warmup...), "\x00") } // exportGolden exports snap into final through a unique sibling *.tmp dir. @@ -500,28 +490,3 @@ func (m *Manager) dropSnap(ctx context.Context, snap string) { log.WithFunc("pool.dropSnap").Warnf(ctx, "drop snapshot %s: %v", snap, err) } } - -func writeGoldenSidecar(path, stamp string) error { - if stamp == "" { - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("clear golden sidecar %s: %w", filepath.Base(path), err) - } - return nil - } - if err := os.WriteFile(path, []byte(stamp), 0o644); err != nil { //nolint:gosec // public stamp - return fmt.Errorf("write golden sidecar %s: %w", filepath.Base(path), err) - } - return nil -} - -func goldenSidecarMatches(path, stamp string) bool { - got, err := os.ReadFile(path) //nolint:gosec // node-local golden path - if stamp == "" { - return errors.Is(err, os.ErrNotExist) - } - return err == nil && string(got) == stamp -} - -func warmupStamp(argv []string) string { - return strings.Join(argv, "\x00") -} diff --git a/sandboxd/pool/stats.go b/sandboxd/pool/stats.go index b258f6e1..c60b0469 100644 --- a/sandboxd/pool/stats.go +++ b/sandboxd/pool/stats.go @@ -61,7 +61,7 @@ func (m *Manager) vmResidentBytes(ctx context.Context, vmName string) (int64, bo // residentBytes reads a process's resident page count from statm's second field. func residentBytes(pid int) (int64, bool) { - b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "statm")) //nolint:gosec // pid comes from cocoon's own VM record + b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "statm")) if err != nil { return 0, false } diff --git a/sandboxd/pool/telemetry.go b/sandboxd/pool/telemetry.go index 68592cb8..b2e4ca59 100644 --- a/sandboxd/pool/telemetry.go +++ b/sandboxd/pool/telemetry.go @@ -58,7 +58,7 @@ type auditFrame struct { Session string `json:"session,omitempty"` Port uint16 `json:"port,omitzero"` Decision string `json:"decision,omitempty"` - Secret string `json:"secret,omitempty"` //nolint:gosec // the secret's ref name, never its value + Secret string `json:"secret,omitempty"` } func (m *Manager) Counters() Counters { diff --git a/sandboxd/pool/warmup_test.go b/sandboxd/pool/warmup_test.go index 0054ec39..2cb7f3b2 100644 --- a/sandboxd/pool/warmup_test.go +++ b/sandboxd/pool/warmup_test.go @@ -26,12 +26,12 @@ func TestGoldenBuildRunsWarmupBeforeSnapshot(t *testing.T) { if eng.warmupAfterSnap { t.Fatal("golden warmup ran after the snapshot save") } - stamp, err := os.ReadFile(final + warmupSidecarSuffix) + stamp, err := os.ReadFile(final + goldenStampSuffix) if err != nil { - t.Fatalf("read warmup sidecar: %v", err) + t.Fatalf("read golden stamp: %v", err) } - if string(stamp) != warmupStamp(argv) { - t.Errorf("sidecar = %q, want %q", stamp, warmupStamp(argv)) + if want := m.goldenStamp(testKey, false, argv); string(stamp) != want { + t.Errorf("stamp = %q, want %q", stamp, want) } } @@ -45,8 +45,8 @@ func TestGoldenBuildSkipsWarmupWhenUnset(t *testing.T) { if len(eng.warmups) != 0 { t.Errorf("Warmup called %d times for a pool without one", len(eng.warmups)) } - if _, err := os.Stat(final + warmupSidecarSuffix); !errors.Is(err, os.ErrNotExist) { - t.Errorf("warmup sidecar present for a pool without one: %v", err) + if stamp, err := os.ReadFile(final + goldenStampSuffix); err != nil || string(stamp) != m.goldenStamp(testKey, false, nil) { + t.Errorf("stamp = %q (%v), want one without a warmup", stamp, err) } } @@ -56,20 +56,23 @@ func TestAdoptGoldenRequiresMatchingWarmup(t *testing.T) { if err := os.MkdirAll(final, 0o755); err != nil { t.Fatalf("mkdir golden: %v", err) } + if err := os.WriteFile(final+goldenStampSuffix, []byte(m.goldenStamp(testKey, false, nil)), 0o644); err != nil { + t.Fatalf("write stamp: %v", err) + } p := m.pools[testKey] m.adoptGolden(p) if p.goldenDir != "" { t.Error("adopted a golden built without the warmup") } - if err := os.WriteFile(final+warmupSidecarSuffix, []byte(warmupStamp([]string{"python3", "-c", "0"})), 0o644); err != nil { - t.Fatalf("write sidecar: %v", err) + if err := os.WriteFile(final+goldenStampSuffix, []byte(m.goldenStamp(testKey, false, []string{"python3", "-c", "0"})), 0o644); err != nil { + t.Fatalf("write stamp: %v", err) } m.adoptGolden(p) if p.goldenDir != "" { t.Error("adopted a golden built with a different warmup") } - if err := os.WriteFile(final+warmupSidecarSuffix, []byte(warmupStamp([]string{"node", "-e", "0"})), 0o644); err != nil { - t.Fatalf("write sidecar: %v", err) + if err := os.WriteFile(final+goldenStampSuffix, []byte(m.goldenStamp(testKey, false, []string{"node", "-e", "0"})), 0o644); err != nil { + t.Fatalf("write stamp: %v", err) } m.adoptGolden(p) if p.goldenDir != final { diff --git a/sandboxd/server/metrics.go b/sandboxd/server/metrics.go index 6ed442f3..c9b86e54 100644 --- a/sandboxd/server/metrics.go +++ b/sandboxd/server/metrics.go @@ -71,7 +71,7 @@ func (s *Server) handleMetrics(w http.ResponseWriter, _ *http.Request) { {"reaps_total", "claims reaped at deadline", c.Reaps}, {"archives_total", "claims archived to the store", c.Archives}, {"unarchives_total", "archived claims restored", c.Unarchives}, - {"archive_deletes_total", "archived checkpoints purged at retention", c.ArchiveDeletes}, + {"archive_deletes_total", "archived checkpoints deleted at release or retention", c.ArchiveDeletes}, } { metric(row.name, "counter", row.help) _, _ = fmt.Fprintf(w, "sandboxd_%s %d\n", row.name, row.value) diff --git a/sandboxd/server/preview.go b/sandboxd/server/preview.go index 66467ecb..e408f456 100644 --- a/sandboxd/server/preview.go +++ b/sandboxd/server/preview.go @@ -136,7 +136,7 @@ func (p *PreviewServer) proxyLocal(w http.ResponseWriter, r *http.Request, claim http.Error(w, "preview target unreachable", http.StatusBadGateway) }, } - rp.ServeHTTP(w, r) //nolint:gosec // target derived from an HMAC-signed token, not client input + rp.ServeHTTP(w, r) } func (p *PreviewServer) forward(w http.ResponseWriter, r *http.Request, owner string) { @@ -151,7 +151,7 @@ func (p *PreviewServer) forward(w http.ResponseWriter, r *http.Request, owner st http.Error(w, "owner node unreachable", http.StatusBadGateway) }, } - rp.ServeHTTP(w, r) //nolint:gosec // owner host comes from an HMAC-signed token + rp.ServeHTTP(w, r) } func (p *PreviewServer) sign(enc string) string { diff --git a/sandboxd/server/server_test.go b/sandboxd/server/server_test.go index 6044f6b1..15b78d32 100644 --- a/sandboxd/server/server_test.go +++ b/sandboxd/server/server_test.go @@ -2101,11 +2101,11 @@ func (f *fakeManager) ClaimWarm(ctx context.Context, key types.PoolKey, ttl time } func (f *fakeManager) ClaimProvision(ctx context.Context, key types.PoolKey, ttl time.Duration, tenant, claimRef string, volumes []types.Volume) (*types.Sandbox, error) { - return fakeClaimProvision(f, ctx, key, ttl, tenant, claimRef, volumes, false) + return fakeClaimProvision(ctx, f, key, ttl, tenant, claimRef, volumes, false) } func (f *fakeManager) ClaimProvisionPromoted(ctx context.Context, key types.PoolKey, ttl time.Duration, tenant, claimRef string, volumes []types.Volume) (*types.Sandbox, error) { - return fakeClaimProvision(f, ctx, key, ttl, tenant, claimRef, volumes, true) + return fakeClaimProvision(ctx, f, key, ttl, tenant, claimRef, volumes, true) } func (f *fakeManager) Release(_ context.Context, id string, cred pool.Cred) error { @@ -2420,7 +2420,7 @@ func postJSON(t *testing.T, url, token, body string) *http.Response { return resp } -func fakeClaimProvision(f *fakeManager, ctx context.Context, key types.PoolKey, ttl time.Duration, tenant, claimRef string, volumes []types.Volume, requirePromoted bool) (*types.Sandbox, error) { +func fakeClaimProvision(ctx context.Context, f *fakeManager, key types.PoolKey, ttl time.Duration, tenant, claimRef string, volumes []types.Volume, requirePromoted bool) (*types.Sandbox, error) { f.provisionCalls++ f.gotTenant = tenant f.gotClaimRef = claimRef diff --git a/sandboxd/store/dir/dir.go b/sandboxd/store/dir/dir.go index 07b356d9..ab4fd8e0 100644 --- a/sandboxd/store/dir/dir.go +++ b/sandboxd/store/dir/dir.go @@ -184,7 +184,7 @@ func (d *Store) publish(_ context.Context, staging, id, digest string) error { case errors.Is(statErr, fs.ErrNotExist): stagedExport := filepath.Join(staging, store.ExportDir) // make the generation fresh before a peer can observe it without committed meta. - if err := os.Chtimes(stagedExport, now, now); err != nil { //nolint:gosec // our own staging dir + if err := os.Chtimes(stagedExport, now, now); err != nil { return err } if err := os.Rename(stagedExport, genDir); err != nil { //nolint:gosec // G703: our own staging dir @@ -285,14 +285,14 @@ func touchCurrentGeneration(final string, now time.Time) error { return err } current := filepath.Join(final, store.ExportGen(meta)) - exportErr := os.Chtimes(current, now, now) //nolint:gosec // current is a hash-derived name under our root + exportErr := os.Chtimes(current, now, now) if errors.Is(exportErr, fs.ErrNotExist) { return nil } if exportErr != nil { return exportErr } - digestErr := os.Chtimes(filepath.Join(final, digestName(meta)), now, now) //nolint:gosec // hash-derived path under our root + digestErr := os.Chtimes(filepath.Join(final, digestName(meta)), now, now) if errors.Is(digestErr, fs.ErrNotExist) { return nil } @@ -413,7 +413,7 @@ func collectDigestSources(root string) ([]digestSource, error) { } func hashChunk(source *digestSource, chunk int, buffer []byte) error { - f, err := os.Open(source.path) //nolint:gosec // path walked from private export staging + f, err := os.Open(source.path) if err != nil { return fmt.Errorf("open export entry %s: %w", source.digest.Path, err) } diff --git a/sandboxd/store/peer/broadcast.go b/sandboxd/store/peer/broadcast.go index 6f6c173f..8176f8c8 100644 --- a/sandboxd/store/peer/broadcast.go +++ b/sandboxd/store/peer/broadcast.go @@ -55,7 +55,7 @@ func deleteOn(ctx context.Context, client *http.Client, addr, id, token string) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } - resp, err := client.Do(req) //nolint:gosec // addr comes from the mesh's own member view + resp, err := client.Do(req) if err != nil { return err } diff --git a/sandboxd/store/peer/probe.go b/sandboxd/store/peer/probe.go index 3d3498d6..d43ac5ca 100644 --- a/sandboxd/store/peer/probe.go +++ b/sandboxd/store/peer/probe.go @@ -227,7 +227,7 @@ func probeOwner(ctx context.Context, client *http.Client, probeKey []byte, addr, if len(probeKey) > 0 { req.Header.Set(ProbeHeader, SignProbe(probeKey, id)) } - resp, err := client.Do(req) //nolint:gosec // addr comes from the mesh's own member view + resp, err := client.Do(req) if err != nil { return false } diff --git a/sandboxd/store/peer/transport.go b/sandboxd/store/peer/transport.go index 8159d014..22704007 100644 --- a/sandboxd/store/peer/transport.go +++ b/sandboxd/store/peer/transport.go @@ -60,7 +60,7 @@ func (p *HTTPPuller) Pull(ctx context.Context, addr, id, dst string) error { } client := cmp.Or(p.Client, http.DefaultClient) - resp, err := client.Do(req) //nolint:gosec // addr comes from the mesh's own member view + resp, err := client.Do(req) if err != nil { return fmt.Errorf("pull %s from %s: %w", id, addr, err) } diff --git a/sandboxd/store/s3/s3.go b/sandboxd/store/s3/s3.go index 44fa2f25..1c5bfe80 100644 --- a/sandboxd/store/s3/s3.go +++ b/sandboxd/store/s3/s3.go @@ -189,7 +189,7 @@ func (s *Store) Metas(ctx context.Context) ([][]byte, error) { } func (s *Store) Delete(ctx context.Context, id string) error { - _ = os.RemoveAll(filepath.Join(s.staging, "cache", id)) //nolint:gosec // id pinned by idRe + _ = os.RemoveAll(filepath.Join(s.staging, "cache", id)) metaKey := s.key(id, store.MetaFile) keys, err := s.list(ctx, s.key(id, "")+"/") if err != nil { @@ -216,7 +216,7 @@ func (s *Store) readMeta(ctx context.Context, id string) ([]byte, string, error) }) if err != nil { apiErr, ok := errors.AsType[smithy.APIError](err) - if ok && (apiErr.ErrorCode() == "NoSuchKey" || apiErr.ErrorCode() == "NotFound") { //nolint:goconst // AWS API error codes, compared as literals + if ok && (apiErr.ErrorCode() == "NoSuchKey" || apiErr.ErrorCode() == "NotFound") { return nil, "", store.ErrNotFound } return nil, "", fmt.Errorf("record %s: %w", id, err) diff --git a/sandboxd/types/types.go b/sandboxd/types/types.go index 3ef1f351..8a18be6d 100644 --- a/sandboxd/types/types.go +++ b/sandboxd/types/types.go @@ -158,7 +158,7 @@ type Sandbox struct { // Tenant names the owning tenant; empty means the operator claimed it. Tenant string `json:"tenant,omitempty"` - // ClaimRef is the opaque caller reference recorded at claim time; empty for pool claims. + // ClaimRef is the opaque caller reference; empty for checkpoint branches and unprefixed forks. ClaimRef string `json:"claim_ref,omitempty"` // Volumes records the volumes successfully applied to this claim. Volumes []Volume `json:"volumes,omitempty"` diff --git a/sandboxd/utils/utils.go b/sandboxd/utils/utils.go index dcd770c9..b1c66b93 100644 --- a/sandboxd/utils/utils.go +++ b/sandboxd/utils/utils.go @@ -24,7 +24,7 @@ func WriteFileSync(path string, data []byte, perm os.FileMode) error { } if err = f.Chmod(perm); err != nil { _ = f.Close() - _ = os.Remove(f.Name()) //nolint:gosec // our own just-created temp under a caller-owned data-dir path + _ = os.Remove(f.Name()) return fmt.Errorf("chmod %s: %w", f.Name(), err) } return ReplaceFileSync(f, path, data) @@ -36,7 +36,7 @@ func ReplaceFileSync(f *os.File, path string, data []byte) (err error) { renamed := false defer func() { if !renamed { - _ = os.Remove(tmp) //nolint:gosec // the caller's own temp under its data-dir path + _ = os.Remove(tmp) } }() if _, err = f.Write(data); err != nil { @@ -50,7 +50,7 @@ func ReplaceFileSync(f *os.File, path string, data []byte) (err error) { if err = f.Close(); err != nil { return fmt.Errorf("close %s: %w", tmp, err) } - if err = os.Rename(tmp, path); err != nil { //nolint:gosec // our own temp, caller-owned data-dir path + if err = os.Rename(tmp, path); err != nil { return fmt.Errorf("rename %s: %w", path, err) } renamed = true diff --git a/sdk/go/client.go b/sdk/go/client.go index 7755b121..db105497 100644 --- a/sdk/go/client.go +++ b/sdk/go/client.go @@ -294,7 +294,7 @@ func (c *Client) roundTrip(ctx context.Context, method, addr, path string, body if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } - return c.hc.Do(req) //nolint:gosec // dialing the caller-configured node is the SDK's purpose + return c.hc.Do(req) } // WithAPIToken sets the bearer for node-scoped calls. diff --git a/sdk/go/silkd/silkdtest/fake.go b/sdk/go/silkd/silkdtest/fake.go index 458f061b..caeafa00 100644 --- a/sdk/go/silkd/silkdtest/fake.go +++ b/sdk/go/silkd/silkdtest/fake.go @@ -212,7 +212,7 @@ func (f *Fake) fsPull(conn net.Conn, path string) { if d.IsDir() { return tw.WriteHeader(&tar.Header{Name: name + "/", Typeflag: tar.TypeDir, Mode: 0o750}) } - body, err := os.ReadFile(p) //nolint:gosec // test fake, paths under the fake root + body, err := os.ReadFile(p) if err != nil { return err } diff --git a/sdk/go/volumes_test.go b/sdk/go/volumes_test.go index ac3617df..301741f1 100644 --- a/sdk/go/volumes_test.go +++ b/sdk/go/volumes_test.go @@ -275,12 +275,12 @@ func TestWithVolumesAttachOnlyRejectsMountLocally(t *testing.T) { func TestRejectsInvalidVolumeModeLocally(t *testing.T) { tests := []struct { name string - claim func(c *Client, ctx context.Context) (*Sandbox, error) + claim func(ctx context.Context, c *Client) (*Sandbox, error) }{ - {"Client.New", func(c *Client, ctx context.Context) (*Sandbox, error) { + {"Client.New", func(ctx context.Context, c *Client) (*Sandbox, error) { return c.New(ctx, "rt:24.04", WithVolumes(Volume{Name: "a", Mode: "readwrite"})) }}, - {"Template.New", func(c *Client, ctx context.Context) (*Sandbox, error) { + {"Template.New", func(ctx context.Context, c *Client) (*Sandbox, error) { tpl := &Template{Name: "task:v1", c: c, addr: c.addr, net: "none", size: "small"} return tpl.New(ctx, WithVolumes(Volume{Name: "a", Mode: "bogus"})) }}, @@ -292,7 +292,7 @@ func TestRejectsInvalidVolumeModeLocally(t *testing.T) { })) t.Cleanup(ts.Close) - _, err := tt.claim(testClient(t, ts), t.Context()) + _, err := tt.claim(t.Context(), testClient(t, ts)) if err == nil || !strings.Contains(err.Error(), "mode must be") { t.Errorf("err = %v, want local mode rejection", err) } diff --git a/silkd/Cargo.toml b/silkd/Cargo.toml index 68bc807e..d66903d8 100644 --- a/silkd/Cargo.toml +++ b/silkd/Cargo.toml @@ -15,7 +15,7 @@ notify = "8" regex = "1" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" -tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros", "io-util", "net", "process", "signal", "sync", "time"] } +tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros", "io-util", "net", "process", "sync", "time"] } [target.'cfg(target_os = "linux")'.dependencies] tokio-vsock = "0.7" diff --git a/silkd/src/exec.rs b/silkd/src/exec.rs index 77207ca5..3253eac8 100644 --- a/silkd/src/exec.rs +++ b/silkd/src/exec.rs @@ -177,7 +177,7 @@ async fn copy_stream(mut r: R, proc: &Proc, stderr: bool, fg: Option<&mpsc::S where R: AsyncRead + Unpin, { - let make: fn(Vec) -> Chunk = if stderr { Chunk::Stderr } else { Chunk::Stdout }; + let make: fn(Arc<[u8]>) -> Chunk = if stderr { Chunk::Stderr } else { Chunk::Stdout }; let mut buf = [0u8; proto::READ_CHUNK]; loop { let n = match r.read(&mut buf).await { @@ -188,7 +188,7 @@ where proc.emit_bytes(stderr, &buf[..n]); continue; }; - let chunk = make(buf[..n].to_vec()); + let chunk = make(buf[..n].into()); proc.emit(&chunk); if fg.send(chunk).await.is_err() { break; diff --git a/silkd/src/proc.rs b/silkd/src/proc.rs index 6279ef87..2616c9f3 100644 --- a/silkd/src/proc.rs +++ b/silkd/src/proc.rs @@ -21,8 +21,8 @@ static SYNTH: AtomicU64 = AtomicU64::new(1 << 30); /// A chunk of process output tagged by stream, shared with live attachers. #[derive(Clone)] pub enum Chunk { - Stdout(Vec), - Stderr(Vec), + Stdout(Arc<[u8]>), + Stderr(Arc<[u8]>), Exit(i32), } @@ -124,9 +124,9 @@ impl Proc { ring.push(stderr, data); if let Some(tx) = self.attached() { let chunk = if stderr { - Chunk::Stderr(data.to_vec()) + Chunk::Stderr(data.into()) } else { - Chunk::Stdout(data.to_vec()) + Chunk::Stdout(data.into()) }; let _ = tx.send(chunk); } @@ -237,7 +237,7 @@ impl Ring { self.tags .iter() .map(|&(stderr, len)| { - let seg = bytes[off..off + len].to_vec(); + let seg: Arc<[u8]> = bytes[off..off + len].into(); off += len; if stderr { Chunk::Stderr(seg) @@ -284,7 +284,7 @@ mod tests { assert_eq!(replay.len(), 1); assert!(proc.tx.get().is_some()); proc.emit_bytes(true, b"after"); - assert!(matches!(rx.try_recv(), Ok(Chunk::Stderr(d)) if d == b"after")); + assert!(matches!(rx.try_recv(), Ok(Chunk::Stderr(d)) if &*d == b"after")); let (_, mut second, _) = proc.attach_stream(); proc.emit(&Chunk::Exit(3)); @@ -302,10 +302,10 @@ mod tests { let (replay, _rx, exit) = proc.attach_stream(); assert_eq!(exit, Some(5)); - assert!(matches!(replay.as_slice(), [Chunk::Stdout(d)] if d == b"tail")); + assert!(matches!(replay.as_slice(), [Chunk::Stdout(d)] if &**d == b"tail")); let (logs, exit) = proc.replay(); assert_eq!(exit, Some(5)); - assert!(matches!(logs.as_slice(), [Chunk::Stdout(d)] if d == b"tail")); + assert!(matches!(logs.as_slice(), [Chunk::Stdout(d)] if &**d == b"tail")); } #[test] @@ -320,7 +320,7 @@ mod tests { proc.emit_bytes(false, b"tail"); proc.mark_exited(5); proc.emit(&Chunk::Exit(5)); - assert!(matches!(rx.try_recv(), Ok(Chunk::Stdout(d)) if d == b"tail")); + assert!(matches!(rx.try_recv(), Ok(Chunk::Stdout(d)) if &*d == b"tail")); assert!(matches!(rx.try_recv(), Ok(Chunk::Exit(5)))); } diff --git a/silkd/src/proto.rs b/silkd/src/proto.rs index cdcc0bcd..4b180b47 100644 --- a/silkd/src/proto.rs +++ b/silkd/src/proto.rs @@ -26,6 +26,8 @@ pub const READ_CHUNK: usize = 32 * 1024; /// Bulk streams chunk larger: fewer frames and flushes per byte, still under MAX_FRAME after base64. pub const BULK_CHUNK: usize = 256 * 1024; +const DATA_FRAME_HEAD: &[u8] = b"{\"v\":1,\"op\":\"data\",\"data\":\""; + /// Client → server frames; unknown JSON fields are ignored, which is forward compatibility. #[derive(Debug, Deserialize)] #[serde(tag = "op", rename_all = "snake_case")] @@ -532,6 +534,7 @@ where S: AsyncWrite + Unpin, { let mut frame = Vec::new(); + let mut decoded = Vec::new(); loop { if !read_frame_into(reader, &mut frame) .await @@ -539,6 +542,12 @@ where { return Err(FeedError::Bad("stream ended before data_end")); } + if let Some(n) = decode_data_frame(&frame, &mut decoded) { + sink.write_all(&decoded[..n]) + .await + .map_err(|e| FeedError::Io(e, "write"))?; + continue; + } match serde_json::from_slice::(&frame) { Ok(Request::Data { data }) => sink .write_all(&data) @@ -558,6 +567,12 @@ pub async fn write_feed_error(w: &mut W, err: FeedError) } } +fn decode_data_frame(frame: &[u8], out: &mut Vec) -> Option { + let b64 = frame.strip_prefix(DATA_FRAME_HEAD)?.strip_suffix(b"\"}")?; + out.resize(base64::decoded_len_estimate(b64.len()), 0); + STANDARD.decode_slice(b64, out).ok() +} + fn cap_check(len: usize) -> io::Result<()> { if len > MAX_FRAME { return Err(io::Error::new( @@ -836,10 +851,40 @@ mod tests { ); } + #[tokio::test] + async fn fast_and_serde_data_frames_feed_the_same_bytes() { + let payloads: [&[u8]; 4] = [b"", b"a", b"\xff\xff\xfe", &[0x5a; 1000]]; + let mut input = Vec::new(); + let mut want = Vec::new(); + for payload in payloads { + let b64 = STANDARD.encode(payload); + for line in [ + format!("{{\"v\":1,\"op\":\"data\",\"data\":\"{b64}\"}}\n"), + format!( + "{{\"v\":1,\"op\":\"data\",\"data\":\"{}\"}}\n", + b64.replace('/', "\\/") + ), + format!("{{\"op\":\"data\",\"data\":\"{b64}\",\"future\":1}}\n"), + ] { + input.extend_from_slice(line.as_bytes()); + want.extend_from_slice(payload); + } + } + input.extend_from_slice(b"{\"v\":1,\"op\":\"data_end\"}\n"); + let mut reader = tokio::io::BufReader::new(input.as_slice()); + let mut sink = Vec::new(); + assert!(feed_data_frames(&mut reader, &mut sink).await.is_ok()); + assert_eq!(sink, want); + } + #[tokio::test] async fn a_failed_upload_renders_a_bad_request_frame() { - let cases: [(&[u8], &str); 2] = [ + let cases: [(&[u8], &str); 3] = [ (b"{\"v\":1,\"op\":\"ps\"}\n", "expected data or data_end"), + ( + b"{\"v\":1,\"op\":\"data\",\"data\":\"!!!\"}\n", + "expected data or data_end", + ), (b"", "stream ended before data_end"), ]; for (input, message) in cases { diff --git a/silkd/src/sysutil.rs b/silkd/src/sysutil.rs index 0a2d0459..22f89413 100644 --- a/silkd/src/sysutil.rs +++ b/silkd/src/sysutil.rs @@ -3,6 +3,7 @@ use std::io::Read; use std::os::fd::{AsRawFd, OwnedFd, RawFd}; use std::os::unix::process::ExitStatusExt; +use std::path::PathBuf; use std::process::ExitStatus; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{LazyLock, Mutex}; @@ -249,13 +250,9 @@ fn snapshot_proxy(get: impl Fn(&str) -> Option) -> Vec<(&'static str, St .collect() } -fn lookup_user(user: &str) -> Result<(u32, u32, String), String> { +fn lookup_user(user: &str) -> Result<(u32, u32, PathBuf), String> { match nix::unistd::User::from_name(user) { - Ok(Some(pw)) => Ok(( - pw.uid.as_raw(), - pw.gid.as_raw(), - pw.dir.to_string_lossy().into_owned(), - )), + Ok(Some(pw)) => Ok((pw.uid.as_raw(), pw.gid.as_raw(), pw.dir)), Ok(None) => Err(format!("unknown user {user:?}")), Err(e) => Err(format!("look up user {user:?}: {e}")), } @@ -340,6 +337,6 @@ mod tests { fn root_resolves() { let (uid, _, home) = lookup_user("root").expect("root must exist"); assert_eq!(uid, 0); - assert!(!home.is_empty()); + assert!(!home.as_os_str().is_empty()); } }