From fe3416d511eb7cfd85c6ac78a98425895e870828 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 14 Sep 2026 12:33:22 +0800 Subject: [PATCH] egress: write the host's lane verdict into every NIC-bearing guest The desktop image's guest-proxy-setup decided from NIC presence, so on a guarded bridge lane the GNOME session and osworld-server got neither a route nor the relay, and silkd's marker only said "locked", which left a guest with a NIC no way to tell "the host has not decided yet" from "the host says direct". sandboxd now writes /etc/silkd-lane with the verdict, relay on a locked bridge lane and direct on a CNI lane, at the same three sites the marker used (golden build, cold provision, late reconcile lock); the golden sidecar records the word so a golden built under another verdict is rebuilt. silkd routes directly only when the file does not read relay, one 8-byte read per exec on a NIC-bearing guest, nothing on the none lane. guest-proxy-setup waits up to 30 s for the file on a NIC-bearing guest, follows relay, and treats direct or a silent host as routing. desktopsmoke gains -net egress with a fetch through the session's proxy environment in place of the checkpoint leg, which the lane refuses. The old marker shipped in no release, so it is renamed rather than kept. Closes #167 --- docs/desktop.md | 16 +++---- docs/egress.md | 12 ++--- docs/silkd.md | 10 ++--- e2e/cmd/desktopsmoke/main.go | 73 +++++++++++++++++++++---------- e2e/fakeengine_test.go | 2 +- os-image/desktop/24.04/Dockerfile | 11 ++++- sandboxd/engine/lane.go | 20 +++++++++ sandboxd/engine/nicmark.go | 12 ----- sandboxd/pool/egress.go | 23 +++++++--- sandboxd/pool/egress_test.go | 28 +++++++----- sandboxd/pool/pool.go | 2 +- sandboxd/pool/pool_test.go | 6 +-- sandboxd/pool/reconcile.go | 2 +- sandboxd/pool/refill.go | 15 ++----- silkd/src/net.rs | 35 ++++++++++++--- 15 files changed, 175 insertions(+), 92 deletions(-) create mode 100644 sandboxd/engine/lane.go delete mode 100644 sandboxd/engine/nicmark.go diff --git a/docs/desktop.md b/docs/desktop.md index 73572a4e..6dd0ee46 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -26,9 +26,9 @@ up a few seconds later — poll `GET /screenshot` until it returns 200. relay. Thunderbird's mail policy is pinned to the SOCKS5 door, so a pool that runs mail tasks sets `"socks5": true` in its policy, and so does the tenant policy when the claim carries one. `net=egress` on a - guarded bridge gives the desktop no web: the NIC is locked and the session's - proxy setup does not follow the lock marker (a CNI-backed lane still routes - directly). + guarded bridge works the same way: the session's proxy setup waits for the + host's lane verdict in `/etc/silkd-lane` and follows it, so the locked NIC + costs the desktop nothing (a CNI-backed lane is told `direct` and routes). - **Size**: `2xlarge` (8 CPU / 16G) — the t3.xlarge class the OSWorld AWS image runs on; the idle session is ~0.5 GB anonymous memory with gnome-shell around 290 MB RSS, and the headroom is for the apps. @@ -78,9 +78,9 @@ Configure it with `SANDBOXD_ADDR`, `SANDBOXD_TOKEN`, `COCOON_TEMPLATE` - `chromium` is a second launcher onto the same engine, for an agent that needs a browser the tasks do not pkill and rebind. It takes the caller's profile and debugging port. -- On a guest with no NIC, `guest-proxy.service` points the session's browsers - and `osworld-server`'s commands at silkd's loopback relay; a guest with a - NIC routes directly and the unit does nothing. The unit tests NIC presence - only, so it does not follow the locked-NIC marker silkd honors on the bridge - egress lane; run desktop pools on the none lane. +- `guest-proxy.service` points the session's browsers and `osworld-server`'s + commands at silkd's loopback relay on a guest with no NIC, and on a guest + whose NIC the host reports as `relay` in `/etc/silkd-lane`; it waits up to + 30 s for that verdict before the session starts. A NIC the host calls + `direct`, or a host that writes nothing, leaves the unit idle. - x86_64 only. diff --git a/docs/egress.md b/docs/egress.md index 44e9c201..881c633d 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -75,11 +75,13 @@ remove keeps an existing lock in place; the next restart retries the remove). A lock that never applied plus a failed remove leaves the VM unguarded until a later remove succeeds. -Because the lock is invisible from inside the guest, sandboxd also tells silkd -about it: a marker written into the guest before the golden snapshot (or on a -cold boot) makes silkd hand the proxy variables to every exec on this lane -exactly as on the none lane, while git keeps running as on any lane with a NIC. -A golden built before the marker existed is rebuilt, not adopted. +Because the lock is invisible from inside the guest, sandboxd writes its +verdict into `/etc/silkd-lane` before the golden snapshot (or on a cold boot): +`relay` on a locked bridge lane, `direct` on a CNI lane. Where silkd reads +`relay` it hands the proxy variables to every exec exactly as on the none lane, +and the image's units wait for the same file instead of guessing from NIC +presence; git keeps running as on any lane with a NIC. A golden built under +another verdict is rebuilt, not adopted. Egress-lane sandboxes do not hibernate, archive, fork, checkpoint, or promote: cocoon resumes a guest before its fresh tap can be re-locked, so any resume from diff --git a/docs/silkd.md b/docs/silkd.md index 9750c7d7..9e3ae81d 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -68,11 +68,11 @@ the host's [guarded-egress](egress.md) proxy over vsock (`CID2:2049`), and `127.0.0.1:1080` to its SOCKS5 door (`CID2:2050`) for clients whose protocol has no HTTP-proxy form; when the host wired no policy the per-connection dial is refused, so the ports are inert. -Where nothing routes directly — the no-network lane, or a NIC the host has -nft-locked and marked as such via `/etc/silkd-nic-locked` — silkd forwards the -image-baked proxy variables (`http_proxy` and friends) into every exec, so -unconfigured clients use the relay without being told; a lane with its own -routed network never gets them. +Where nothing routes directly — the no-network lane, or a NIC whose host +verdict in `/etc/silkd-lane` reads `relay` because it is nft-locked — silkd +forwards the image-baked proxy variables (`http_proxy` and friends) into every +exec, so unconfigured clients use the relay without being told; a lane with its +own routed network never gets them. ## Limits diff --git a/e2e/cmd/desktopsmoke/main.go b/e2e/cmd/desktopsmoke/main.go index b3c07173..a021376b 100644 --- a/e2e/cmd/desktopsmoke/main.go +++ b/e2e/cmd/desktopsmoke/main.go @@ -1,6 +1,7 @@ -// desktopsmoke is the desktop-flavor acceptance: claim (none/2xlarge) → the -// OSWorld guest server over the relay (screenshot, AT-SPI tree, a PyAutoGUI -// action echoed by the cursor) → checkpoint/branch of the warmed desktop. +// desktopsmoke is the desktop-flavor acceptance: claim (2xlarge) → the OSWorld +// guest server over the relay (screenshot, AT-SPI tree, a PyAutoGUI action +// echoed by the cursor) → on the none lane a checkpoint/branch of the warmed +// desktop, on the egress lane a fetch through the session's proxy environment. package main import ( @@ -31,21 +32,23 @@ func main() { addr := flag.String("addr", "127.0.0.1:7777", "sandboxd address") token := flag.String("token", "", "node api token") template := flag.String("template", "ghcr.io/cocoonstack/sandbox/desktop:24.04", "desktop template ref") + lane := flag.String("net", "none", "lane: none, or egress for a guarded bridge pool with a policy") + probe := flag.String("probe", "http://postman-echo.com/get", "URL the egress policy allows, fetched from the session's environment on the egress lane") flag.Parse() - if err := run(*addr, *token, *template); err != nil { + if err := run(*addr, *token, *template, sandbox.NetShape(*lane), *probe); err != nil { fmt.Fprintln(os.Stderr, "desktopsmoke:", err) os.Exit(1) } fmt.Println("DESKTOPSMOKE PASS") } -func run(addr, token, template string) error { +func run(addr, token, template string, lane sandbox.NetShape, probe string) error { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() start := time.Now() _, sb, err := harness.Claim(ctx, addr, token, template, - sandbox.WithNetwork(sandbox.NetNone), sandbox.WithSize(sandbox.XXLarge), + sandbox.WithNetwork(lane), sandbox.WithSize(sandbox.XXLarge), sandbox.WithTimeout(30*time.Minute)) if err != nil { return err @@ -62,6 +65,9 @@ func run(addr, token, template string) error { if err = clickAndReadCursor(ctx, sb); err != nil { return err } + if lane == sandbox.NetEgress { + return proxyReachesSession(ctx, sb, probe) + } ckpt, err := sb.Checkpoint(ctx, "desktop-warmed") if err != nil { @@ -119,23 +125,10 @@ func accessibilityTree(ctx context.Context, sb *sandbox.Sandbox) error { // clickAndReadCursor proves /execute reaches the X display, not just the shell. func clickAndReadCursor(ctx context.Context, sb *sandbox.Sandbox) error { action := fmt.Sprintf("import pyautogui; pyautogui.moveTo(%d, %d); pyautogui.click()", clickX, clickY) - req, _ := json.Marshal(map[string]any{"command": []string{"python", "-c", action}, "shell": false, "timeout": 15}) - body, err := harness.HTTPOverPort(ctx, sb, serverPort, "POST", "/execute", req) - if err != nil { - return fmt.Errorf("execute: %w", err) - } - var exec struct { - Status string `json:"status"` - ReturnCode int `json:"returncode"` - Error string `json:"error"` - } - if err = json.Unmarshal(body, &exec); err != nil { - return fmt.Errorf("execute: %w", err) - } - if exec.Status != "success" || exec.ReturnCode != 0 { - return fmt.Errorf("execute: %s rc=%d: %s", exec.Status, exec.ReturnCode, strings.TrimSpace(exec.Error)) + if _, err := execute(ctx, sb, []string{"python", "-c", action}, 15); err != nil { + return err } - body, err = harness.HTTPOverPort(ctx, sb, serverPort, "GET", "/cursor_position", nil) + body, err := harness.HTTPOverPort(ctx, sb, serverPort, "GET", "/cursor_position", nil) if err != nil { return fmt.Errorf("cursor_position: %w", err) } @@ -149,3 +142,39 @@ func clickAndReadCursor(ctx context.Context, sb *sandbox.Sandbox) error { fmt.Printf(" server: /execute pyautogui click → /cursor_position %v\n", pos) return nil } + +// proxyReachesSession proves the session's environment carries the relay and the relay reaches an allowed origin. +func proxyReachesSession(ctx context.Context, sb *sandbox.Sandbox, probe string) error { + script := `test -n "$http_proxy" && curl -sS -m 20 -o /dev/null -w '%{http_code}' ` + probe + out, err := execute(ctx, sb, []string{"sh", "-c", script}, 30) + if err != nil { + return fmt.Errorf("proxy leg: %w", err) + } + if strings.TrimSpace(out) != "200" { + return fmt.Errorf("proxy leg: fetch through the session's proxy returned %q, want 200", strings.TrimSpace(out)) + } + fmt.Println(" proxy: the session's environment carries the relay and an allowed origin answers 200") + return nil +} + +// execute runs one command through osworld-server and returns its output; a non-zero exit is an error. +func execute(ctx context.Context, sb *sandbox.Sandbox, command []string, timeout int) (string, error) { + req, _ := json.Marshal(map[string]any{"command": command, "shell": false, "timeout": timeout}) + body, err := harness.HTTPOverPort(ctx, sb, serverPort, "POST", "/execute", req) + if err != nil { + return "", fmt.Errorf("execute: %w", err) + } + var reply struct { + Status string `json:"status"` + Output string `json:"output"` + ReturnCode int `json:"returncode"` + Error string `json:"error"` + } + if err = json.Unmarshal(body, &reply); err != nil { + return "", fmt.Errorf("execute: %w", err) + } + if reply.Status != "success" || reply.ReturnCode != 0 { + return "", fmt.Errorf("execute: %s rc=%d: %s", reply.Status, reply.ReturnCode, strings.TrimSpace(reply.Error)) + } + return reply.Output, nil +} diff --git a/e2e/fakeengine_test.go b/e2e/fakeengine_test.go index 8475478f..a99e99eb 100644 --- a/e2e/fakeengine_test.go +++ b/e2e/fakeengine_test.go @@ -110,7 +110,7 @@ func (f *fakeEngine) DialGuestPort(context.Context, string, uint16) (net.Conn, e func (f *fakeEngine) InstallCACert(context.Context, string, []byte) error { return nil } -func (f *fakeEngine) MarkNICLocked(context.Context, string) error { return nil } +func (f *fakeEngine) MarkLane(context.Context, string, engine.Lane) error { return nil } func (f *fakeEngine) Warmup(context.Context, string, []string) error { return nil } diff --git a/os-image/desktop/24.04/Dockerfile b/os-image/desktop/24.04/Dockerfile index 00223cfa..db8f3960 100644 --- a/os-image/desktop/24.04/Dockerfile +++ b/os-image/desktop/24.04/Dockerfile @@ -151,11 +151,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ 'host='"'"'127.0.0.1'"'"'' \ 'port=1080' \ > /usr/local/share/guest-proxy.dconf \ + # a NIC alone says nothing about routing: the host writes /etc/silkd-lane once silkd answers, so a NIC-bearing guest waits for the verdict && printf '%s\n' \ '#!/bin/sh' \ 'set -e' \ ': > /run/guest-proxy.env' \ - 'for n in /sys/class/net/*; do case ${n##*/} in lo|sit0) ;; *) exit 0;; esac; done' \ + 'nic=0' \ + 'for n in /sys/class/net/*; do case ${n##*/} in lo|sit0) ;; *) nic=1;; esac; done' \ + 'if [ $nic = 1 ]; then' \ + ' i=0' \ + ' while [ ! -e /etc/silkd-lane ] && [ $i -lt 300 ]; do sleep 0.1; i=$((i+1)); done' \ + ' if [ ! -e /etc/silkd-lane ]; then echo "guest-proxy: no lane verdict from the host; routing directly" >&2; exit 0; fi' \ + ' read -r lane < /etc/silkd-lane' \ + ' [ "$lane" = relay ] || exit 0' \ + 'fi' \ 'cat /usr/local/share/guest-proxy.env > /run/guest-proxy.env' \ 'cat /usr/local/share/guest-proxy.dconf > /etc/dconf/db/local.d/01-proxy' \ 'dconf update' \ diff --git a/sandboxd/engine/lane.go b/sandboxd/engine/lane.go new file mode 100644 index 00000000..d395c322 --- /dev/null +++ b/sandboxd/engine/lane.go @@ -0,0 +1,20 @@ +package engine + +import "context" + +const ( + laneFile = "/etc/silkd-lane" + + LaneRelay Lane = "relay" + LaneDirect Lane = "direct" +) + +// Lane is the host's verdict on how a guest's traffic leaves: through the relay when its NIC is nft-locked, directly when the NIC routes. +type Lane string + +// MarkLane writes the verdict into the guest, where silkd and the image's units read it; the nft lock is invisible from inside. +func (e *Engine) MarkLane(ctx context.Context, vsockSocket string, lane Lane) error { + ctx, cancel := context.WithTimeout(ctx, cmdTimeout) + defer cancel() + return e.silkdWriteFile(ctx, vsockSocket, laneFile, 0o644, []byte(lane+"\n")) +} diff --git a/sandboxd/engine/nicmark.go b/sandboxd/engine/nicmark.go deleted file mode 100644 index 64d7002a..00000000 --- a/sandboxd/engine/nicmark.go +++ /dev/null @@ -1,12 +0,0 @@ -package engine - -import "context" - -const nicLockedMark = "/etc/silkd-nic-locked" - -// MarkNICLocked tells silkd the guest's NIC is nft-locked, so it steers execs at the relay. -func (e *Engine) MarkNICLocked(ctx context.Context, vsockSocket string) error { - ctx, cancel := context.WithTimeout(ctx, cmdTimeout) - defer cancel() - return e.silkdWriteFile(ctx, vsockSocket, nicLockedMark, 0o644, nil) -} diff --git a/sandboxd/pool/egress.go b/sandboxd/pool/egress.go index f2909fe4..482ee59a 100644 --- a/sandboxd/pool/egress.go +++ b/sandboxd/pool/egress.go @@ -94,17 +94,30 @@ func (m *Manager) lockEgressNIC(ctx context.Context, sb *types.Sandbox) error { return nil } -// markLockedNIC tells the guest its NIC is nft-locked; silkd cannot see the hook from inside. -func (m *Manager) markLockedNIC(ctx context.Context, key types.PoolKey, sock string) error { - if !m.locksNIC(key) { +// markLane tells the guest how its traffic leaves; the nft lock is invisible from inside. +func (m *Manager) markLane(ctx context.Context, key types.PoolKey, sock string) error { + lane := m.laneOf(key) + if lane == "" { return nil } - if err := m.eng.MarkNICLocked(ctx, sock); err != nil { - return fmt.Errorf("mark nic locked: %w", err) + if err := m.eng.MarkLane(ctx, sock, lane); err != nil { + return fmt.Errorf("mark lane: %w", err) } return nil } +// laneOf is the verdict a guest receives; the none lane has no NIC to route through and gets none. +func (m *Manager) laneOf(key types.PoolKey) engine.Lane { + switch { + case key.Net != types.NetEgress: + return "" + case m.locksNIC(key): + return engine.LaneRelay + default: + return engine.LaneDirect + } +} + func (m *Manager) locksNIC(key types.PoolKey) bool { return m.lockEgress && key.Net == types.NetEgress } diff --git a/sandboxd/pool/egress_test.go b/sandboxd/pool/egress_test.go index f49ff46f..c2f76ad5 100644 --- a/sandboxd/pool/egress_test.go +++ b/sandboxd/pool/egress_test.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -103,22 +104,25 @@ func TestArmEgressBindsSocksOnlyWhenOptedIn(t *testing.T) { } } -func TestLockedLaneMarksGuestOnColdBoots(t *testing.T) { +func TestLaneVerdictReachesGuestOnColdBoots(t *testing.T) { tests := []struct { - name string - key types.PoolKey - cold bool - want int + name string + key types.PoolKey + locked bool + cold bool + want []string }{ - {"golden build on the egress lane", egKey, false, 1}, - {"golden build on the none lane", testKey, false, 0}, - {"cold provision on the egress lane", egKey, true, 1}, - {"cold provision on the none lane", testKey, true, 0}, + {"golden build on a locked egress lane", egKey, true, false, []string{"relay"}}, + {"golden build on an unlocked egress lane", egKey, false, false, []string{"direct"}}, + {"golden build on the none lane", testKey, true, false, nil}, + {"cold provision on a locked egress lane", egKey, true, true, []string{"relay"}}, + {"cold provision on the none lane", testKey, true, true, nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { eng := newFakeEngine() m := egressManager(t, eng, config.PoolSpec{PoolKey: tt.key, Warm: 1, Egress: egPolicy}) + m.lockEgress = tt.locked if tt.cold { sb, err := m.provision(t.Context(), tt.key, "") if err != nil { @@ -128,8 +132,8 @@ func TestLockedLaneMarksGuestOnColdBoots(t *testing.T) { } else if err := m.buildGoldenSteps(t.Context(), tt.key, "sbx-gb", "snap", filepath.Join(m.goldensDir(), tt.key.Hash())); err != nil { t.Fatalf("buildGoldenSteps: %v", err) } - if got := len(eng.nicMarks); got != tt.want { - t.Errorf("MarkNICLocked calls = %d, want %d", got, tt.want) + if !slices.Equal(eng.laneMarks, tt.want) { + t.Errorf("MarkLane verdicts = %q, want %q", eng.laneMarks, tt.want) } }) } @@ -146,7 +150,7 @@ func TestGoldenNICSidecarGatesAdoption(t *testing.T) { if p.goldenDir != "" { t.Error("adopted an egress-lane golden that never marked its guest; want rebuild") } - if err := writeGoldenSidecar(g+nicSidecarSuffix, nicStamp(true)); err != nil { + if err := writeGoldenSidecar(g+nicSidecarSuffix, string(engine.LaneRelay)); err != nil { t.Fatalf("write sidecar: %v", err) } m.adoptGolden(p) diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index 57c9af00..8bbc947c 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -117,7 +117,7 @@ type Engine interface { Probe(ctx context.Context, vsockSocket string, timeout time.Duration) error DialGuestPort(ctx context.Context, vsockSocket string, port uint16) (net.Conn, error) InstallCACert(ctx context.Context, vsockSocket string, certPEM []byte) error - MarkNICLocked(ctx context.Context, vsockSocket string) error + MarkLane(ctx context.Context, vsockSocket string, lane engine.Lane) error Warmup(ctx context.Context, vsockSocket string, argv []string) error DiskAttach(ctx context.Context, vmName string, spec engine.VolumeSpec) error MountVolume(ctx context.Context, vsockSocket, name, mount string, rw bool) error diff --git a/sandboxd/pool/pool_test.go b/sandboxd/pool/pool_test.go index 64d3e014..68e4eb06 100644 --- a/sandboxd/pool/pool_test.go +++ b/sandboxd/pool/pool_test.go @@ -813,7 +813,7 @@ type fakeEngine struct { removeSeenOps map[string][]string removeSeenDirty map[string][]string dirtyPaths []string - nicMarks []string + laneMarks []string syncs []string hibernates, restores, snapRemoves []string @@ -1060,10 +1060,10 @@ func (f *fakeEngine) InstallCACert(_ context.Context, vsockSocket string, _ []by return f.installCAErr } -func (f *fakeEngine) MarkNICLocked(_ context.Context, vsockSocket string) error { +func (f *fakeEngine) MarkLane(_ context.Context, _ string, lane engine.Lane) error { f.mu.Lock() defer f.mu.Unlock() - f.nicMarks = append(f.nicMarks, vsockSocket) + f.laneMarks = append(f.laneMarks, string(lane)) return nil } diff --git a/sandboxd/pool/reconcile.go b/sandboxd/pool/reconcile.go index dfe3e669..e338d691 100644 --- a/sandboxd/pool/reconcile.go +++ b/sandboxd/pool/reconcile.go @@ -177,7 +177,7 @@ func (m *Manager) resyncEgress(ctx context.Context, live map[string]types.VMReco err = netfilter.Lock(tap) } if err == nil && !locked[tap] { - err = m.markLockedNIC(ctx, sb.Key, sb.VsockSocket) + err = m.markLane(ctx, sb.Key, sb.VsockSocket) } if err != nil { logger.Errorf(ctx, err, "ensure egress lock %s; quarantining", sb.ID) diff --git a/sandboxd/pool/refill.go b/sandboxd/pool/refill.go index dd9f18f8..523a5103 100644 --- a/sandboxd/pool/refill.go +++ b/sandboxd/pool/refill.go @@ -191,7 +191,7 @@ func (m *Manager) buildGoldenSteps(ctx context.Context, key types.PoolKey, name, if err != nil { return err } - if err = m.markLockedNIC(ctx, key, sock); err != nil { + if err = m.markLane(ctx, key, sock); err != nil { return err } caBaked := m.poolIntercepts(key) @@ -213,7 +213,7 @@ func (m *Manager) buildGoldenSteps(ctx context.Context, key types.PoolKey, name, if err := m.writeGoldenCASidecar(final, caBaked); err != nil { return err } - if err := writeGoldenSidecar(final+nicSidecarSuffix, nicStamp(m.locksNIC(key))); err != nil { + if err := writeGoldenSidecar(final+nicSidecarSuffix, string(m.laneOf(key))); err != nil { return err } return writeGoldenSidecar(final+warmupSidecarSuffix, warmupStamp(warmup)) @@ -251,7 +251,7 @@ func (m *Manager) writeGoldenCASidecar(final string, caBaked bool) error { 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, nicStamp(m.locksNIC(p.key))) && + goldenSidecarMatches(g+nicSidecarSuffix, string(m.laneOf(p.key))) && goldenSidecarMatches(g+warmupSidecarSuffix, warmupStamp(m.poolWarmups[p.key])) { p.goldenDir = g } @@ -323,7 +323,7 @@ func (m *Manager) provisionCold(ctx context.Context, key types.PoolKey) (*types. if err != nil { return nil, err } - if err := m.markLockedNIC(ctx, key, sb.VsockSocket); err != nil { + if err := m.markLane(ctx, key, sb.VsockSocket); err != nil { m.destroy(ctx, sb.VMName) return nil, err } @@ -503,10 +503,3 @@ func goldenSidecarMatches(path, stamp string) bool { func warmupStamp(argv []string) string { return strings.Join(argv, "\x00") } - -func nicStamp(locked bool) string { - if locked { - return "locked" - } - return "" -} diff --git a/silkd/src/net.rs b/silkd/src/net.rs index f2110f43..a813e47e 100644 --- a/silkd/src/net.rs +++ b/silkd/src/net.rs @@ -1,11 +1,12 @@ //! Guest network-lane detection: the egress lane has a device-backed NIC, the none lane only virtual interfaces. -use std::path::Path; +use std::fs::File; +use std::io::Read; use std::sync::LazyLock; use std::sync::atomic::{AtomicI8, Ordering}; -/// Marker the host writes when it has nft-locked the guest's NIC; on the root filesystem so a guest reboot keeps it. -const NIC_LOCKED_MARK: &str = "/etc/silkd-nic-locked"; +/// File the host writes with its lane verdict, `relay` or `direct`; on the root filesystem so a guest reboot keeps it. +const LANE_FILE: &str = "/etc/silkd-lane"; static LANE_OVERRIDE: AtomicI8 = AtomicI8::new(-1); @@ -31,9 +32,9 @@ pub fn has_egress() -> bool { *DEVICE_BACKED } -/// Reports whether execs route directly: a NIC the host has not locked. +/// Reports whether execs route directly: a NIC the host did not mark as relayed. pub fn routes_directly() -> bool { - has_egress() && !Path::new(NIC_LOCKED_MARK).exists() + has_egress() && !lane_is_relay(LANE_FILE) } /// Lane override for tests: set_var would race every concurrent getenv. @@ -51,3 +52,27 @@ fn silkd_net() -> i8 { }); *SILKD_NET } + +fn lane_is_relay(path: &str) -> bool { + let mut buf = [0u8; 8]; + File::open(path) + .and_then(|mut f| f.read(&mut buf)) + .is_ok_and(|n| buf[..n].trim_ascii() == b"relay") +} + +#[cfg(test)] +mod tests { + use super::lane_is_relay; + + #[test] + fn lane_file_decides_relay() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lane"); + let text = path.to_str().unwrap(); + assert!(!lane_is_relay(text)); + std::fs::write(&path, "direct\n").unwrap(); + assert!(!lane_is_relay(text)); + std::fs::write(&path, "relay\n").unwrap(); + assert!(lane_is_relay(text)); + } +}