Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a3a3570
Pin Chromium's proxy with managed policy on filtered sessions
yummybomb Oct 8, 2026
767dc7a
Keep the egress pin's temp file out of the policy dir, sort it last, …
yummybomb Oct 8, 2026
c82416c
Pin the base flags' egress proxy and clear per-URL WebRTC rules
yummybomb Oct 8, 2026
1fb75ae
Wait for Chromium's policy watch before unpinning in the e2e test
yummybomb Oct 8, 2026
9ebe374
Wait for the extension's APIs before reading its settings in the e2e …
yummybomb Oct 8, 2026
66a55a5
Pin only private bypass entries and keep the policy directory root-owned
yummybomb Oct 8, 2026
4164bd9
Take the pinned bypass list from the control plane and close host-map…
yummybomb Oct 8, 2026
6efa5ec
Check the egress pin against the policy instead of the recorded policy
yummybomb Oct 9, 2026
46bfbb7
Test the egress pin's default bypass from the base flags
yummybomb Oct 9, 2026
a6eb7c9
Test where the egress pin is written and staged
yummybomb Oct 9, 2026
20e1536
Say the egress pin comes from the base flags and the policy
yummybomb Oct 9, 2026
c241c16
Reject every whitespace character in private_hosts
yummybomb Oct 9, 2026
5153187
Keep chrome policy from merging per-URL WebRTC rules into the egress pin
yummybomb Oct 9, 2026
cd60e14
Test that the egress pin clears group and other write bits separately
yummybomb Oct 9, 2026
c0c31f2
Test that GET /network/egress-policy reports private hosts
yummybomb Oct 9, 2026
829f3bc
Drop unreachable egress policy branches
yummybomb Oct 9, 2026
fe7d63f
Drop the egress pin failure test the changed-hosts test covers
yummybomb Oct 9, 2026
8bb7606
Keep a nil private hosts slice recorded as an empty list
yummybomb Oct 9, 2026
ce5385a
Trim egress pin comments and say why Chromium is not starting
yummybomb Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions server/cmd/api/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ type ApiService struct {
// session's egress allowlist. Shared with the CDP proxy, which reads it to
// decide whether to refuse a context that would set its own proxy.
egressPolicy *egresspolicy.State
// egressPin holds Chromium on the egress proxy while filtered. The
// launcher writes it; the egress policy handler checks and removes it.
egressPin egresspolicy.Pin
// chromiumBaseFlags are the flags the launcher derives the pin from.
chromiumBaseFlags []string

lifecycleCtx context.Context
lifecycleCancel context.CancelFunc
Expand Down Expand Up @@ -199,6 +204,8 @@ func New(
otlpExport: otlpExport,
s2Storage: s2Storage,
egressPolicy: egressPolicy,
egressPin: egresspolicy.DefaultPin,
chromiumBaseFlags: egresspolicy.BaseFlags(os.Getenv("CHROMIUM_FLAGS")),
webmcp: webmcpclient.NewManager(upstreamMgr),
browserRepl: newBrowserReplManager(),
lifecycleCtx: ctx,
Expand Down
13 changes: 12 additions & 1 deletion server/cmd/api/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,18 @@ func testEgressPolicy(t *testing.T) *egresspolicy.State {
func newSvc(t *testing.T, mgr recorder.RecordManager) (*ApiService, error) {
t.Helper()
ts, es := newTelemetrySession(t)
return New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t))
svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t))
if err != nil {
return nil, err
}
policies := t.TempDir()
managed := filepath.Join(policies, "managed")
if err := os.Mkdir(managed, 0o755); err != nil {
return nil, err
}
svc.egressPin = egresspolicy.Pin{Path: filepath.Join(managed, "zz-kernel-egress.json"), StageDir: policies}
svc.chromiumBaseFlags = egresspolicy.BaseFlags("--proxy-server=http://192.0.2.1:3129")
return svc, nil
}

func TestApiService_PatchChromiumFlags(t *testing.T) {
Expand Down
71 changes: 63 additions & 8 deletions server/cmd/api/api/egress_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,87 @@ package api

import (
"context"
"errors"
"strings"
"unicode"

"github.com/kernel/kernel-images/server/lib/egresspolicy"
"github.com/kernel/kernel-images/server/lib/logger"
oapi "github.com/kernel/kernel-images/server/lib/oapi"
)

// GetNetworkEgressPolicy handles GET /network/egress-policy.
func (s *ApiService) GetNetworkEgressPolicy(_ context.Context, _ oapi.GetNetworkEgressPolicyRequestObject) (oapi.GetNetworkEgressPolicyResponseObject, error) {
return oapi.GetNetworkEgressPolicy200JSONResponse{Filtered: s.egressPolicy.Filtered()}, nil
policy := s.egressPolicy.Policy()
return oapi.GetNetworkEgressPolicy200JSONResponse{Filtered: policy.Filtered, PrivateHosts: policy.PrivateHosts}, nil
}

// PutNetworkEgressPolicy handles PUT /network/egress-policy.
// Records whether the session's egress is restricted to an allowlist, which is
// what the CDP proxy consults before forwarding a command that would put a
// browser context on its own proxy.
// Records whether the session's egress is restricted to an allowlist, which the
// CDP proxy consults before forwarding a command that would put a browser
// context on its own proxy, and pins Chromium's proxy with managed policy.
func (s *ApiService) PutNetworkEgressPolicy(ctx context.Context, req oapi.PutNetworkEgressPolicyRequestObject) (oapi.PutNetworkEgressPolicyResponseObject, error) {
log := logger.FromContext(ctx)
if req.Body == nil {
return oapi.PutNetworkEgressPolicy400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "missing body"}}, nil
}
if req.Body.PrivateHosts != nil {
for _, host := range *req.Body.PrivateHosts {
// Chromium splits its bypass list on these.
if host == "" || strings.ContainsAny(host, ";,") || strings.ContainsFunc(host, unicode.IsSpace) {
return oapi.PutNetworkEgressPolicy400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "private_hosts entries must be non-empty and contain no ';', ',' or whitespace"}}, nil
}
}
}
policy := egresspolicy.Policy{Filtered: req.Body.Filtered, PrivateHosts: req.Body.PrivateHosts}

// Applying the pin can restart Chromium.
s.chromiumConfigMu.Lock()
defer s.chromiumConfigMu.Unlock()

// A policy that was not persisted would be lost if this process restarted,
// so the caller is told it did not apply rather than being left to assume
// a refusal is in place.
if err := s.egressPolicy.SetFiltered(req.Body.Filtered); err != nil {
log.Error("failed to apply egress policy", "err", err, "filtered", req.Body.Filtered)
if err := s.egressPolicy.Set(policy); err != nil {
log.Error("failed to apply egress policy", "err", err, "filtered", policy.Filtered)
return oapi.PutNetworkEgressPolicy500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to apply egress policy"}}, nil
}
log.Info("egress policy applied", "filtered", req.Body.Filtered)
return oapi.PutNetworkEgressPolicy200JSONResponse{Filtered: req.Body.Filtered}, nil
if err := s.applyEgressPin(ctx, policy); err != nil {
log.Error("failed to apply egress proxy pin", "err", err, "filtered", policy.Filtered)
return oapi.PutNetworkEgressPolicy500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to apply egress policy"}}, nil
}
log.Info("egress policy applied", "filtered", policy.Filtered)
return oapi.PutNetworkEgressPolicy200JSONResponse{Filtered: policy.Filtered, PrivateHosts: policy.PrivateHosts}, nil
}

// applyEgressPin brings Chromium's proxy pin in line with the policy. The
// caller must hold chromiumConfigMu.
//
// A missing or stale pin is written by restarting Chromium, so it is in force
// before the request returns; Chromium's own policy reload would be late.
// Removal needs no restart. A Chromium restarting on its own as the policy
// flips to unfiltered can write the pin back, which fails closed until its
// next start.
func (s *ApiService) applyEgressPin(ctx context.Context, policy egresspolicy.Policy) error {
if !policy.Filtered {
return s.egressPin.Remove()
}
pinned, err := s.egressPin.Matches(policy, s.chromiumBaseFlags)
if err != nil {
return err
}
if pinned {
return nil
}
if err := s.restartChromiumAndWait(ctx, "egress policy"); err != nil {
return err
}
pinned, err = s.egressPin.Matches(policy, s.chromiumBaseFlags)
if err != nil {
return err
}
if !pinned {
return errors.New("chromium restarted without the egress proxy pin")
}
return nil
}
76 changes: 72 additions & 4 deletions server/cmd/api/api/egress_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"testing"

"github.com/kernel/kernel-images/server/lib/egresspolicy"
oapi "github.com/kernel/kernel-images/server/lib/oapi"
"github.com/stretchr/testify/require"
)
Expand All @@ -19,20 +20,87 @@ func TestGetNetworkEgressPolicyDefaultsToUnfiltered(t *testing.T) {
require.Equal(t, oapi.GetNetworkEgressPolicy200JSONResponse{Filtered: false}, resp)
}

// The control plane applies the policy at setup and again whenever an
// allowlist is added to or removed from a running session, so the endpoint has
// to carry a session both ways and tolerate being told the same thing twice.
// The policy is reported as it was applied, private hosts included.
func TestGetNetworkEgressPolicyReportsPrivateHosts(t *testing.T) {
svc, err := newSvc(t, newMockRecordManager())
require.NoError(t, err)
hosts := []string{"10.1.0.0/16"}
require.NoError(t, svc.egressPolicy.Set(egresspolicy.Policy{Filtered: true, PrivateHosts: &hosts}))

resp, err := svc.GetNetworkEgressPolicy(context.Background(), oapi.GetNetworkEgressPolicyRequestObject{})
require.NoError(t, err)
require.Equal(t, oapi.GetNetworkEgressPolicy200JSONResponse{Filtered: true, PrivateHosts: &hosts}, resp)
}

// The control plane applies the policy at setup and again whenever a running
// session's allowlist changes, so the endpoint has to carry a session both ways
// and tolerate being told the same thing twice. The session starts filtered
// with its pin in place, as the launcher leaves it, so nothing here needs a
// restart.
func TestPutNetworkEgressPolicy(t *testing.T) {
svc, err := newSvc(t, newMockRecordManager())
require.NoError(t, err)
filtered := egresspolicy.Policy{Filtered: true}
require.NoError(t, svc.egressPolicy.Set(filtered))
require.NoError(t, svc.egressPin.Sync(filtered, svc.chromiumBaseFlags))

for _, want := range []bool{true, true, false} {
for _, want := range []bool{true, true, false, false} {
resp, err := svc.PutNetworkEgressPolicy(context.Background(), oapi.PutNetworkEgressPolicyRequestObject{
Body: &oapi.NetworkEgressPolicy{Filtered: want},
})
require.NoError(t, err)
require.Equal(t, oapi.PutNetworkEgressPolicy200JSONResponse{Filtered: want}, resp)
require.Equal(t, want, svc.egressPolicy.Filtered())
pinned, err := svc.egressPin.Matches(egresspolicy.Policy{Filtered: true}, svc.chromiumBaseFlags)
require.NoError(t, err)
require.Equal(t, want, pinned)
}
}

// The pin's bypass list is the policy's private hosts, so a pin written for
// other private hosts is rewritten with a restart, and one written for these
// is left alone. The new hosts are recorded even when the restart fails, so the
// next Chromium start pins them, and a retry before that start still finds the
// old pin and restarts again rather than reporting it applied.
func TestPutNetworkEgressPolicyRestartsForChangedPrivateHosts(t *testing.T) {
svc, err := newSvc(t, newMockRecordManager())
require.NoError(t, err)
before := egresspolicy.Policy{Filtered: true, PrivateHosts: &[]string{"10.1.0.0/16"}}
require.NoError(t, svc.egressPolicy.Set(before))
require.NoError(t, svc.egressPin.Sync(before, svc.chromiumBaseFlags))
// Leaves supervisorctl off PATH, so a restart fails.
t.Setenv("PATH", t.TempDir())

after := []string{"preview.internal:8443"}
put := oapi.PutNetworkEgressPolicyRequestObject{Body: &oapi.NetworkEgressPolicy{Filtered: true, PrivateHosts: &after}}
for range 2 {
resp, err := svc.PutNetworkEgressPolicy(context.Background(), put)
require.NoError(t, err)
require.IsType(t, oapi.PutNetworkEgressPolicy500JSONResponse{}, resp, "a pin for other private hosts did not restart Chromium")
require.Equal(t, &after, svc.egressPolicy.Policy().PrivateHosts)
}

// Stands in for the launcher, which writes the pin on the next start.
require.NoError(t, svc.egressPin.Sync(svc.egressPolicy.Policy(), svc.chromiumBaseFlags))
resp, err := svc.PutNetworkEgressPolicy(context.Background(), put)
require.NoError(t, err)
require.Equal(t, oapi.PutNetworkEgressPolicy200JSONResponse{Filtered: true, PrivateHosts: &after}, resp)
}

// Chromium splits its bypass list on ";" and ",", so an entry carrying either
// would add bypass rules of its own.
func TestPutNetworkEgressPolicyRejectsMalformedPrivateHosts(t *testing.T) {
svc, err := newSvc(t, newMockRecordManager())
require.NoError(t, err)

for _, host := range []string{"", "a.internal;*", "a.internal,*", "a.internal *", "a.internal\v*", "a.internal\u00a0*"} {
hosts := []string{"10.1.0.0/16", host}
resp, err := svc.PutNetworkEgressPolicy(context.Background(), oapi.PutNetworkEgressPolicyRequestObject{
Body: &oapi.NetworkEgressPolicy{Filtered: true, PrivateHosts: &hosts},
})
require.NoError(t, err)
require.IsType(t, oapi.PutNetworkEgressPolicy400JSONResponse{}, resp, "accepted private host %q", host)
require.False(t, svc.egressPolicy.Filtered())
}
}

Expand Down
32 changes: 20 additions & 12 deletions server/cmd/chromium-launcher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"flag"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
Expand All @@ -12,6 +13,7 @@ import (
"time"

"github.com/kernel/kernel-images/server/lib/chromiumflags"
"github.com/kernel/kernel-images/server/lib/egresspolicy"
"github.com/kernel/kernel-images/server/lib/x11"
)

Expand All @@ -25,8 +27,6 @@ const (
// pulseSink is the null sink chromium plays into; the recorder captures
// its .monitor source.
pulseSink = "KernelOutput"

defaultPrivateNetworkBypassFlag = "--proxy-bypass-list=10.0.0.0/8;172.16.0.0/12;192.168.0.0/16;100.64.0.0/10;fc00::/7"
)

func main() {
Expand Down Expand Up @@ -80,9 +80,26 @@ func main() {
fmt.Fprintf(os.Stderr, "failed reading runtime flags: %v\n", err)
os.Exit(1)
}

// A filtered session must not start without its proxy pinned: an extension
// could otherwise switch Chromium to direct connections around the egress
// proxy and the allowlist it enforces.
egress := egresspolicy.Load(egresspolicy.DefaultStatePath, slog.New(slog.NewTextHandler(os.Stderr, nil))).Policy()
if egress.Filtered {
var dropped []string
runtimeTokens, dropped = egresspolicy.DropHostMappingFlags(runtimeTokens)
if len(dropped) > 0 {
fmt.Fprintf(os.Stderr, "egress is filtered; ignoring runtime flags that remap hosts: %s\n", strings.Join(dropped, " "))
}
}
if err := egresspolicy.DefaultPin.Sync(egress, egresspolicy.BaseFlags(baseFlags)); err != nil {
fmt.Fprintf(os.Stderr, "not starting chromium: failed to sync egress proxy pin: %v\n", err)
os.Exit(1)
}

final := chromiumflags.MergeFlagsWithRuntimeTokens(baseFlags, runtimeTokens)
final = chromiumflags.TranslateKernelDisableFeatures(final)
final = withDefaultPrivateNetworkBypass(final)
final = egresspolicy.WithDefaultPrivateNetworkBypass(final)

// Diagnostics for parity with previous scripts
fmt.Printf("BASE_FLAGS: %s\n", baseFlags)
Expand Down Expand Up @@ -162,15 +179,6 @@ func main() {
}
}

func withDefaultPrivateNetworkBypass(flags []string) []string {
for _, flag := range flags {
if flag == "--proxy-bypass-list" || strings.HasPrefix(flag, "--proxy-bypass-list=") {
return flags
}
}
return append(flags, defaultPrivateNetworkBypassFlag)
}

// execLookPath helps satisfy syscall.Exec's requirement to pass an absolute path.
func execLookPath(file string) (string, error) {
if strings.ContainsRune(file, os.PathSeparator) {
Expand Down
60 changes: 0 additions & 60 deletions server/cmd/chromium-launcher/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,69 +4,9 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"

"github.com/kernel/kernel-images/server/lib/chromiumflags"
)

func TestWithDefaultPrivateNetworkBypass(t *testing.T) {
tests := []struct {
name string
flags []string
want []string
}{
{
name: "image default",
want: []string{defaultPrivateNetworkBypassFlag},
},
{
name: "default follows unrelated flags",
flags: []string{"--kiosk"},
want: []string{"--kiosk", defaultPrivateNetworkBypassFlag},
},
{
name: "custom list replaces image default",
flags: []string{"--proxy-bypass-list=preview.internal"},
want: []string{"--proxy-bypass-list=preview.internal"},
},
{
name: "explicit empty list clears image default",
flags: []string{"--proxy-bypass-list="},
want: []string{"--proxy-bypass-list="},
},
{
name: "bare empty list clears image default",
flags: []string{"--proxy-bypass-list"},
want: []string{"--proxy-bypass-list"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := withDefaultPrivateNetworkBypass(tt.flags)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("withDefaultPrivateNetworkBypass() mismatch:\n got: %#v\nwant: %#v", got, tt.want)
}
})
}
}

func TestDefaultPrivateNetworkBypassPreservesRuntimePrecedence(t *testing.T) {
configured := chromiumflags.MergeFlagsWithRuntimeTokens(
"--proxy-bypass-list=preview.internal",
[]string{defaultPrivateNetworkBypassFlag},
)
got := withDefaultPrivateNetworkBypass(configured)
want := []string{
"--proxy-bypass-list=preview.internal",
defaultPrivateNetworkBypassFlag,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("runtime precedence changed:\n got: %#v\nwant: %#v", got, want)
}
}

func TestExecLookPath(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "mybin")
Expand Down
1 change: 0 additions & 1 deletion server/cmd/wrapper/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,4 @@ func prepareUserDirs(asRoot bool) {
_ = exec.Command("chown", "-R", "kernel:kernel",
"/home/kernel", "/home/kernel/user-data", "/home/kernel/.config",
"/home/kernel/.pki", "/home/kernel/.cache").Run()
_ = exec.Command("chown", "-R", "kernel:kernel", "/etc/chromium/policies").Run()
}
Loading
Loading