From e1d16f93806abb2d2fa696b4ce360d7586fe8eb9 Mon Sep 17 00:00:00 2001 From: tnsardesai <18272584+tnsardesai@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:48:00 +0000 Subject: [PATCH 1/3] Add browser location bridge --- server/cmd/api/api/api.go | 8 + server/cmd/api/api/browser_location.go | 187 ++++++++++++++++++++ server/cmd/api/api/browser_location_test.go | 40 +++++ server/cmd/api/api/chromium_configure.go | 36 +++- server/cmd/api/main.go | 1 + server/cmd/chromium-launcher/main.go | 39 +++- server/cmd/chromium-launcher/main_test.go | 7 + server/lib/cdpclient/cdpclient.go | 31 ++++ server/lib/cdpclient/cdpclient_test.go | 24 +++ server/openapi.yaml | 5 + 10 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 server/cmd/api/api/browser_location.go create mode 100644 server/cmd/api/api/browser_location_test.go diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 6faa1b6b7..91e8942b3 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -74,6 +74,14 @@ type ApiService struct { // or mutate its runtime flags and policies. chromiumConfigMu sync.Mutex + browserLocationMu sync.Mutex + browserLocation struct { + accepted *browserLocationBundle + applied *browserLocationBundle + lastError string + cancel context.CancelFunc + } + // inputMu serializes input-related operations (mouse, keyboard, screenshot) inputMu sync.Mutex diff --git a/server/cmd/api/api/browser_location.go b/server/cmd/api/api/browser_location.go new file mode 100644 index 000000000..a59294030 --- /dev/null +++ b/server/cmd/api/api/browser_location.go @@ -0,0 +1,187 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/kernel/kernel-images/server/lib/logger" + "golang.org/x/text/language" +) + +const browserLocationApplyTimeout = 5 * time.Second + +type browserLocationBundle struct { + Epoch string `json:"epoch"` + Generation uint64 `json:"generation"` + TimeZone string `json:"timezone"` + Locale string `json:"locale"` + Languages []string `json:"languages"` +} + +type browserLocationStatus struct { + Accepted *browserLocationBundle `json:"accepted,omitempty"` + Applied *browserLocationBundle `json:"applied,omitempty"` + Error string `json:"error,omitempty"` +} + +var ( + errStaleBrowserLocation = errors.New("browser location generation is stale") + errConflictBrowserLocation = errors.New("browser location generation conflicts with accepted payload") +) + +func validateBrowserLocationBundle(raw string) (browserLocationBundle, error) { + var bundle browserLocationBundle + if err := json.Unmarshal([]byte(raw), &bundle); err != nil { + return bundle, fmt.Errorf("invalid browser_location JSON") + } + if bundle.Epoch == "" || bundle.Generation == 0 { + return bundle, fmt.Errorf("browser_location epoch and generation are required") + } + zonePath, err := browserLocationZonePath(bundle.TimeZone) + if err != nil { + return bundle, err + } + if _, err := os.Stat(zonePath); err != nil { + return bundle, fmt.Errorf("unsupported browser_location timezone") + } + locale, err := language.Parse(bundle.Locale) + if err != nil || locale.String() != bundle.Locale { + return bundle, fmt.Errorf("browser_location locale must be canonical BCP 47") + } + if len(bundle.Languages) == 0 || bundle.Languages[0] != bundle.Locale { + return bundle, fmt.Errorf("browser_location languages must start with locale") + } + for _, value := range bundle.Languages { + tag, err := language.Parse(value) + if err != nil || tag.String() != value { + return bundle, fmt.Errorf("browser_location languages must be canonical BCP 47") + } + } + return bundle, nil +} + +func browserLocationZonePath(timezone string) (string, error) { + if timezone == "" || filepath.IsAbs(timezone) || strings.Contains(timezone, "..") || strings.ContainsRune(timezone, '\x00') { + return "", fmt.Errorf("invalid browser_location timezone") + } + root := "/usr/share/zoneinfo" + path := filepath.Join(root, filepath.FromSlash(timezone)) + if !strings.HasPrefix(path, root+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid browser_location timezone") + } + return path, nil +} + +func (s *ApiService) acceptBrowserLocation(bundle browserLocationBundle) error { + s.browserLocationMu.Lock() + defer s.browserLocationMu.Unlock() + current := s.browserLocation.accepted + if current != nil && current.Epoch == bundle.Epoch { + if bundle.Generation < current.Generation { + return errStaleBrowserLocation + } + if bundle.Generation == current.Generation && !browserLocationBundlesEqual(*current, bundle) { + return errConflictBrowserLocation + } + } + copy := bundle + s.browserLocation.accepted = © + s.browserLocation.lastError = "" + if s.browserLocation.cancel != nil { + s.browserLocation.cancel() + } + ctx, cancel := context.WithCancel(s.lifecycleCtx) + s.browserLocation.cancel = cancel + go s.reconcileBrowserLocation(ctx, copy) + return nil +} + +func (s *ApiService) reconcileBrowserLocation(ctx context.Context, bundle browserLocationBundle) { + deadline := time.Now().Add(browserLocationApplyTimeout) + var lastErr error + for { + if err := rewriteLocaltime(bundle.TimeZone); err != nil { + lastErr = err + } else { + lastErr = s.withCDPClientTimeout(ctx, time.Second, func(cdpCtx context.Context, client *cdpclient.Client) error { + languages := strings.Join(bundle.Languages, ",") + if err := client.SetBrowserLocation(cdpCtx, bundle.Locale, languages); err != nil { + return err + } + observed, err := client.GetBrowserLocation(cdpCtx) + if err != nil { + return err + } + if observed.Locale != bundle.Locale || observed.AcceptLanguages != languages || observed.TimeZone != bundle.TimeZone { + return fmt.Errorf("browser location has not converged") + } + return nil + }) + } + if lastErr == nil { + s.browserLocationMu.Lock() + if accepted := s.browserLocation.accepted; accepted != nil && browserLocationBundlesEqual(*accepted, bundle) { + copy := bundle + s.browserLocation.applied = © + s.browserLocation.lastError = "" + } + s.browserLocationMu.Unlock() + return + } + if time.Now().After(deadline) { + s.browserLocationMu.Lock() + if accepted := s.browserLocation.accepted; accepted != nil && browserLocationBundlesEqual(*accepted, bundle) { + s.browserLocation.lastError = lastErr.Error() + } + s.browserLocationMu.Unlock() + logger.FromContext(ctx).Error("browser location did not converge", "error", lastErr, "generation", bundle.Generation) + return + } + select { + case <-ctx.Done(): + return + case <-time.After(50 * time.Millisecond): + } + } +} + +func rewriteLocaltime(timezone string) error { + target, err := browserLocationZonePath(timezone) + if err != nil { + return err + } + tmp := fmt.Sprintf("/etc/.localtime-kernel-%d", time.Now().UnixNano()) + if err := os.Symlink(target, tmp); err != nil { + return fmt.Errorf("stage localtime: %w", err) + } + defer os.Remove(tmp) + if err := os.Rename(tmp, "/etc/localtime"); err != nil { + return fmt.Errorf("replace localtime: %w", err) + } + return nil +} + +func (s *ApiService) browserLocationSnapshot() browserLocationStatus { + s.browserLocationMu.Lock() + defer s.browserLocationMu.Unlock() + return browserLocationStatus{Accepted: s.browserLocation.accepted, Applied: s.browserLocation.applied, Error: s.browserLocation.lastError} +} + +// GetBrowserLocationHTTP exposes accepted/applied state for internal reconciliation. +func (s *ApiService) GetBrowserLocationHTTP(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.browserLocationSnapshot()) +} + +func browserLocationBundlesEqual(a, b browserLocationBundle) bool { + return a.Epoch == b.Epoch && a.Generation == b.Generation && a.TimeZone == b.TimeZone && a.Locale == b.Locale && slices.Equal(a.Languages, b.Languages) +} diff --git a/server/cmd/api/api/browser_location_test.go b/server/cmd/api/api/browser_location_test.go new file mode 100644 index 000000000..731bdd222 --- /dev/null +++ b/server/cmd/api/api/browser_location_test.go @@ -0,0 +1,40 @@ +package api + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateBrowserLocationBundle(t *testing.T) { + zone := filepath.Join("/usr/share/zoneinfo", "America", "New_York") + if _, err := os.Stat(zone); err != nil { + t.Skip("zoneinfo unavailable") + } + + bundle, err := validateBrowserLocationBundle(`{"epoch":"lease-1","generation":2,"timezone":"America/New_York","locale":"en-US","languages":["en-US","en"]}`) + require.NoError(t, err) + assert.Equal(t, uint64(2), bundle.Generation) + assert.Equal(t, []string{"en-US", "en"}, bundle.Languages) + + for _, raw := range []string{ + `{"epoch":"lease-1","generation":2,"timezone":"../../etc/passwd","locale":"en-US","languages":["en-US"]}`, + `{"epoch":"lease-1","generation":2,"timezone":"America/New_York","locale":"en-us","languages":["en-us"]}`, + `{"epoch":"lease-1","generation":2,"timezone":"America/New_York","locale":"en-US","languages":["de-DE"]}`, + } { + _, err := validateBrowserLocationBundle(raw) + assert.Error(t, err) + } +} + +func TestBrowserLocationBundlesEqual(t *testing.T) { + a := browserLocationBundle{Epoch: "lease", Generation: 1, TimeZone: "UTC", Locale: "en-US", Languages: []string{"en-US", "en"}} + b := a + b.Languages = append([]string(nil), a.Languages...) + assert.True(t, browserLocationBundlesEqual(a, b)) + b.Generation++ + assert.False(t, browserLocationBundlesEqual(a, b)) +} diff --git a/server/cmd/api/api/chromium_configure.go b/server/cmd/api/api/chromium_configure.go index 18d43c2bd..d160ed804 100644 --- a/server/cmd/api/api/chromium_configure.go +++ b/server/cmd/api/api/chromium_configure.go @@ -36,7 +36,8 @@ type chromiumConfigureState struct { profileTemp string // temp archive path hasProfile bool - startURLRaw *string + startURLRaw *string + browserLocation *browserLocationBundle extItems []extensionZipItem // zipTemp paths; merged with chromiumCfgParseExtensions @@ -169,6 +170,14 @@ func (s *ApiService) chromiumConfigureLive(ctx context.Context, st *chromiumConf } } + if st.browserLocation != nil { + if err := s.acceptBrowserLocation(*st.browserLocation); err != nil { + if errors.Is(err, errStaleBrowserLocation) || errors.Is(err, errConflictBrowserLocation) { + return oapi.ChromiumConfigure409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: err.Error()}} + } + return cfg500ConfigureStep(chromiumConfigureStepLocation, err.Error()) + } + } chromiumConfigureNavigate(ctx, s, spec) return nil } @@ -329,6 +338,11 @@ func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumC return resp } } + if st.browserLocation != nil { + if err := s.acceptBrowserLocation(*st.browserLocation); err != nil { + return cfg500ConfigureStep(chromiumConfigureStepLocation, err.Error()) + } + } chromiumConfigureNavigate(ctx, s, spec) if len(stoppedRecordings) > 0 { go s.startNewRecordingSegments(context.WithoutCancel(ctx), stoppedRecordings) @@ -352,6 +366,7 @@ const ( chromiumConfigureStepDisplay chromiumConfigureStep = "display" chromiumConfigureStepFlags chromiumConfigureStep = "chromium_flags" chromiumConfigureStepProfile chromiumConfigureStep = "profile" + chromiumConfigureStepLocation chromiumConfigureStep = "browser_location" ) func chromiumStartURLSpec(raw *string) (startURLParsed, string) { @@ -510,6 +525,9 @@ func cfgActionables(st *chromiumConfigureState) int { if st.displayJSON != nil && strings.TrimSpace(*st.displayJSON) != "" { n++ } + if st.browserLocation != nil { + n++ + } return n } @@ -534,7 +552,7 @@ func chromiumCfgParseMultipart(body interface{}, st *chromiumConfigureState) err gotZip bool } var cur *pend - var gotDisplay, gotChromiumFlags, gotChromePolicies, gotStripComponents, gotProfileArchive, gotStartURL bool + var gotDisplay, gotChromiumFlags, gotChromePolicies, gotStripComponents, gotProfileArchive, gotStartURL, gotBrowserLocation bool for { part, err := mr.NextPart() @@ -611,6 +629,20 @@ func chromiumCfgParseMultipart(body interface{}, st *chromiumConfigureState) err } st.profileTemp = tmp.Name() st.hasProfile = true + case "browser_location": + if gotBrowserLocation { + return cfgParseBadRequest("duplicate browser_location field") + } + gotBrowserLocation = true + b, err := io.ReadAll(part) + if err != nil { + return cfgParseInternal("read browser_location field") + } + bundle, err := validateBrowserLocationBundle(string(b)) + if err != nil { + return cfgParseBadRequest(err.Error()) + } + st.browserLocation = &bundle case "start_url": if gotStartURL { return cfgParseBadRequest("duplicate start_url field") diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 669333ed2..ecd9849fe 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -257,6 +257,7 @@ func main() { // api_call event emission. Off until the telemetry handlers flip it on. r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish)) r.Use(api.WebMCPRequestSizeMiddleware) + r.Get("/browser/location", apiService.GetBrowserLocationHTTP) // Enforce additionalProperties: false on POST /repl. r.Use(api.StrictBrowserReplBodyMiddleware) strictHandler := oapi.NewStrictHandlerWithOptions(apiService, []oapi.StrictMiddlewareFunc{ diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index d08d040b8..ed30b6306 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -35,6 +35,12 @@ func main() { runtimeFlagsPath := flag.String("runtime-flags", "/chromium/flags", "Path to runtime flags overlay file") flag.Parse() + if err := applyStartupTimezone(os.Getenv("KERNEL_BROWSER_TIMEZONE")); err != nil { + fmt.Fprintf(os.Stderr, "failed to initialize browser timezone: %v\n", err) + os.Exit(1) + } + _ = os.Unsetenv("TZ") + // Clean up stale lock file from previous SIGKILL termination // Chromium creates this lock and doesn't clean it up when killed _ = os.Remove("/home/kernel/user-data/SingletonLock") @@ -107,7 +113,7 @@ func main() { // Prepare environment. PULSE_SERVER/PULSE_SINK route chromium's audio into the // recorder's sink; the root path below relies on this inherited env, while the // non-root path re-asserts them in its runuser env allowlist. - env := os.Environ() + env := withoutEnvironmentVariable(os.Environ(), "TZ") env = append(env, "DISPLAY=:1", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", @@ -221,3 +227,34 @@ func killExistingChromium() { // Timeout - processes may still exist but we continue anyway fmt.Fprintf(os.Stderr, "warning: chromium processes may still be running after kill attempt\n") } + +func applyStartupTimezone(timezone string) error { + timezone = strings.TrimSpace(timezone) + if timezone == "" { + return nil + } + if filepath.IsAbs(timezone) || strings.Contains(timezone, "..") || strings.ContainsRune(timezone, '\x00') { + return fmt.Errorf("invalid timezone") + } + target := filepath.Join("/usr/share/zoneinfo", filepath.FromSlash(timezone)) + if _, err := os.Stat(target); err != nil { + return fmt.Errorf("unsupported timezone: %w", err) + } + tmp := fmt.Sprintf("/etc/.localtime-kernel-%d", time.Now().UnixNano()) + if err := os.Symlink(target, tmp); err != nil { + return err + } + defer os.Remove(tmp) + return os.Rename(tmp, "/etc/localtime") +} + +func withoutEnvironmentVariable(env []string, key string) []string { + prefix := key + "=" + out := make([]string, 0, len(env)) + for _, value := range env { + if !strings.HasPrefix(value, prefix) { + out = append(out, value) + } + } + return out +} diff --git a/server/cmd/chromium-launcher/main_test.go b/server/cmd/chromium-launcher/main_test.go index 6e6d11b0c..f30355aca 100644 --- a/server/cmd/chromium-launcher/main_test.go +++ b/server/cmd/chromium-launcher/main_test.go @@ -94,3 +94,10 @@ func TestExecLookPath(t *testing.T) { t.Fatalf("execLookPath PATH search failed: p=%q err=%v", p, err) } } + +func TestWithoutEnvironmentVariable(t *testing.T) { + got := withoutEnvironmentVariable([]string{"A=1", "TZ=", "B=2"}, "TZ") + if !reflect.DeepEqual(got, []string{"A=1", "B=2"}) { + t.Fatalf("unexpected env: %v", got) + } +} diff --git a/server/lib/cdpclient/cdpclient.go b/server/lib/cdpclient/cdpclient.go index 825b3f36b..d619e42f1 100644 --- a/server/lib/cdpclient/cdpclient.go +++ b/server/lib/cdpclient/cdpclient.go @@ -725,3 +725,34 @@ func (c *Client) SetDeviceMetricsOverride(ctx context.Context, width, height int return nil } + +// BrowserLocation is Chromium's current browser-owned geography state. +type BrowserLocation struct { + Locale string `json:"locale"` + AcceptLanguages string `json:"acceptLanguages"` + TimeZone string `json:"timezone"` +} + +// SetBrowserLocation updates Chromium's session-only locale and language defaults. +func (c *Client) SetBrowserLocation(ctx context.Context, locale, acceptLanguages string) error { + _, err := c.Send(ctx, "Browser.setKernelBrowserLocation", map[string]string{ + "locale": locale, "acceptLanguages": acceptLanguages, + }, "") + if err != nil { + return fmt.Errorf("Browser.setKernelBrowserLocation: %w", err) + } + return nil +} + +// GetBrowserLocation reads Chromium's observed locale, languages and host timezone. +func (c *Client) GetBrowserLocation(ctx context.Context) (BrowserLocation, error) { + raw, err := c.Send(ctx, "Browser.getKernelBrowserLocation", nil, "") + if err != nil { + return BrowserLocation{}, fmt.Errorf("Browser.getKernelBrowserLocation: %w", err) + } + var location BrowserLocation + if err := json.Unmarshal(raw, &location); err != nil { + return BrowserLocation{}, fmt.Errorf("decode browser location: %w", err) + } + return location, nil +} diff --git a/server/lib/cdpclient/cdpclient_test.go b/server/lib/cdpclient/cdpclient_test.go index 6e23390d8..e30f1f111 100644 --- a/server/lib/cdpclient/cdpclient_test.go +++ b/server/lib/cdpclient/cdpclient_test.go @@ -32,6 +32,9 @@ type fakeCDP struct { getVersionCalled bool failGetVersion bool productResponse string + browserLocale string + browserLanguages string + browserTimezone string loadUnpackedCalled bool loadUnpackedPath string loadUnpackedID string @@ -117,6 +120,14 @@ func (f *fakeCDP) handler(w http.ResponseWriter, r *http.Request) { "jsVersion": "1.2.3", } } + case "Browser.setKernelBrowserLocation": + var params map[string]string + _ = json.Unmarshal(req.Params, ¶ms) + f.browserLocale = params["locale"] + f.browserLanguages = params["acceptLanguages"] + result = map[string]any{} + case "Browser.getKernelBrowserLocation": + result = map[string]any{"locale": f.browserLocale, "acceptLanguages": f.browserLanguages, "timezone": f.browserTimezone} case "Extensions.loadUnpacked": f.loadUnpackedCalled = true var params map[string]string @@ -665,3 +676,16 @@ func TestCommandOnlyClientDiscardsEvents(t *testing.T) { _, err = client.Send(ctx, "Browser.getVersion", nil, "") require.NoError(t, err) } + +func TestBrowserLocation(t *testing.T) { + f := &fakeCDP{browserTimezone: "Europe/Berlin"} + url := startFakeCDP(t, f) + client, err := Dial(context.Background(), url) + require.NoError(t, err) + defer client.Close() + + require.NoError(t, client.SetBrowserLocation(context.Background(), "de-DE", "de-DE,de")) + location, err := client.GetBrowserLocation(context.Background()) + require.NoError(t, err) + assert.Equal(t, BrowserLocation{Locale: "de-DE", AcceptLanguages: "de-DE,de", TimeZone: "Europe/Berlin"}, location) +} diff --git a/server/openapi.yaml b/server/openapi.yaml index 87a26a85a..ef3a3caa4 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1325,6 +1325,11 @@ paths: URL text to navigate after configure. Bare hosts are normalized to https://, length is capped at 2048 bytes, and Chrome decides which schemes are navigable. type: string + browser_location: + description: >- + UTF-8 JSON browser location bundle with epoch, generation, timezone, locale, + and languages. Accepted asynchronously without restarting Chromium. + type: string extensions: type: array description: Extension zips paired with consecutive extensions.name fields (same as upload-extensions-and-restart). From bfd4e03fd3b06122ac23284ba4c1c4c26c91e084 Mon Sep 17 00:00:00 2001 From: tnsardesai <18272584+tnsardesai@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:55:48 +0000 Subject: [PATCH 2/3] Harden browser location lifecycle --- server/cmd/api/api/api.go | 36 +- server/cmd/api/api/browser_location.go | 348 +++++++++++++++++++- server/cmd/api/api/browser_location_test.go | 120 +++++++ server/cmd/api/api/chromium_configure.go | 36 +- server/cmd/api/main.go | 2 + server/cmd/chromium-launcher/main.go | 9 +- server/cmd/chromium-launcher/main_test.go | 16 + server/lib/cdpclient/cdpclient.go | 30 +- server/lib/cdpclient/cdpclient_test.go | 73 ++-- server/lib/devtoolsproxy/proxy.go | 31 ++ server/lib/devtoolsproxy/proxy_test.go | 78 +++++ server/lib/metrics/browser_location.go | 71 ++++ server/lib/metrics/browser_location_test.go | 23 ++ 13 files changed, 804 insertions(+), 69 deletions(-) create mode 100644 server/lib/metrics/browser_location.go create mode 100644 server/lib/metrics/browser_location_test.go diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 91e8942b3..8c4312c73 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "sync" + "sync/atomic" "time" "github.com/kernel/kernel-images/server/lib/cdpmonitor" @@ -74,12 +75,26 @@ type ApiService struct { // or mutate its runtime flags and policies. chromiumConfigMu sync.Mutex - browserLocationMu sync.Mutex - browserLocation struct { - accepted *browserLocationBundle - applied *browserLocationBundle - lastError string - cancel context.CancelFunc + browserLocationMu sync.Mutex + browserLocationApplyMu sync.Mutex + browserLocationReconcile func(context.Context, browserLocationBundle) + browserLocationValidate func(context.Context, browserLocationBundle) error + browserLocation struct { + activeEpoch string + accepted *browserLocationBundle + applied *browserLocationBundle + components browserLocationComponents + lastError string + cancel context.CancelFunc + acceptedCount atomic.Uint64 + appliedCount atomic.Uint64 + retries atomic.Uint64 + stale atomic.Uint64 + conflicts atomic.Uint64 + epochRejects atomic.Uint64 + failures atomic.Uint64 + convergenceMs atomic.Uint64 + lastAttempt time.Time } // inputMu serializes input-related operations (mouse, keyboard, screenshot) @@ -167,7 +182,7 @@ func New( _ = mon.SetTelemetry(false) ctx, cancel := context.WithCancel(context.Background()) - return &ApiService{ + service := &ApiService{ recordManager: recordManager, factory: factory, defaultRecorderID: "default", @@ -185,7 +200,12 @@ func New( browserRepl: newBrowserReplManager(), lifecycleCtx: ctx, lifecycleCancel: cancel, - }, nil + } + if err := service.initializeBrowserLocation(); err != nil { + cancel() + return nil, err + } + return service, nil } func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecordingRequestObject) (oapi.StartRecordingResponseObject, error) { diff --git a/server/cmd/api/api/browser_location.go b/server/cmd/api/api/browser_location.go index a59294030..961978d57 100644 --- a/server/cmd/api/api/browser_location.go +++ b/server/cmd/api/api/browser_location.go @@ -2,6 +2,7 @@ package api import ( "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -14,10 +15,14 @@ import ( "github.com/kernel/kernel-images/server/lib/cdpclient" "github.com/kernel/kernel-images/server/lib/logger" + "github.com/kernel/kernel-images/server/lib/metrics" "golang.org/x/text/language" ) -const browserLocationApplyTimeout = 5 * time.Second +const ( + browserLocationApplyTimeout = 5 * time.Second + defaultBrowserLocationStatePath = "/run/kernel/browser-location.json" +) type browserLocationBundle struct { Epoch string `json:"epoch"` @@ -27,15 +32,33 @@ type browserLocationBundle struct { Languages []string `json:"languages"` } +type browserLocationComponents struct { + TimeZone bool `json:"timezone"` + Browser bool `json:"browser"` + Renderers bool `json:"renderers"` + NetworkContexts bool `json:"network_contexts"` + DateTimeLocale string `json:"date_time_locale,omitempty"` + NumberLocale string `json:"number_locale,omitempty"` + CollatorLocale string `json:"collator_locale,omitempty"` +} + type browserLocationStatus struct { - Accepted *browserLocationBundle `json:"accepted,omitempty"` - Applied *browserLocationBundle `json:"applied,omitempty"` - Error string `json:"error,omitempty"` + ActiveEpoch string `json:"active_epoch,omitempty"` + Accepted *browserLocationBundle `json:"accepted,omitempty"` + Applied *browserLocationBundle `json:"applied,omitempty"` + Components browserLocationComponents `json:"components"` + Error string `json:"error,omitempty"` +} + +type browserLocationDurableState struct { + ActiveEpoch string `json:"active_epoch"` + Accepted *browserLocationBundle `json:"accepted,omitempty"` } var ( errStaleBrowserLocation = errors.New("browser location generation is stale") errConflictBrowserLocation = errors.New("browser location generation conflicts with accepted payload") + errBrowserLocationEpoch = errors.New("browser location epoch is not active") ) func validateBrowserLocationBundle(raw string) (browserLocationBundle, error) { @@ -81,37 +104,131 @@ func browserLocationZonePath(timezone string) (string, error) { return path, nil } +func (s *ApiService) validateBrowserLocationSupport(ctx context.Context, bundle browserLocationBundle) error { + var resolution cdpclient.BrowserLocationResolution + err := s.withCDPClientTimeout(ctx, time.Second, func(cdpCtx context.Context, client *cdpclient.Client) error { + var err error + resolution, err = client.ValidateBrowserLocation(cdpCtx, bundle.Locale) + return err + }) + if err != nil { + return fmt.Errorf("validate browser_location locale: %w", err) + } + observed := cdpclient.BrowserLocation{ + DateTimeLocale: resolution.DateTimeLocale, + NumberLocale: resolution.NumberLocale, + CollatorLocale: resolution.CollatorLocale, + } + if !resolvedLocalesMatch(bundle.Locale, observed) { + return fmt.Errorf("browser_location locale does not resolve consistently") + } + return nil +} + +func (s *ApiService) resetBrowserLocation(previousEpoch string, bundle browserLocationBundle) error { + if bundle.Generation != 1 { + return fmt.Errorf("browser location reset requires generation 1") + } + s.browserLocationApplyMu.Lock() + defer s.browserLocationApplyMu.Unlock() + s.browserLocationMu.Lock() + defer s.browserLocationMu.Unlock() + if s.browserLocation.activeEpoch == bundle.Epoch { + if s.browserLocation.accepted != nil && browserLocationBundlesEqual(*s.browserLocation.accepted, bundle) { + s.browserLocation.retries.Add(1) + s.startBrowserLocationReconcileLocked(bundle) + return nil + } + return errConflictBrowserLocation + } + if s.browserLocation.activeEpoch != previousEpoch { + s.browserLocation.epochRejects.Add(1) + return errBrowserLocationEpoch + } + copy := bundle + if err := s.persistBrowserLocationState(browserLocationDurableState{ActiveEpoch: bundle.Epoch, Accepted: ©}); err != nil { + return err + } + if s.browserLocation.cancel != nil { + s.browserLocation.cancel() + } + s.browserLocation.activeEpoch = bundle.Epoch + s.browserLocation.accepted = © + s.browserLocation.applied = nil + s.browserLocation.components = browserLocationComponents{} + s.browserLocation.lastError = "" + s.browserLocation.acceptedCount.Add(1) + s.startBrowserLocationReconcileLocked(bundle) + return nil +} + func (s *ApiService) acceptBrowserLocation(bundle browserLocationBundle) error { + s.browserLocationApplyMu.Lock() + defer s.browserLocationApplyMu.Unlock() s.browserLocationMu.Lock() defer s.browserLocationMu.Unlock() + if s.browserLocation.activeEpoch == "" || s.browserLocation.activeEpoch != bundle.Epoch { + s.browserLocation.epochRejects.Add(1) + return errBrowserLocationEpoch + } current := s.browserLocation.accepted - if current != nil && current.Epoch == bundle.Epoch { + if current != nil { if bundle.Generation < current.Generation { + s.browserLocation.stale.Add(1) return errStaleBrowserLocation } - if bundle.Generation == current.Generation && !browserLocationBundlesEqual(*current, bundle) { - return errConflictBrowserLocation + if bundle.Generation == current.Generation { + if !browserLocationBundlesEqual(*current, bundle) { + s.browserLocation.conflicts.Add(1) + return errConflictBrowserLocation + } + s.browserLocation.retries.Add(1) + s.startBrowserLocationReconcileLocked(bundle) + return nil } } copy := bundle + if err := s.persistBrowserLocationState(browserLocationDurableState{ActiveEpoch: s.browserLocation.activeEpoch, Accepted: ©}); err != nil { + return err + } s.browserLocation.accepted = © + s.browserLocation.applied = nil + s.browserLocation.components = browserLocationComponents{} s.browserLocation.lastError = "" + s.browserLocation.acceptedCount.Add(1) + s.startBrowserLocationReconcileLocked(bundle) + return nil +} + +func (s *ApiService) startBrowserLocationReconcileLocked(bundle browserLocationBundle) { + s.browserLocation.lastAttempt = time.Now() if s.browserLocation.cancel != nil { s.browserLocation.cancel() } ctx, cancel := context.WithCancel(s.lifecycleCtx) s.browserLocation.cancel = cancel - go s.reconcileBrowserLocation(ctx, copy) - return nil + reconcile := s.browserLocationReconcile + if reconcile == nil { + reconcile = s.reconcileBrowserLocation + } + go reconcile(ctx, bundle) } func (s *ApiService) reconcileBrowserLocation(ctx context.Context, bundle browserLocationBundle) { - deadline := time.Now().Add(browserLocationApplyTimeout) + started := time.Now() + deadline := started.Add(browserLocationApplyTimeout) var lastErr error for { + s.browserLocationApplyMu.Lock() + if ctx.Err() != nil || !s.browserLocationIsCurrent(bundle) { + s.browserLocationApplyMu.Unlock() + return + } + components := browserLocationComponents{} if err := rewriteLocaltime(bundle.TimeZone); err != nil { lastErr = err } else { + components.TimeZone = true lastErr = s.withCDPClientTimeout(ctx, time.Second, func(cdpCtx context.Context, client *cdpclient.Client) error { languages := strings.Join(bundle.Languages, ",") if err := client.SetBrowserLocation(cdpCtx, bundle.Locale, languages); err != nil { @@ -121,18 +238,32 @@ func (s *ApiService) reconcileBrowserLocation(ctx context.Context, bundle browse if err != nil { return err } - if observed.Locale != bundle.Locale || observed.AcceptLanguages != languages || observed.TimeZone != bundle.TimeZone { - return fmt.Errorf("browser location has not converged") + components.Browser = observed.Locale == bundle.Locale && observed.AcceptLanguages == languages && observed.TimeZone == bundle.TimeZone + components.Renderers = observed.RenderersConverged + components.NetworkContexts = observed.NetworkContextsConverged + components.DateTimeLocale = observed.DateTimeLocale + components.NumberLocale = observed.NumberLocale + components.CollatorLocale = observed.CollatorLocale + if !components.Browser || !components.Renderers || !components.NetworkContexts || !resolvedLocalesMatch(bundle.Locale, observed) { + return fmt.Errorf("browser location components have not converged") } return nil }) } + s.browserLocationApplyMu.Unlock() + s.browserLocationMu.Lock() + if accepted := s.browserLocation.accepted; accepted != nil && browserLocationBundlesEqual(*accepted, bundle) { + s.browserLocation.components = components + } + s.browserLocationMu.Unlock() if lastErr == nil { s.browserLocationMu.Lock() if accepted := s.browserLocation.accepted; accepted != nil && browserLocationBundlesEqual(*accepted, bundle) { copy := bundle s.browserLocation.applied = © s.browserLocation.lastError = "" + s.browserLocation.appliedCount.Add(1) + s.browserLocation.convergenceMs.Store(uint64(time.Since(started).Milliseconds())) } s.browserLocationMu.Unlock() return @@ -141,11 +272,13 @@ func (s *ApiService) reconcileBrowserLocation(ctx context.Context, bundle browse s.browserLocationMu.Lock() if accepted := s.browserLocation.accepted; accepted != nil && browserLocationBundlesEqual(*accepted, bundle) { s.browserLocation.lastError = lastErr.Error() + s.browserLocation.failures.Add(1) } s.browserLocationMu.Unlock() logger.FromContext(ctx).Error("browser location did not converge", "error", lastErr, "generation", bundle.Generation) return } + s.browserLocation.retries.Add(1) select { case <-ctx.Done(): return @@ -154,6 +287,31 @@ func (s *ApiService) reconcileBrowserLocation(ctx context.Context, bundle browse } } +func (s *ApiService) browserLocationIsCurrent(bundle browserLocationBundle) bool { + s.browserLocationMu.Lock() + defer s.browserLocationMu.Unlock() + return s.browserLocation.accepted != nil && browserLocationBundlesEqual(*s.browserLocation.accepted, bundle) +} + +func resolvedLocalesMatch(expected string, observed cdpclient.BrowserLocation) bool { + want, err := language.Parse(expected) + if err != nil { + return false + } + wantBase, _ := want.Base() + for _, value := range []string{observed.DateTimeLocale, observed.NumberLocale, observed.CollatorLocale} { + tag, err := language.Parse(value) + if err != nil { + return false + } + base, _ := tag.Base() + if base != wantBase { + return false + } + } + return true +} + func rewriteLocaltime(timezone string) error { target, err := browserLocationZonePath(timezone) if err != nil { @@ -170,13 +328,175 @@ func rewriteLocaltime(timezone string) error { return nil } +func (s *ApiService) browserLocationStatePath() string { + if value := strings.TrimSpace(os.Getenv("KERNEL_BROWSER_LOCATION_STATE_PATH")); value != "" { + return value + } + return defaultBrowserLocationStatePath +} + +func (s *ApiService) persistBrowserLocationState(state browserLocationDurableState) error { + data, err := json.Marshal(state) + if err != nil { + return err + } + path := s.browserLocationStatePath() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create browser location state directory: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".browser-location-*") + if err != nil { + return fmt.Errorf("create browser location state: %w", err) + } + name := tmp.Name() + defer os.Remove(name) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(name, path); err != nil { + return fmt.Errorf("replace browser location state: %w", err) + } + return nil +} + +func (s *ApiService) initializeBrowserLocation() error { + data, err := os.ReadFile(s.browserLocationStatePath()) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read browser location state: %w", err) + } + if err == nil { + var state browserLocationDurableState + if err := json.Unmarshal(data, &state); err != nil { + return fmt.Errorf("decode browser location state: %w", err) + } + if state.Accepted != nil && state.ActiveEpoch != state.Accepted.Epoch { + return fmt.Errorf("browser location state epoch mismatch") + } + s.browserLocation.activeEpoch = state.ActiveEpoch + s.browserLocation.accepted = state.Accepted + } + if s.upstreamMgr != nil { + go s.browserLocationLifecycleLoop() + } + return nil +} + +func (s *ApiService) browserLocationLifecycleLoop() { + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + lastUpstream := "" + for { + select { + case <-s.lifecycleCtx.Done(): + return + case <-ticker.C: + upstream := s.upstreamMgr.Current() + if upstream == "" { + continue + } + s.browserLocationMu.Lock() + upstreamChanged := upstream != lastUpstream + if upstreamChanged { + lastUpstream = upstream + } + needsRetry := s.browserLocation.accepted != nil && s.browserLocation.applied == nil && s.browserLocation.lastError != "" && time.Since(s.browserLocation.lastAttempt) >= browserLocationApplyTimeout + if s.browserLocation.accepted != nil && (upstreamChanged || needsRetry) { + s.startBrowserLocationReconcileLocked(*s.browserLocation.accepted) + } + s.browserLocationMu.Unlock() + } + } +} + func (s *ApiService) browserLocationSnapshot() browserLocationStatus { s.browserLocationMu.Lock() defer s.browserLocationMu.Unlock() - return browserLocationStatus{Accepted: s.browserLocation.accepted, Applied: s.browserLocation.applied, Error: s.browserLocation.lastError} + return browserLocationStatus{ + ActiveEpoch: s.browserLocation.activeEpoch, + Accepted: s.browserLocation.accepted, + Applied: s.browserLocation.applied, + Components: s.browserLocation.components, + Error: s.browserLocation.lastError, + } +} + +func (s *ApiService) BrowserLocationMetrics() metrics.BrowserLocationSnapshot { + s.browserLocationMu.Lock() + components := s.browserLocation.components + converged := s.browserLocation.applied != nil && s.browserLocation.accepted != nil && browserLocationBundlesEqual(*s.browserLocation.applied, *s.browserLocation.accepted) + s.browserLocationMu.Unlock() + return metrics.BrowserLocationSnapshot{ + Accepted: s.browserLocation.acceptedCount.Load(), + Applied: s.browserLocation.appliedCount.Load(), + Retries: s.browserLocation.retries.Load(), + Stale: s.browserLocation.stale.Load(), + Conflicts: s.browserLocation.conflicts.Load(), + EpochRejects: s.browserLocation.epochRejects.Load(), + Failures: s.browserLocation.failures.Load(), + ConvergenceMs: s.browserLocation.convergenceMs.Load(), + Converged: converged, + TimeZoneConverged: components.TimeZone, + BrowserConverged: components.Browser, + RenderersConverged: components.Renderers, + NetworkConverged: components.NetworkContexts, + } +} + +// ResetBrowserLocationHTTP is the lease-authoritative epoch transition. Ordinary +// configure requests cannot change epochs. The instance JWT is not exposed on +// customer CDP or session APIs and binds the reset to this VM lease. +func (s *ApiService) ResetBrowserLocationHTTP(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + expected := os.Getenv("KERNEL_INSTANCE_JWT") + if expected == "" || subtle.ConstantTimeCompare([]byte(token), []byte(expected)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + defer r.Body.Close() + var reset struct { + PreviousEpoch string `json:"previous_epoch"` + Bundle browserLocationBundle `json:"bundle"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&reset); err != nil { + http.Error(w, "invalid browser location reset", http.StatusBadRequest) + return + } + raw, _ := json.Marshal(reset.Bundle) + validated, err := validateBrowserLocationBundle(string(raw)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + validate := s.browserLocationValidate + if validate == nil { + validate = s.validateBrowserLocationSupport + } + if err := validate(r.Context(), validated); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := s.resetBrowserLocation(reset.PreviousEpoch, validated); err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + w.WriteHeader(http.StatusAccepted) } -// GetBrowserLocationHTTP exposes accepted/applied state for internal reconciliation. +// GetBrowserLocationHTTP exposes accepted/applied component state for internal reconciliation. func (s *ApiService) GetBrowserLocationHTTP(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(s.browserLocationSnapshot()) diff --git a/server/cmd/api/api/browser_location_test.go b/server/cmd/api/api/browser_location_test.go index 731bdd222..5923f9d5f 100644 --- a/server/cmd/api/api/browser_location_test.go +++ b/server/cmd/api/api/browser_location_test.go @@ -1,10 +1,17 @@ package api import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "time" + "github.com/kernel/kernel-images/server/lib/devtoolsproxy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -38,3 +45,116 @@ func TestBrowserLocationBundlesEqual(t *testing.T) { b.Generation++ assert.False(t, browserLocationBundlesEqual(a, b)) } + +func newBrowserLocationStateService(t *testing.T) *ApiService { + t.Helper() + t.Setenv("KERNEL_BROWSER_LOCATION_STATE_PATH", filepath.Join(t.TempDir(), "state.json")) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + service := &ApiService{lifecycleCtx: ctx} + service.browserLocationReconcile = func(context.Context, browserLocationBundle) {} + return service +} + +func testLocationBundle(epoch string, generation uint64) browserLocationBundle { + return browserLocationBundle{Epoch: epoch, Generation: generation, TimeZone: "UTC", Locale: "en-US", Languages: []string{"en-US", "en"}} +} + +func TestBrowserLocationEpochOrdering(t *testing.T) { + service := newBrowserLocationStateService(t) + a1 := testLocationBundle("lease-a", 1) + require.NoError(t, service.resetBrowserLocation("", a1)) + + a2 := testLocationBundle("lease-a", 2) + require.NoError(t, service.acceptBrowserLocation(a2)) + assert.ErrorIs(t, service.acceptBrowserLocation(a1), errStaleBrowserLocation) + + b1 := testLocationBundle("lease-b", 1) + require.NoError(t, service.resetBrowserLocation("lease-a", b1)) + assert.ErrorIs(t, service.acceptBrowserLocation(a2), errBrowserLocationEpoch) + assert.ErrorIs(t, service.resetBrowserLocation("", a1), errBrowserLocationEpoch) + assert.Equal(t, b1, *service.browserLocationSnapshot().Accepted) + + require.NoError(t, service.acceptBrowserLocation(b1)) + conflict := b1 + conflict.Locale = "de-DE" + conflict.Languages = []string{"de-DE", "de"} + assert.ErrorIs(t, service.acceptBrowserLocation(conflict), errConflictBrowserLocation) +} + +func TestBrowserLocationStateSurvivesAPIRestart(t *testing.T) { + service := newBrowserLocationStateService(t) + bundle := testLocationBundle("lease-a", 1) + require.NoError(t, service.resetBrowserLocation("", bundle)) + + restored := &ApiService{lifecycleCtx: service.lifecycleCtx} + restored.browserLocationReconcile = func(context.Context, browserLocationBundle) {} + require.NoError(t, restored.initializeBrowserLocation()) + status := restored.browserLocationSnapshot() + assert.Equal(t, "lease-a", status.ActiveEpoch) + require.NotNil(t, status.Accepted) + assert.Equal(t, bundle, *status.Accepted) +} + +func TestBrowserLocationReconcilesAfterChromiumRestart(t *testing.T) { + service := newBrowserLocationStateService(t) + logPath := filepath.Join(t.TempDir(), "chromium.log") + require.NoError(t, os.WriteFile(logPath, nil, 0o600)) + manager := devtoolsproxy.NewUpstreamManager(logPath, slog.New(slog.DiscardHandler)) + manager.Start(service.lifecycleCtx) + service.upstreamMgr = manager + + reconciled := make(chan browserLocationBundle, 4) + service.browserLocationReconcile = func(_ context.Context, bundle browserLocationBundle) { reconciled <- bundle } + bundle := testLocationBundle("lease-a", 1) + require.NoError(t, service.resetBrowserLocation("", bundle)) + <-reconciled + go service.browserLocationLifecycleLoop() + + file, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0) + require.NoError(t, err) + defer file.Close() + _, err = file.WriteString("DevTools listening on ws://127.0.0.1:9222/devtools/browser/a\n") + require.NoError(t, err) + select { + case got := <-reconciled: + assert.Equal(t, bundle, got) + case <-time.After(3 * time.Second): + t.Fatal("location was not reconciled after Chromium became ready") + } + _, err = file.WriteString("DevTools listening on ws://127.0.0.1:9222/devtools/browser/b\n") + require.NoError(t, err) + select { + case got := <-reconciled: + assert.Equal(t, bundle, got) + case <-time.After(3 * time.Second): + t.Fatal("location was not reconciled after Chromium restart") + } +} + +func TestResetBrowserLocationHTTPRequiresInstanceIdentity(t *testing.T) { + service := newBrowserLocationStateService(t) + service.browserLocationValidate = func(context.Context, browserLocationBundle) error { return nil } + t.Setenv("KERNEL_INSTANCE_JWT", "instance-token") + body := `{"previous_epoch":"","bundle":{"epoch":"lease-a","generation":1,"timezone":"UTC","locale":"en-US","languages":["en-US","en"]}}` + + unauthorized := httptest.NewRecorder() + service.ResetBrowserLocationHTTP(unauthorized, httptest.NewRequest(http.MethodPost, "/internal/browser-location/reset", strings.NewReader(body))) + assert.Equal(t, http.StatusUnauthorized, unauthorized.Code) + assert.Empty(t, service.browserLocationSnapshot().ActiveEpoch) + + request := httptest.NewRequest(http.MethodPost, "/internal/browser-location/reset", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer instance-token") + accepted := httptest.NewRecorder() + service.ResetBrowserLocationHTTP(accepted, request) + assert.Equal(t, http.StatusAccepted, accepted.Code) + assert.Equal(t, "lease-a", service.browserLocationSnapshot().ActiveEpoch) +} + +func TestBrowserLocationPersistenceFailureDoesNotChangeEpoch(t *testing.T) { + service := newBrowserLocationStateService(t) + t.Setenv("KERNEL_BROWSER_LOCATION_STATE_PATH", "/proc/kernel-browser-location-state") + err := service.resetBrowserLocation("", testLocationBundle("lease-a", 1)) + require.Error(t, err) + assert.Empty(t, service.browserLocationSnapshot().ActiveEpoch) +} diff --git a/server/cmd/api/api/chromium_configure.go b/server/cmd/api/api/chromium_configure.go index d160ed804..84af29297 100644 --- a/server/cmd/api/api/chromium_configure.go +++ b/server/cmd/api/api/chromium_configure.go @@ -170,13 +170,8 @@ func (s *ApiService) chromiumConfigureLive(ctx context.Context, st *chromiumConf } } - if st.browserLocation != nil { - if err := s.acceptBrowserLocation(*st.browserLocation); err != nil { - if errors.Is(err, errStaleBrowserLocation) || errors.Is(err, errConflictBrowserLocation) { - return oapi.ChromiumConfigure409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: err.Error()}} - } - return cfg500ConfigureStep(chromiumConfigureStepLocation, err.Error()) - } + if response := s.applyBrowserLocationConfig(ctx, st); response != nil { + return response } chromiumConfigureNavigate(ctx, s, spec) return nil @@ -338,10 +333,8 @@ func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumC return resp } } - if st.browserLocation != nil { - if err := s.acceptBrowserLocation(*st.browserLocation); err != nil { - return cfg500ConfigureStep(chromiumConfigureStepLocation, err.Error()) - } + if response := s.applyBrowserLocationConfig(ctx, st); response != nil { + return response } chromiumConfigureNavigate(ctx, s, spec) if len(stoppedRecordings) > 0 { @@ -356,6 +349,27 @@ type startURLParsed struct { url string } +func (s *ApiService) applyBrowserLocationConfig(ctx context.Context, st *chromiumConfigureState) oapi.ChromiumConfigureResponseObject { + if st.browserLocation == nil { + return nil + } + validate := s.browserLocationValidate + if validate == nil { + validate = s.validateBrowserLocationSupport + } + if err := validate(ctx, *st.browserLocation); err != nil { + return oapi.ChromiumConfigure400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}} + } + err := s.acceptBrowserLocation(*st.browserLocation) + if err == nil { + return nil + } + if errors.Is(err, errStaleBrowserLocation) || errors.Is(err, errConflictBrowserLocation) || errors.Is(err, errBrowserLocationEpoch) { + return oapi.ChromiumConfigure409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: err.Error()}} + } + return cfg500ConfigureStep(chromiumConfigureStepLocation, err.Error()) +} + type chromiumConfigureStep string const ( diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index ecd9849fe..57cd462c4 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -258,6 +258,7 @@ func main() { r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish)) r.Use(api.WebMCPRequestSizeMiddleware) r.Get("/browser/location", apiService.GetBrowserLocationHTTP) + r.Post("/internal/browser-location/reset", apiService.ResetBrowserLocationHTTP) // Enforce additionalProperties: false on POST /repl. r.Use(api.StrictBrowserReplBodyMiddleware) strictHandler := oapi.NewStrictHandlerWithOptions(apiService, []oapi.StrictMiddlewareFunc{ @@ -384,6 +385,7 @@ func main() { rMetrics.Use(chiMiddleware.Recoverer) metricsCollectors := []metrics.Collector{ metrics.NewNetworkCollector(apiService.NetworkMetrics), + metrics.NewBrowserLocationCollector(apiService.BrowserLocationMetrics), metrics.NewChromeCollector(upstreamMgr), metrics.NewGPUCollector(), metrics.NewSystemCollector(), diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index ed30b6306..0f8ee1d21 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -35,7 +35,7 @@ func main() { runtimeFlagsPath := flag.String("runtime-flags", "/chromium/flags", "Path to runtime flags overlay file") flag.Parse() - if err := applyStartupTimezone(os.Getenv("KERNEL_BROWSER_TIMEZONE")); err != nil { + if err := applyStartupTimezone(startupTimezone(os.Getenv)); err != nil { fmt.Fprintf(os.Stderr, "failed to initialize browser timezone: %v\n", err) os.Exit(1) } @@ -258,3 +258,10 @@ func withoutEnvironmentVariable(env []string, key string) []string { } return out } + +func startupTimezone(getenv func(string) string) string { + if timezone := getenv("KERNEL_BROWSER_TIMEZONE"); timezone != "" { + return timezone + } + return getenv("TZ") +} diff --git a/server/cmd/chromium-launcher/main_test.go b/server/cmd/chromium-launcher/main_test.go index f30355aca..dd5ec735d 100644 --- a/server/cmd/chromium-launcher/main_test.go +++ b/server/cmd/chromium-launcher/main_test.go @@ -101,3 +101,19 @@ func TestWithoutEnvironmentVariable(t *testing.T) { t.Fatalf("unexpected env: %v", got) } } + +func TestStartupTimezone(t *testing.T) { + values := map[string]string{"TZ": "America/Chicago", "KERNEL_BROWSER_TIMEZONE": "Europe/Berlin"} + getenv := func(key string) string { return values[key] } + if got := startupTimezone(getenv); got != "Europe/Berlin" { + t.Fatalf("unexpected timezone: %s", got) + } + delete(values, "KERNEL_BROWSER_TIMEZONE") + if got := startupTimezone(getenv); got != "America/Chicago" { + t.Fatalf("unexpected fallback timezone: %s", got) + } + delete(values, "TZ") + if got := startupTimezone(getenv); got != "" { + t.Fatalf("unexpected empty timezone: %s", got) + } +} diff --git a/server/lib/cdpclient/cdpclient.go b/server/lib/cdpclient/cdpclient.go index d619e42f1..f81654b55 100644 --- a/server/lib/cdpclient/cdpclient.go +++ b/server/lib/cdpclient/cdpclient.go @@ -728,9 +728,33 @@ func (c *Client) SetDeviceMetricsOverride(ctx context.Context, width, height int // BrowserLocation is Chromium's current browser-owned geography state. type BrowserLocation struct { - Locale string `json:"locale"` - AcceptLanguages string `json:"acceptLanguages"` - TimeZone string `json:"timezone"` + Locale string `json:"locale"` + AcceptLanguages string `json:"acceptLanguages"` + TimeZone string `json:"timezone"` + DateTimeLocale string `json:"dateTimeLocale"` + NumberLocale string `json:"numberLocale"` + CollatorLocale string `json:"collatorLocale"` + RenderersConverged bool `json:"renderersConverged"` + NetworkContextsConverged bool `json:"networkContextsConverged"` +} + +type BrowserLocationResolution struct { + DateTimeLocale string `json:"dateTimeLocale"` + NumberLocale string `json:"numberLocale"` + CollatorLocale string `json:"collatorLocale"` +} + +// ValidateBrowserLocation verifies locale behavior against Chromium's shipped ICU data. +func (c *Client) ValidateBrowserLocation(ctx context.Context, locale string) (BrowserLocationResolution, error) { + raw, err := c.Send(ctx, "Browser.validateKernelBrowserLocation", map[string]string{"locale": locale}, "") + if err != nil { + return BrowserLocationResolution{}, fmt.Errorf("Browser.validateKernelBrowserLocation: %w", err) + } + var resolution BrowserLocationResolution + if err := json.Unmarshal(raw, &resolution); err != nil { + return BrowserLocationResolution{}, fmt.Errorf("decode browser location validation: %w", err) + } + return resolution, nil } // SetBrowserLocation updates Chromium's session-only locale and language defaults. diff --git a/server/lib/cdpclient/cdpclient_test.go b/server/lib/cdpclient/cdpclient_test.go index e30f1f111..711842ce0 100644 --- a/server/lib/cdpclient/cdpclient_test.go +++ b/server/lib/cdpclient/cdpclient_test.go @@ -18,35 +18,38 @@ import ( // fakeCDP is a minimal CDP server that responds to the commands used by // SetDeviceMetricsOverride and GetBrowserVersion. type fakeCDP struct { - getTargetsCalled bool - attachCalled bool - setMetricsCalled bool - setMetricsWidth int - setMetricsHeight int - detachCalled bool - pageTargetID string - sessionID string - failGetTargets bool - failSetMetrics bool - returnNoPageTargets bool - getVersionCalled bool - failGetVersion bool - productResponse string - browserLocale string - browserLanguages string - browserTimezone string - loadUnpackedCalled bool - loadUnpackedPath string - loadUnpackedID string - failLoadUnpacked bool - getExtensionsCalled bool - extensions []ExtensionInfo - failGetExtensions bool - navigateCalled bool - navigateCalls int - navigateURL string - pageStates []string - pageStateIndex int + getTargetsCalled bool + attachCalled bool + setMetricsCalled bool + setMetricsWidth int + setMetricsHeight int + detachCalled bool + pageTargetID string + sessionID string + failGetTargets bool + failSetMetrics bool + returnNoPageTargets bool + getVersionCalled bool + failGetVersion bool + productResponse string + browserLocale string + browserLanguages string + browserTimezone string + browserDateTimeLocale string + browserNumberLocale string + browserCollatorLocale string + loadUnpackedCalled bool + loadUnpackedPath string + loadUnpackedID string + failLoadUnpacked bool + getExtensionsCalled bool + extensions []ExtensionInfo + failGetExtensions bool + navigateCalled bool + navigateCalls int + navigateURL string + pageStates []string + pageStateIndex int } func (f *fakeCDP) handler(w http.ResponseWriter, r *http.Request) { @@ -120,6 +123,8 @@ func (f *fakeCDP) handler(w http.ResponseWriter, r *http.Request) { "jsVersion": "1.2.3", } } + case "Browser.validateKernelBrowserLocation": + result = map[string]any{"dateTimeLocale": f.browserDateTimeLocale, "numberLocale": f.browserNumberLocale, "collatorLocale": f.browserCollatorLocale} case "Browser.setKernelBrowserLocation": var params map[string]string _ = json.Unmarshal(req.Params, ¶ms) @@ -127,7 +132,7 @@ func (f *fakeCDP) handler(w http.ResponseWriter, r *http.Request) { f.browserLanguages = params["acceptLanguages"] result = map[string]any{} case "Browser.getKernelBrowserLocation": - result = map[string]any{"locale": f.browserLocale, "acceptLanguages": f.browserLanguages, "timezone": f.browserTimezone} + result = map[string]any{"locale": f.browserLocale, "acceptLanguages": f.browserLanguages, "timezone": f.browserTimezone, "dateTimeLocale": f.browserDateTimeLocale, "numberLocale": f.browserNumberLocale, "collatorLocale": f.browserCollatorLocale, "renderersConverged": true, "networkContextsConverged": true} case "Extensions.loadUnpacked": f.loadUnpackedCalled = true var params map[string]string @@ -678,14 +683,18 @@ func TestCommandOnlyClientDiscardsEvents(t *testing.T) { } func TestBrowserLocation(t *testing.T) { - f := &fakeCDP{browserTimezone: "Europe/Berlin"} + f := &fakeCDP{browserTimezone: "Europe/Berlin", browserDateTimeLocale: "de", browserNumberLocale: "de", browserCollatorLocale: "de"} url := startFakeCDP(t, f) client, err := Dial(context.Background(), url) require.NoError(t, err) defer client.Close() + resolution, err := client.ValidateBrowserLocation(context.Background(), "de-DE") + require.NoError(t, err) + assert.Equal(t, BrowserLocationResolution{DateTimeLocale: "de", NumberLocale: "de", CollatorLocale: "de"}, resolution) + require.NoError(t, client.SetBrowserLocation(context.Background(), "de-DE", "de-DE,de")) location, err := client.GetBrowserLocation(context.Background()) require.NoError(t, err) - assert.Equal(t, BrowserLocation{Locale: "de-DE", AcceptLanguages: "de-DE,de", TimeZone: "Europe/Berlin"}, location) + assert.Equal(t, BrowserLocation{Locale: "de-DE", AcceptLanguages: "de-DE,de", TimeZone: "Europe/Berlin", DateTimeLocale: "de", NumberLocale: "de", CollatorLocale: "de", RenderersConverged: true, NetworkContextsConverged: true}, location) } diff --git a/server/lib/devtoolsproxy/proxy.go b/server/lib/devtoolsproxy/proxy.go index 50cdde31d..24a16003d 100644 --- a/server/lib/devtoolsproxy/proxy.go +++ b/server/lib/devtoolsproxy/proxy.go @@ -27,6 +27,34 @@ import ( "github.com/nrednav/cuid2" ) +var internalCDPMethods = map[string]struct{}{ + "Browser.validateKernelBrowserLocation": {}, + "Browser.setKernelBrowserLocation": {}, + "Browser.getKernelBrowserLocation": {}, +} + +func filterInternalCDPMethod(message []byte) []byte { + var command struct { + Method string `json:"method"` + } + if json.Unmarshal(message, &command) != nil { + return message + } + if _, blocked := internalCDPMethods[command.Method]; !blocked { + return message + } + var fields map[string]json.RawMessage + if json.Unmarshal(message, &fields) != nil { + return message + } + fields["method"] = json.RawMessage(`"Kernel.internalMethodUnavailable"`) + filtered, err := json.Marshal(fields) + if err != nil { + return message + } + return filtered +} + var devtoolsListeningRegexp = regexp.MustCompile(`DevTools listening on (ws://\S+)`) // UpstreamManager tails the Chromium supervisord log and extracts the current DevTools @@ -325,6 +353,9 @@ func WebSocketProxyHandler(mgr *UpstreamManager, logger *slog.Logger, logCDPMess logCDPMessage(logger, direction, mt, msg) } msgCount.Add(1) + if direction == "->" && mt == websocket.MessageText { + return filterInternalCDPMethod(msg) + } return msg } diff --git a/server/lib/devtoolsproxy/proxy_test.go b/server/lib/devtoolsproxy/proxy_test.go index eb2ee285e..b730f63dc 100644 --- a/server/lib/devtoolsproxy/proxy_test.go +++ b/server/lib/devtoolsproxy/proxy_test.go @@ -866,3 +866,81 @@ func TestWebSocketProxyHandler_EmitsUpstreamErrorOnDialFailure(t *testing.T) { // controlOn is the gate a proxy test needs to see cdp_command events at all. func controlOn() bool { return true } + +func TestFilterInternalCDPMethod(t *testing.T) { + for _, method := range []string{"Browser.validateKernelBrowserLocation", "Browser.setKernelBrowserLocation", "Browser.getKernelBrowserLocation"} { + input := []byte(`{"id":7,"method":"` + method + `","params":{"locale":"de-DE"}}`) + var got map[string]any + if err := json.Unmarshal(filterInternalCDPMethod(input), &got); err != nil { + t.Fatal(err) + } + if got["method"] != "Kernel.internalMethodUnavailable" || got["id"] != float64(7) { + t.Fatalf("unexpected filtered command: %#v", got) + } + } + + emulation := []byte(`{"id":8,"method":"Emulation.setLocaleOverride","params":{"locale":"de-DE"}}`) + if got := filterInternalCDPMethod(emulation); string(got) != string(emulation) { + t.Fatalf("standard command changed: %s", got) + } +} + +func TestWebSocketProxyRejectsInternalLocationMethods(t *testing.T) { + methods := make(chan string, 2) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}) + if err != nil { + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + for { + _, message, err := conn.Read(r.Context()) + if err != nil { + return + } + var command struct { + ID int `json:"id"` + Method string `json:"method"` + } + if json.Unmarshal(message, &command) != nil { + return + } + methods <- command.Method + response, _ := json.Marshal(map[string]any{"id": command.ID, "result": map[string]any{}}) + if conn.Write(r.Context(), websocket.MessageText, response) != nil { + return + } + } + })) + defer upstream.Close() + + upstreamURL, _ := url.Parse(upstream.URL) + manager := NewUpstreamManager("/dev/null", silentLogger()) + manager.setCurrent("ws://" + upstreamURL.Host) + proxy := httptest.NewServer(WebSocketProxyHandler(manager, silentLogger(), false, scaletozero.NewNoopController(), nil, nil, nil, nil)) + defer proxy.Close() + proxyURL, _ := url.Parse(proxy.URL) + proxyURL.Scheme = "ws" + conn, _, err := websocket.Dial(context.Background(), proxyURL.String(), nil) + if err != nil { + t.Fatal(err) + } + defer conn.Close(websocket.StatusNormalClosure, "") + + commands := []string{"Browser.setKernelBrowserLocation", "Emulation.setLocaleOverride"} + for index, method := range commands { + message, _ := json.Marshal(map[string]any{"id": index + 1, "method": method}) + if err := conn.Write(context.Background(), websocket.MessageText, message); err != nil { + t.Fatal(err) + } + if _, _, err := conn.Read(context.Background()); err != nil { + t.Fatal(err) + } + } + if got := <-methods; got != "Kernel.internalMethodUnavailable" { + t.Fatalf("internal method reached upstream as %q", got) + } + if got := <-methods; got != "Emulation.setLocaleOverride" { + t.Fatalf("standard Emulation method changed to %q", got) + } +} diff --git a/server/lib/metrics/browser_location.go b/server/lib/metrics/browser_location.go new file mode 100644 index 000000000..ce1106883 --- /dev/null +++ b/server/lib/metrics/browser_location.go @@ -0,0 +1,71 @@ +package metrics + +import "context" + +// BrowserLocationSnapshot contains process-lifetime control-plane counters and +// current component convergence. It intentionally carries no lease or locale +// labels so metric cardinality remains bounded. +type BrowserLocationSnapshot struct { + Accepted uint64 + Applied uint64 + Retries uint64 + Stale uint64 + Conflicts uint64 + EpochRejects uint64 + Failures uint64 + ConvergenceMs uint64 + Converged bool + TimeZoneConverged bool + BrowserConverged bool + RenderersConverged bool + NetworkConverged bool +} + +type BrowserLocationCollector struct { + snapshot func() BrowserLocationSnapshot +} + +func NewBrowserLocationCollector(snapshot func() BrowserLocationSnapshot) *BrowserLocationCollector { + return &BrowserLocationCollector{snapshot: snapshot} +} + +func (*BrowserLocationCollector) Name() string { return "browser_location" } + +func (c *BrowserLocationCollector) Collect(_ context.Context, w *Writer) error { + s := c.snapshot() + counters := []struct { + name string + help string + value uint64 + }{ + {"kernel_browser_location_accepted_total", "Browser location bundles accepted.", s.Accepted}, + {"kernel_browser_location_applied_total", "Browser location bundles observed fully converged.", s.Applied}, + {"kernel_browser_location_retries_total", "Browser location same-value and convergence retries.", s.Retries}, + {"kernel_browser_location_stale_total", "Stale browser location generations rejected.", s.Stale}, + {"kernel_browser_location_conflicts_total", "Equal-generation browser location payload conflicts rejected.", s.Conflicts}, + {"kernel_browser_location_epoch_rejects_total", "Browser location bundles rejected for a non-active epoch.", s.EpochRejects}, + {"kernel_browser_location_failures_total", "Browser location reconciliation attempts that exhausted their deadline.", s.Failures}, + } + for _, counter := range counters { + w.Metric(counter.name, counter.help, "counter") + w.Sample(counter.name, nil, float64(counter.value)) + } + w.Metric("kernel_browser_location_convergence_milliseconds", "Most recent browser location convergence latency.", "gauge") + w.Sample("kernel_browser_location_convergence_milliseconds", nil, float64(s.ConvergenceMs)) + w.Metric("kernel_browser_location_converged", "Whether the accepted browser location generation is fully converged.", "gauge") + w.Sample("kernel_browser_location_converged", nil, boolToFloat(s.Converged)) + components := []struct { + name string + value bool + }{ + {"timezone", s.TimeZoneConverged}, + {"browser", s.BrowserConverged}, + {"renderers", s.RenderersConverged}, + {"network_contexts", s.NetworkConverged}, + } + w.Metric("kernel_browser_location_component_converged", "Whether each browser location component has converged.", "gauge") + for _, component := range components { + w.Sample("kernel_browser_location_component_converged", []Label{{Name: "component", Value: component.name}}, boolToFloat(component.value)) + } + return nil +} diff --git a/server/lib/metrics/browser_location_test.go b/server/lib/metrics/browser_location_test.go new file mode 100644 index 000000000..48f110f9e --- /dev/null +++ b/server/lib/metrics/browser_location_test.go @@ -0,0 +1,23 @@ +package metrics + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBrowserLocationCollector(t *testing.T) { + collector := NewBrowserLocationCollector(func() BrowserLocationSnapshot { + return BrowserLocationSnapshot{Accepted: 2, Applied: 1, Retries: 3, Converged: false, TimeZoneConverged: true} + }) + writer := &Writer{} + require.NoError(t, collector.Collect(context.Background(), writer)) + out := string(writer.Bytes()) + assert.Contains(t, out, "kernel_browser_location_accepted_total 2") + assert.Contains(t, out, "kernel_browser_location_applied_total 1") + assert.Contains(t, out, "kernel_browser_location_retries_total 3") + assert.Contains(t, out, `kernel_browser_location_component_converged{component="timezone"} 1`) + assert.Contains(t, out, `kernel_browser_location_component_converged{component="renderers"} 0`) +} From a7d82534fa35b72dd378e29531a22ae2fb81eec5 Mon Sep 17 00:00:00 2001 From: tnsardesai <18272584+tnsardesai@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:02:33 +0000 Subject: [PATCH 3/3] Register location routes after middleware --- server/cmd/api/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 57cd462c4..ce4b150a4 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -257,10 +257,10 @@ func main() { // api_call event emission. Off until the telemetry handlers flip it on. r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish)) r.Use(api.WebMCPRequestSizeMiddleware) - r.Get("/browser/location", apiService.GetBrowserLocationHTTP) - r.Post("/internal/browser-location/reset", apiService.ResetBrowserLocationHTTP) // Enforce additionalProperties: false on POST /repl. r.Use(api.StrictBrowserReplBodyMiddleware) + r.Get("/browser/location", apiService.GetBrowserLocationHTTP) + r.Post("/internal/browser-location/reset", apiService.ResetBrowserLocationHTTP) strictHandler := oapi.NewStrictHandlerWithOptions(apiService, []oapi.StrictMiddlewareFunc{ api.TelemetryStrictMiddleware(), }, oapi.StrictHTTPServerOptions{