diff --git a/server/Makefile b/server/Makefile index 303ea419..6f98d35b 100644 --- a/server/Makefile +++ b/server/Makefile @@ -36,7 +36,7 @@ test: @echo "" @echo "=== Running e2e tests (testcontainers — this may take a few minutes) ===" @echo "" - go test -v -race ./e2e/ + go test -v -race -timeout 120m ./e2e/ clean: @rm -rf $(BIN_DIR) diff --git a/server/cmd/api/api/chromium.go b/server/cmd/api/api/chromium.go index 0fbda144..772ba1e9 100644 --- a/server/cmd/api/api/chromium.go +++ b/server/cmd/api/api/chromium.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/kernel/kernel-images/server/lib/cdpclient" "github.com/kernel/kernel-images/server/lib/chromiumflags" "github.com/kernel/kernel-images/server/lib/logger" oapi "github.com/kernel/kernel-images/server/lib/oapi" @@ -21,6 +22,12 @@ import ( var nameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]{1,255}$`) +// extensionZipItem is a finalized name + temp zip path (caller removes temps). +type extensionZipItem struct { + zipTemp string + name string +} + // chromiumFlagsPath is the runtime flags file read by the chromium-launcher at startup. const chromiumFlagsPath = "/chromium/flags" @@ -130,52 +137,90 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no extensions provided"}}, nil } - // Materialize uploads + extItems := make([]extensionZipItem, 0, len(items)) + for _, p := range items { + if !p.zipReceived || p.name == "" { + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "each item must include zip_file and name"}}, nil + } + extItems = append(extItems, extensionZipItem{zipTemp: p.zipTemp, name: p.name}) + } + + reqMsg, err := s.applyExtensionZipItems(ctx, extItems) + if reqMsg != "" { + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: reqMsg}}, nil + } + if err != nil { + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: err.Error()}}, nil + } + + // Restart Chromium and wait for DevTools to be ready + if err := s.restartChromiumAndWait(ctx, "extension upload"); err != nil { + return oapi.UploadExtensionsAndRestart500JSONResponse{ + InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: err.Error()}, + }, nil + } + + log.Info("devtools ready", "elapsed", time.Since(start).String()) + return oapi.UploadExtensionsAndRestart201Response{}, nil +} + +// applyExtensionZipItems applies name+zipTemp extension pairs (merge flags for --load-extension). +// On validation errors returns (reqMsg, nil); on internal errors returns ("", err). +func (s *ApiService) applyExtensionZipItems(ctx context.Context, items []extensionZipItem) (reqMsg string, err error) { + log := logger.FromContext(ctx) extBase := "/home/kernel/extensions" + if err := os.MkdirAll(extBase, 0o755); err != nil { + return "", fmt.Errorf("failed to create extension base dir: %w", err) + } - // Fail early if any destination already exists for _, p := range items { dest := filepath.Join(extBase, p.name) if _, err := os.Stat(dest); err == nil { - return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("extension name already exists: %s", p.name)}}, nil + return fmt.Sprintf("extension name already exists: %s", p.name), nil } else if !os.IsNotExist(err) { log.Error("failed to check extension dir", "error", err) - return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to check extension dir"}}, nil + return "", fmt.Errorf("failed to check extension dir: %w", err) } } - for _, p := range items { - if !p.zipReceived || p.name == "" { - return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "each item must include zip_file and name"}}, nil + var createdDests []string + success := false + defer func() { + if success { + return } + for _, dest := range createdDests { + if removeErr := os.RemoveAll(dest); removeErr != nil { + log.Warn("failed to clean up partial extension dir", "error", removeErr, "dest", dest) + } + } + }() + + for _, p := range items { dest := filepath.Join(extBase, p.name) if err := os.MkdirAll(dest, 0o755); err != nil { log.Error("failed to create extension dir", "error", err) - return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create extension dir"}}, nil + return "", fmt.Errorf("failed to create extension dir: %w", err) } + createdDests = append(createdDests, dest) if err := ziputil.Unzip(p.zipTemp, dest); err != nil { log.Error("failed to unzip zip file", "error", err) - return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid zip file"}}, nil + return "invalid zip file", nil } - // Rewrite update.xml URLs to match the extension name (directory name) - // This ensures URLs like /extensions/web-bot-auth/ become /extensions// updateXMLPath := filepath.Join(dest, "update.xml") if err := policy.RewriteUpdateXMLUrls(updateXMLPath, p.name); err != nil { log.Warn("failed to rewrite update.xml URLs", "error", err, "extension", p.name) - // continue since not all extensions require update.xml } if err := exec.Command("chown", "-R", "kernel:kernel", dest).Run(); err != nil { log.Error("failed to chown extension dir", "error", err) - return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to chown extension dir"}}, nil + return "", fmt.Errorf("failed to chown extension dir: %w", err) } log.Info("installed extension", "name", p.name) } - // Update enterprise policy for extensions that require it - // Track which extensions need --load-extension flags (those NOT using policy installation) var pathsNeedingFlags []string for _, p := range items { @@ -184,14 +229,11 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap manifestPath := filepath.Join(extensionPath, "manifest.json") updateXMLPath := filepath.Join(extensionPath, "update.xml") - // Check if this extension requires enterprise policy requiresEntPolicy, err := s.policy.RequiresEnterprisePolicy(manifestPath) if err != nil { log.Warn("failed to read manifest for policy check", "error", err, "extension", extensionName) - // Continue with requiresEntPolicy = false } - // Try to extract Chrome extension ID from update.xml chromeExtensionID := extensionName var extractionErr error if extractedID, err := policy.ExtractExtensionIDFromUpdateXML(updateXMLPath); err == nil { @@ -205,25 +247,17 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap if requiresEntPolicy { log.Info("extension requires enterprise policy", "name", extensionName) - // Validate that update.xml and .crx files are present for policy-installed extensions - // These files are required for ExtensionInstallForcelist to work hasUpdateXML := false hasCRX := false if _, err := os.Stat(updateXMLPath); err == nil { - // For policy extensions, update.xml must exist AND be parseable if extractionErr != nil { - return oapi.UploadExtensionsAndRestart400JSONResponse{ - BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{ - Message: fmt.Sprintf("extension %s requires enterprise policy but update.xml is invalid: %v", extensionName, extractionErr), - }, - }, nil + return fmt.Sprintf("extension %s requires enterprise policy but update.xml is invalid: %v", extensionName, extractionErr), nil } hasUpdateXML = true log.Info("found update.xml in extension zip", "name", extensionName) } - // Look for any .crx file in the directory entries, err := os.ReadDir(extensionPath) if err == nil { for _, entry := range entries { @@ -235,7 +269,6 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap } } - // If missing required files for ExtensionInstallForcelist, fall back to --load-extension if !hasUpdateXML || !hasCRX { log.Info("extension missing policy files, falling back to --load-extension", "name", extensionName, "hasUpdateXML", hasUpdateXML, "hasCRX", hasCRX) @@ -243,31 +276,17 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap pathsNeedingFlags = append(pathsNeedingFlags, extensionPath) } } else { - // Only add --load-extension flags for non-policy extensions pathsNeedingFlags = append(pathsNeedingFlags, extensionPath) } - // Add to enterprise policy - // Pass both extensionName (for URL paths) and chromeExtensionID (for policy entries) if err := s.policy.AddExtension(extensionName, chromeExtensionID, extensionPath, requiresEntPolicy); err != nil { log.Error("failed to update enterprise policy", "error", err, "extension", extensionName) - return oapi.UploadExtensionsAndRestart500JSONResponse{ - InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{ - Message: fmt.Sprintf("failed to update enterprise policy for %s: %v", extensionName, err), - }, - }, nil + return "", fmt.Errorf("failed to update enterprise policy for %s: %w", extensionName, err) } log.Info("updated enterprise policy", "extension", extensionName, "chromeExtensionID", chromeExtensionID, "requiresEnterprisePolicy", requiresEntPolicy) } - // Build flags overlay file in /chromium/flags, merging with existing flags - // Only add --load-extension flags for extensions that don't use policy installation - // NOTE: We intentionally do NOT use --disable-extensions-except here because it causes - // Chrome to disable external providers (including the policy loader), which prevents - // enterprise policy extensions (ExtensionInstallForcelist) from being fetched and installed. - // See Chromium source: extension_service.cc - external providers are only created when - // extensions_enabled() returns true, which is false when --disable-extensions-except is used. var newTokens []string if len(pathsNeedingFlags) > 0 { newTokens = []string{ @@ -275,22 +294,12 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap } } - // Merge and write flags if _, err := s.mergeAndWriteChromiumFlags(ctx, newTokens); err != nil { - return oapi.UploadExtensionsAndRestart500JSONResponse{ - InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: err.Error()}, - }, nil + return "", err } - // Restart Chromium and wait for DevTools to be ready - if err := s.restartChromiumAndWait(ctx, "extension upload"); err != nil { - return oapi.UploadExtensionsAndRestart500JSONResponse{ - InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: err.Error()}, - }, nil - } - - log.Info("devtools ready", "elapsed", time.Since(start).String()) - return oapi.UploadExtensionsAndRestart201Response{}, nil + success = true + return "", nil } // mergeAndWriteChromiumFlags reads existing flags, merges them with new flags, @@ -337,36 +346,162 @@ func (s *ApiService) restartChromiumAndWait(ctx context.Context, operation strin log := logger.FromContext(ctx) start := time.Now() - // Begin listening for devtools URL updates, since we are about to restart Chromium + log.Info("restarting chromium via supervisorctl", "operation", operation) + if err := s.stopChromium(ctx); err != nil { + return err + } + if err := s.startChromiumAndWait(ctx, operation); err != nil { + return err + } + log.Info("chromium restart complete", "operation", operation, "elapsed", time.Since(start).String()) + return nil +} + +const supervisorCtlConf = "/etc/supervisor/supervisord.conf" +const chromiumDevToolsReadyTimeout = 90 * time.Second + +func supervisorctlArgv(verb string, prog string) []string { + return []string{"-c", supervisorCtlConf, verb, prog} +} + +func chromiumSupervisorStatus(ctx context.Context) (string, string, error) { + cmdCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(cmdCtx, "supervisorctl", supervisorctlArgv("status", "chromium")...).CombinedOutput() + text := strings.TrimSpace(string(out)) + fields := strings.Fields(text) + if len(fields) >= 2 { + return fields[1], text, nil + } + if err != nil { + return "", text, err + } + return "", text, fmt.Errorf("unexpected supervisorctl status output: %q", text) +} + +func waitChromiumSupervisorStatus(ctx context.Context, want string, timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + var last string + for { + status, out, err := chromiumSupervisorStatus(ctx) + if err == nil && status == want { + return out, nil + } + if out != "" { + last = out + } + if time.Now().After(deadline) { + if err != nil { + return last, err + } + return last, fmt.Errorf("chromium did not reach %s within %s (last status: %s)", want, timeout, last) + } + time.Sleep(500 * time.Millisecond) + } +} + +// stopChromium runs supervisorctl stop chromium and waits for the command to complete. +func (s *ApiService) stopChromium(ctx context.Context) error { + log := logger.FromContext(ctx) + cmdCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) + defer cancel() + log.Info("stopping chromium via supervisorctl") + out, err := exec.CommandContext(cmdCtx, "supervisorctl", supervisorctlArgv("stop", "chromium")...).CombinedOutput() + if err != nil { + log.Error("failed to stop chromium", "error", err, "out", string(out)) + status, statusOut, statusErr := chromiumSupervisorStatus(ctx) + if statusErr == nil { + switch status { + case "STOPPED": + log.Info("chromium already stopped after supervisorctl stop error", "status", statusOut) + return nil + case "STOPPING": + if stoppedOut, waitErr := waitChromiumSupervisorStatus(ctx, "STOPPED", 30*time.Second); waitErr == nil { + log.Info("chromium reached stopped after supervisorctl stop error", "status", stoppedOut) + return nil + } + } + } + return fmt.Errorf("supervisorctl stop chromium failed: %w", err) + } + if stoppedOut, waitErr := waitChromiumSupervisorStatus(ctx, "STOPPED", 30*time.Second); waitErr != nil { + log.Warn("chromium stop command completed but stopped status was not confirmed", "error", waitErr, "status", stoppedOut) + } + return nil +} + +// startChromiumAndWait launches chromium via supervisorctl start and waits for DevTools readiness. +func (s *ApiService) startChromiumAndWait(ctx context.Context, operation string) error { + log := logger.FromContext(ctx) + start := time.Now() + + prevUpstream := s.upstreamMgr.Current() updates, cancelSub := s.upstreamMgr.Subscribe() defer cancelSub() - // Run supervisorctl restart with a new context to let it run beyond the lifetime of the http request. - // This lets us return as soon as the DevTools URL is updated. errCh := make(chan error, 1) - log.Info("restarting chromium via supervisorctl", "operation", operation) + doneCh := make(chan struct{}) + log.Info("starting chromium via supervisorctl", "operation", operation) go func() { - cmdCtx, cancelCmd := context.WithTimeout(context.WithoutCancel(ctx), 1*time.Minute) + defer close(doneCh) + cmdCtx, cancelCmd := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) defer cancelCmd() - out, err := exec.CommandContext(cmdCtx, "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "restart", "chromium").CombinedOutput() + out, err := exec.CommandContext(cmdCtx, "supervisorctl", supervisorctlArgv("start", "chromium")...).CombinedOutput() if err != nil { - log.Error("failed to restart chromium", "error", err, "out", string(out)) - errCh <- fmt.Errorf("supervisorctl restart failed: %w", err) + log.Error("failed to start chromium", "error", err, "out", string(out)) + errCh <- fmt.Errorf("supervisorctl start chromium failed: %w", err) } }() - // Wait for either a new upstream, a restart error, or timeout - timeout := time.NewTimer(15 * time.Second) + timeout := time.NewTimer(chromiumDevToolsReadyTimeout) defer timeout.Stop() - select { - case <-updates: - log.Info("devtools ready", "operation", operation, "elapsed", time.Since(start).String()) - return nil - case err := <-errCh: - return err - case <-timeout.C: - log.Info("devtools not ready in time", "operation", operation, "elapsed", time.Since(start).String()) - return fmt.Errorf("devtools not ready in time") + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + commandDone := false + tryReady := func(upstream string, allowCurrent bool) bool { + if upstream == "" { + return false + } + if !allowCurrent && upstream == prevUpstream { + return false + } + dialCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + c, err := cdpclient.Dial(dialCtx, upstream) + if err != nil { + return false + } + _ = c.Close() + return true + } + + for { + select { + case upstream, ok := <-updates: + if ok && tryReady(upstream, false) { + log.Info("devtools ready", "operation", operation, "elapsed", time.Since(start).String()) + return nil + } + case err := <-errCh: + return err + case <-doneCh: + commandDone = true + doneCh = nil + if tryReady(s.upstreamMgr.Current(), true) { + log.Info("devtools ready", "operation", operation, "elapsed", time.Since(start).String()) + return nil + } + case <-ticker.C: + if commandDone && tryReady(s.upstreamMgr.Current(), true) { + log.Info("devtools ready", "operation", operation, "elapsed", time.Since(start).String()) + return nil + } + case <-timeout.C: + status, statusOut, _ := chromiumSupervisorStatus(ctx) + log.Info("devtools not ready in time", "operation", operation, "elapsed", time.Since(start).String(), "supervisor_status", statusOut) + return fmt.Errorf("devtools not ready in time (chromium status: %s)", status) + } } } diff --git a/server/cmd/api/api/chromium_configure.go b/server/cmd/api/api/chromium_configure.go new file mode 100644 index 00000000..167ac529 --- /dev/null +++ b/server/cmd/api/api/chromium_configure.go @@ -0,0 +1,796 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/kernel/kernel-images/server/lib/logger" + oapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/kernel/kernel-images/server/lib/policy" + "github.com/kernel/kernel-images/server/lib/zstdutil" +) + +const userDataProfileDir = "/home/kernel/user-data" +const maxStartURLLen = 2048 +const startURLDispatchTimeout = 3 * time.Second + +type chromiumConfigureState struct { + displayJSON *string + chromiumFlagsJSON *string + chromePoliciesJSON *string + + stripComponents int + + profileTemp string // temp archive path + hasProfile bool + + startURLRaw *string + + extItems []extensionZipItem // zipTemp paths; merged with chromiumCfgParseExtensions + + allTemps []string +} + +func (st *chromiumConfigureState) cleanup() { + for _, p := range st.allTemps { + _ = os.Remove(p) + } +} + +// ChromiumConfigure batched Chromium/session configuration plus optional navigation. +func (s *ApiService) ChromiumConfigure(ctx context.Context, request oapi.ChromiumConfigureRequestObject) (resp oapi.ChromiumConfigureResponseObject, err error) { + start := time.Now() + + if request.Body == nil { + return cfg400("request body required"), nil + } + + st := &chromiumConfigureState{} + if parseErr := chromiumCfgParseMultipart(request.Body, st); parseErr != nil { + st.cleanup() + var cfgErr chromiumCfgParseError + if errors.As(parseErr, &cfgErr) && cfgErr.internal { + return cfg500Configure(parseErr.Error()), nil + } + return cfg400(parseErr.Error()), nil + } + defer st.cleanup() + + spec, msgs := chromiumStartURLSpec(st.startURLRaw) + if msgs != "" { + return cfg400(msgs), nil + } + + if cfgActionables(st)+cfgHasStartURLSpec(spec) == 0 { + return cfg400("no configuration fields provided"), nil + } + + needsStop := chromiumNeedsStopCycle(st) + chromiumStopped := false + restartAfterStop := func() error { + if !chromiumStopped { + return nil + } + if err := s.startChromiumAndWait(ctx, "batched chromium configure"); err != nil { + return err + } + chromiumStopped = false + return nil + } + defer func() { + if restartErr := restartAfterStop(); restartErr != nil { + if resp != nil { + logger.FromContext(ctx).Error("failed to restart chromium after configure error", "error", restartErr) + return + } + resp = cfg500ConfigureStep(chromiumConfigureStepStart, restartErr.Error()) + err = nil + } + }() + + if needsStop { + logger.FromContext(ctx).Info("chromium configure (stop/start path)") + if err := s.stopChromium(ctx); err != nil { + return cfg500ConfigureStep(chromiumConfigureStepStop, err.Error()), nil + } + chromiumStopped = true + + policyOverrides, err := chromiumValidatePolicies(st.chromePoliciesJSON) + if err != nil { + return cfgResponseFromStepError(chromiumConfigureStepPolicies, err), nil + } + if err := chromiumApplyPolicies(ctx, s, policyOverrides); err != nil { + return cfgResponseFromStepError(chromiumConfigureStepPolicies, err), nil + } + + if reqMsgs, ierr := chromiumApplyExtensions(ctx, s, st.extItems); reqMsgs != "" { + return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsgs)), nil + } else if ierr != nil { + return cfg500ConfigureStep(chromiumConfigureStepExtensions, ierr.Error()), nil + } + + if st.displayJSON != nil && strings.TrimSpace(*st.displayJSON) != "" { + displayPlan, displayResp := chromiumPrepareDisplay(ctx, s, st.displayJSON) + if displayResp != nil { + return displayResp, nil + } + if displayPlan != nil { + stopped, stopErr := s.stopActiveRecordings(ctx) + if stopErr != nil { + return cfg500ConfigureStep(chromiumConfigureStepDisplay, fmt.Sprintf("failed to stop recordings: %v", stopErr)), nil + } + if len(stopped) > 0 { + defer func() { + go s.startNewRecordingSegments(context.WithoutCancel(ctx), stopped) + }() + } + if rr := chromiumDisplayApplyWhileStopped(ctx, s, displayPlan); rr != nil { + return rr, nil + } + } + } + + flagsPlan, err := chromiumValidateFlags(st.chromiumFlagsJSON) + if err != nil { + return cfgResponseFromStepError(chromiumConfigureStepFlags, err), nil + } + if err := chromiumMergeFlags(ctx, s, flagsPlan); err != nil { + return cfgResponseFromStepError(chromiumConfigureStepFlags, err), nil + } + + if st.hasProfile { + preparedProfile, cleanupProfile, err := chromiumPrepareProfileArchive(st.profileTemp, st.stripComponents) + if cleanupProfile != nil { + defer cleanupProfile() + } + if err != nil { + return cfg500ConfigureStep(chromiumConfigureStepProfile, err.Error()), nil + } + if err := chromiumInstallPreparedProfile(preparedProfile); err != nil { + return cfg500ConfigureStep(chromiumConfigureStepProfile, err.Error()), nil + } + } + + if err := restartAfterStop(); err != nil { + return cfg500ConfigureStep(chromiumConfigureStepStart, err.Error()), nil + } + } else { + if st.displayJSON != nil && strings.TrimSpace(*st.displayJSON) != "" { + displayPlan, displayResp := chromiumPrepareDisplay(ctx, s, st.displayJSON) + if displayResp != nil { + return displayResp, nil + } + if displayPlan != nil { + if rr := chromiumRunPatchDisplay(ctx, s, displayPlan.body); rr != nil { + return rr, nil + } + } + } + } + + if spec.needsNav { + if err := chromiumDoNavigate(ctx, s, spec); err != nil { + logger.FromContext(ctx).Warn("start_url dispatch failed", "error", err) + } + } + + logger.FromContext(ctx).Info("chromium configure finished", "elapsed", time.Since(start).String()) + return oapi.ChromiumConfigure200JSONResponse{Ok: true}, nil +} + +type startURLParsed struct { + needsNav bool + url string +} + +type chromiumConfigureStep string + +const ( + chromiumConfigureStepStop chromiumConfigureStep = "stop_chromium" + chromiumConfigureStepStart chromiumConfigureStep = "start_chromium" + chromiumConfigureStepPolicies chromiumConfigureStep = "chrome_policies" + chromiumConfigureStepExtensions chromiumConfigureStep = "extensions" + chromiumConfigureStepDisplay chromiumConfigureStep = "display" + chromiumConfigureStepFlags chromiumConfigureStep = "chromium_flags" + chromiumConfigureStepProfile chromiumConfigureStep = "profile" +) + +func chromiumStartURLSpec(raw *string) (startURLParsed, string) { + var out startURLParsed + if raw == nil || strings.TrimSpace(*raw) == "" { + return out, "" + } + if len(*raw) > maxStartURLLen { + return out, fmt.Sprintf("start_url exceeds max length of %d bytes", maxStartURLLen) + } + out.url = normalizeStartURL(*raw) + out.needsNav = true + return out, "" +} + +func normalizeStartURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return rawURL + } + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme == "" || + strings.Contains(parsed.Scheme, ".") || + strings.EqualFold(parsed.Scheme, "localhost") { + return "https://" + rawURL + } + return rawURL +} + +func chromiumDoNavigate(ctx context.Context, s *ApiService, spec startURLParsed) error { + upstream := s.upstreamMgr.Current() + if upstream == "" { + return fmt.Errorf("devtools upstream not available") + } + navCtx, cancel := context.WithTimeout(ctx, startURLDispatchTimeout) + defer cancel() + return cdpclient.DispatchStartURL(navCtx, upstream, spec.url) +} + +type cfgBadRequestError struct { + message string +} + +func (e cfgBadRequestError) Error() string { + return e.message +} + +func cfgBadRequest(msg string) error { + return cfgBadRequestError{message: msg} +} + +type chromiumCfgParseError struct { + message string + internal bool +} + +func (e chromiumCfgParseError) Error() string { + return e.message +} + +func cfgParseBadRequest(msg string) error { + return chromiumCfgParseError{message: msg} +} + +func cfgParseInternal(msg string) error { + return chromiumCfgParseError{message: msg, internal: true} +} + +func cfgResponseFromStepError(step chromiumConfigureStep, err error) oapi.ChromiumConfigureResponseObject { + var bad cfgBadRequestError + if errors.As(err, &bad) { + return cfg400(fmt.Sprintf("%s: %s", step, bad.message)) + } + return cfg500ConfigureStep(step, err.Error()) +} + +type chromiumFlagsPlan struct { + flags []string +} + +type chromiumDisplayPlan struct { + body *oapi.PatchDisplayJSONRequestBody + width int + height int + refreshRate int +} + +func chromiumNeedsStopCycle(st *chromiumConfigureState) bool { + return st.hasProfile || + len(st.extItems) > 0 || + policiesContentNonEmpty(st.chromePoliciesJSON) || + flagsContentNonEmpty(st.chromiumFlagsJSON) +} + +func policiesContentNonEmpty(s *string) bool { + if !policiesNonEmpty(s) { + return false + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(strings.TrimSpace(*s)), &m); err != nil { + return true + } + return len(m) > 0 +} + +func flagsContentNonEmpty(s *string) bool { + if !flagsNonEmpty(s) { + return false + } + var raw struct { + Flags []string `json:"flags"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(*s)), &raw); err != nil { + return true + } + return len(raw.Flags) > 0 +} + +func policiesNonEmpty(s *string) bool { + return s != nil && strings.TrimSpace(*s) != "" +} + +func flagsNonEmpty(s *string) bool { + return s != nil && strings.TrimSpace(*s) != "" +} + +func cfg400(msg string) oapi.ChromiumConfigure400JSONResponse { + return oapi.ChromiumConfigure400JSONResponse{ + BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: msg}, + } +} + +func cfg500Configure(msg string) oapi.ChromiumConfigure500JSONResponse { + return oapi.ChromiumConfigure500JSONResponse(oapi.ChromiumConfigureError{ + Phase: oapi.ConfigurePhase, + Message: msg, + }) +} + +func cfg500ConfigureStep(step chromiumConfigureStep, msg string) oapi.ChromiumConfigure500JSONResponse { + stepValue := oapi.ChromiumConfigureErrorStep(step) + return oapi.ChromiumConfigure500JSONResponse(oapi.ChromiumConfigureError{ + Phase: oapi.ConfigurePhase, + Step: &stepValue, + Message: msg, + }) +} + +func cfgActionables(st *chromiumConfigureState) int { + n := 0 + if policiesContentNonEmpty(st.chromePoliciesJSON) { + n++ + } + if flagsContentNonEmpty(st.chromiumFlagsJSON) { + n++ + } + if len(st.extItems) > 0 { + n++ + } + if st.hasProfile { + n++ + } + if st.displayJSON != nil && strings.TrimSpace(*st.displayJSON) != "" { + n++ + } + return n +} + +func cfgHasStartURLSpec(spec startURLParsed) int { + if !spec.needsNav { + return 0 + } + return 1 +} + +func chromiumCfgParseMultipart(body interface{}, st *chromiumConfigureState) error { + mr, ok := any(body).(interface { + NextPart() (*multipart.Part, error) + }) + if !ok { + return cfgParseInternal("multipart reader not available") + } + + type pend struct { + zipTmp string + name string + gotZip bool + } + var cur *pend + var gotDisplay, gotChromiumFlags, gotChromePolicies, gotStripComponents, gotProfileArchive, gotStartURL bool + + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return cfgParseBadRequest("failed reading multipart") + } + switch name := part.FormName(); name { + case "display": + if gotDisplay { + return cfgParseBadRequest("duplicate display field") + } + gotDisplay = true + b, err := io.ReadAll(part) + if err != nil { + return cfgParseInternal("read display field") + } + v := strings.TrimSpace(string(b)) + st.displayJSON = &v + case "chromium_flags": + if gotChromiumFlags { + return cfgParseBadRequest("duplicate chromium_flags field") + } + gotChromiumFlags = true + b, err := io.ReadAll(part) + if err != nil { + return cfgParseInternal("read chromium_flags field") + } + v := string(b) + st.chromiumFlagsJSON = &v + case "chrome_policies": + if gotChromePolicies { + return cfgParseBadRequest("duplicate chrome_policies field") + } + gotChromePolicies = true + b, err := io.ReadAll(part) + if err != nil { + return cfgParseInternal("read chrome_policies field") + } + v := string(b) + st.chromePoliciesJSON = &v + case "strip_components": + if gotStripComponents { + return cfgParseBadRequest("duplicate strip_components field") + } + gotStripComponents = true + b, err := io.ReadAll(part) + if err != nil { + return cfgParseInternal("read strip_components") + } + n, err := strconv.Atoi(strings.TrimSpace(string(b))) + if err != nil || n < 0 { + return cfgParseBadRequest("strip_components must be a non-negative integer") + } + st.stripComponents = n + case "profile_archive": + if gotProfileArchive { + return cfgParseBadRequest("duplicate profile_archive field") + } + gotProfileArchive = true + tmp, err := os.CreateTemp("", "bcc-prof-*.tar.zst") + if err != nil { + return cfgParseInternal("temp profile_archive") + } + st.allTemps = append(st.allTemps, tmp.Name()) + if _, err := io.Copy(tmp, part); err != nil { + tmp.Close() + return cfgParseInternal("read profile_archive") + } + if err := tmp.Close(); err != nil { + return cfgParseInternal("finalize profile_archive") + } + st.profileTemp = tmp.Name() + st.hasProfile = true + case "start_url": + if gotStartURL { + return cfgParseBadRequest("duplicate start_url field") + } + gotStartURL = true + b, err := io.ReadAll(part) + if err != nil { + return cfgParseInternal("read start_url") + } + v := string(b) + st.startURLRaw = &v + case "extensions.zip_file": + if cur == nil { + cur = &pend{} + } + if cur.gotZip { + return cfgParseBadRequest("duplicate extensions.zip_file pair") + } + tmp, err := os.CreateTemp("", "bcc-ext-*.zip") + if err != nil { + return cfgParseInternal("temp extensions.zip_file") + } + st.allTemps = append(st.allTemps, tmp.Name()) + if _, err := io.Copy(tmp, part); err != nil { + tmp.Close() + return cfgParseInternal("read extensions.zip_file") + } + if err := tmp.Close(); err != nil { + return cfgParseInternal("close extensions.zip_file") + } + cur.zipTmp = tmp.Name() + cur.gotZip = true + case "extensions.name": + if cur == nil { + cur = &pend{} + } + b, err := io.ReadAll(part) + if err != nil { + return cfgParseInternal("read extensions.name") + } + nm := strings.TrimSpace(string(b)) + if nm == "" || !nameRegex.MatchString(nm) { + return cfgParseBadRequest("invalid extensions.name") + } + if cur.name != "" { + return cfgParseBadRequest("duplicate extensions.name in pair") + } + cur.name = nm + default: + return cfgParseBadRequest(fmt.Sprintf("unknown form field %q", name)) + } + if cur != nil && cur.gotZip && cur.name != "" { + st.extItems = append(st.extItems, extensionZipItem{zipTemp: cur.zipTmp, name: cur.name}) + cur = nil + } + } + if cur != nil && (!cur.gotZip || cur.name == "") { + return cfgParseBadRequest("each extension pair needs extensions.zip_file plus extensions.name") + } + return nil +} + +func chromiumPrepareProfileArchive(profilePath string, strip int) (preparedDir string, cleanup func(), err error) { + parent := filepath.Dir(userDataProfileDir) + if err := os.MkdirAll(parent, 0o755); err != nil { + return "", nil, fmt.Errorf("mkdir user-data parent: %w", err) + } + preparedDir, err = os.MkdirTemp(parent, ".user-data-new-*") + if err != nil { + return "", nil, fmt.Errorf("create temp user-data dir: %w", err) + } + cleanup = func() { + _ = os.RemoveAll(preparedDir) + } + + f, err := os.Open(profilePath) + if err != nil { + cleanup() + return "", nil, err + } + defer f.Close() + if err := zstdutil.UntarZstd(f, preparedDir, strip); err != nil { + cleanup() + return "", nil, fmt.Errorf("extract profile archive: %w", err) + } + out, err := exec.Command("chown", "-R", "kernel:kernel", preparedDir).CombinedOutput() + if err != nil { + cleanup() + return "", nil, fmt.Errorf("chown user-data: %w (%s)", err, string(out)) + } + return preparedDir, cleanup, nil +} + +func chromiumInstallPreparedProfile(preparedDir string) error { + if preparedDir == "" { + return nil + } + parent := filepath.Dir(userDataProfileDir) + backupDir := filepath.Join(parent, fmt.Sprintf(".user-data-old-%d", time.Now().UnixNano())) + hadExisting := false + + if _, err := os.Stat(userDataProfileDir); err == nil { + hadExisting = true + if err := os.Rename(userDataProfileDir, backupDir); err != nil { + return fmt.Errorf("backup user-data: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat user-data: %w", err) + } + + if err := os.Rename(preparedDir, userDataProfileDir); err != nil { + if hadExisting { + _ = os.Rename(backupDir, userDataProfileDir) + } + return fmt.Errorf("replace user-data: %w", err) + } + if hadExisting { + _ = os.RemoveAll(backupDir) + } + return nil +} + +func chromiumParseDisplayParts(displayJSON *string) (*oapi.PatchDisplayJSONRequestBody, string) { + if displayJSON == nil { + return nil, "" + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(*displayJSON), &raw); err != nil { + return nil, "invalid display JSON" + } + if len(raw) == 0 { + return nil, "display payload empty" + } + blob, err := json.Marshal(raw) + if err != nil { + return nil, "invalid display marshal" + } + var body oapi.PatchDisplayJSONRequestBody + if err := json.Unmarshal(blob, &body); err != nil { + return nil, fmt.Sprintf("invalid display payload: %v", err) + } + return &body, "" +} + +func chromiumPrepareDisplay(ctx context.Context, s *ApiService, displayJSON *string) (*chromiumDisplayPlan, oapi.ChromiumConfigureResponseObject) { + if displayJSON == nil { + return nil, nil + } + body, msgs := chromiumParseDisplayParts(displayJSON) + if msgs != "" { + return nil, cfg400(msgs) + } + if body.Width == nil && body.Height == nil { + return nil, cfg400("no display parameters to update") + } + + currentWidth, currentHeight, currentRefreshRate, err := s.getCurrentResolution(ctx) + if err != nil { + logger.FromContext(ctx).Error("failed to get current resolution", "error", err) + return nil, cfg500ConfigureStep(chromiumConfigureStepDisplay, "failed to get current display resolution") + } + width, height, refreshRate := currentWidth, currentHeight, currentRefreshRate + if body.Width != nil { + width = *body.Width + } + if body.Height != nil { + height = *body.Height + } + if body.RefreshRate != nil { + refreshRate = int(*body.RefreshRate) + } + + if width <= 0 || height <= 0 { + return nil, cfg400("invalid width/height") + } + + requireIdle := true + if body.RequireIdle != nil { + requireIdle = *body.RequireIdle + } + if requireIdle { + live := s.getActiveNekoSessions(ctx) + isRecording := s.anyRecordingActive(ctx) + if live != 0 || isRecording { + return nil, oapi.ChromiumConfigure409JSONResponse{ + ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{ + Message: "resize refused: live view or recording/replay active", + }, + } + } + } + + return &chromiumDisplayPlan{ + body: body, + width: width, + height: height, + refreshRate: refreshRate, + }, nil +} + +func chromiumDisplayApplyWhileStopped(ctx context.Context, s *ApiService, plan *chromiumDisplayPlan) oapi.ChromiumConfigureResponseObject { + w, h := plan.width, plan.height + if w <= 0 || h <= 0 { + return cfg400("display width and height must be positive") + } + mode := s.detectDisplayMode(ctx) + rr := plan.refreshRate + if mode == "xvfb" { + s.xvfbResizeMu.Lock() + err := s.resizeXvfb(ctx, w, h) + if err == nil { + s.clearViewportOverride() + } + s.xvfbResizeMu.Unlock() + if err != nil { + return cfg500ConfigureStep(chromiumConfigureStepDisplay, err.Error()) + } + return nil + } + var err error + if s.isNekoEnabled() { + err = s.setResolutionViaNeko(ctx, w, h, rr) + } else { + err = s.setResolutionXorgViaXrandr(ctx, w, h, rr, false) + } + if err != nil { + return cfg500ConfigureStep(chromiumConfigureStepDisplay, err.Error()) + } + return nil +} + +func chromiumRunPatchDisplay(ctx context.Context, s *ApiService, body *oapi.PatchDisplayJSONRequestBody) oapi.ChromiumConfigureResponseObject { + resp, err := s.PatchDisplay(ctx, oapi.PatchDisplayRequestObject{Body: body}) + if err != nil { + return cfg500ConfigureStep(chromiumConfigureStepDisplay, err.Error()) + } + switch r := resp.(type) { + case oapi.PatchDisplay200JSONResponse: + return nil + case oapi.PatchDisplay400JSONResponse: + return cfg400(r.Message) + case oapi.PatchDisplay409JSONResponse: + return oapi.ChromiumConfigure409JSONResponse{ConflictErrorJSONResponse: r.ConflictErrorJSONResponse} + case oapi.PatchDisplay500JSONResponse: + return cfg500ConfigureStep(chromiumConfigureStepDisplay, r.Message) + default: + return cfg500ConfigureStep(chromiumConfigureStepDisplay, "unexpected PatchDisplay response") + } +} + +func chromiumValidatePolicies(raw *string) (policy.ChromiumPolicyOverrides, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil, nil + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(*raw), &m); err != nil { + return nil, cfgBadRequest("invalid chrome_policies JSON") + } + if len(m) == 0 { + return nil, nil + } + overrides, err := policy.NewChromiumPolicyOverrides(m) + if err != nil { + return nil, cfgBadRequest(err.Error()) + } + if err := overrides.Validate(); err != nil { + return nil, cfgBadRequest(err.Error()) + } + return overrides, nil +} + +func chromiumApplyPolicies(ctx context.Context, s *ApiService, overrides policy.ChromiumPolicyOverrides) error { + if len(overrides) == 0 { + return nil + } + if err := s.policy.ApplyOverrides(overrides); err != nil { + if strings.Contains(err.Error(), "cannot be overridden") || strings.Contains(err.Error(), "invalid chromium policy overrides") { + return cfgBadRequest(err.Error()) + } + return err + } + return nil +} + +func chromiumApplyExtensions(ctx context.Context, s *ApiService, items []extensionZipItem) (string, error) { + if len(items) == 0 { + return "", nil + } + return s.applyExtensionZipItems(ctx, items) +} + +func chromiumValidateFlags(raw *string) (*chromiumFlagsPlan, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil, nil + } + var body struct { + Flags []string `json:"flags"` + } + if err := json.Unmarshal([]byte(*raw), &body); err != nil { + return nil, cfgBadRequest("invalid chromium_flags JSON") + } + if len(body.Flags) == 0 { + return nil, nil + } + for _, flag := range body.Flags { + t := strings.TrimSpace(flag) + if t == "" { + return nil, cfgBadRequest("empty flag in chromium_flags") + } + if !strings.HasPrefix(t, "--") { + return nil, cfgBadRequest(fmt.Sprintf("invalid flag format: %s (must start with --)", flag)) + } + } + return &chromiumFlagsPlan{flags: body.Flags}, nil +} + +func chromiumMergeFlags(ctx context.Context, s *ApiService, plan *chromiumFlagsPlan) error { + if plan == nil { + return nil + } + _, err := s.mergeAndWriteChromiumFlags(ctx, plan.flags) + return err +} diff --git a/server/cmd/api/api/chromium_configure_test.go b/server/cmd/api/api/chromium_configure_test.go new file mode 100644 index 00000000..e4f44f04 --- /dev/null +++ b/server/cmd/api/api/chromium_configure_test.go @@ -0,0 +1,233 @@ +package api + +import ( + "bytes" + "errors" + "io" + "mime/multipart" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFlagsContentNonEmpty(t *testing.T) { + emptyArr := `{}` + fl := `{"flags":[]}` + real := `{"flags":["--kiosk"]}` + require.False(t, flagsContentNonEmpty(&emptyArr)) + require.False(t, flagsContentNonEmpty(&fl)) + require.True(t, flagsContentNonEmpty(&real)) +} + +func TestPoliciesContentNonEmpty(t *testing.T) { + emptyObj := `{}` + real := `{"DefaultCookiesSetting": 1}` + require.False(t, policiesContentNonEmpty(&emptyObj)) + require.True(t, policiesContentNonEmpty(&real)) +} + +func TestChromiumConfigureActionableFlags(t *testing.T) { + emptyFlags := `{"flags":[]}` + realFlags := `{"flags":["--kiosk"]}` + + st := &chromiumConfigureState{chromiumFlagsJSON: &emptyFlags} + require.Equal(t, 0, cfgActionables(st)) + require.False(t, chromiumNeedsStopCycle(st)) + + st = &chromiumConfigureState{chromiumFlagsJSON: &realFlags} + require.Equal(t, 1, cfgActionables(st)) + require.True(t, chromiumNeedsStopCycle(st)) +} + +func TestChromiumConfigureActionablePolicies(t *testing.T) { + emptyPolicies := `{}` + realPolicies := `{"QuicAllowed":false}` + + st := &chromiumConfigureState{chromePoliciesJSON: &emptyPolicies} + require.Equal(t, 0, cfgActionables(st)) + require.False(t, chromiumNeedsStopCycle(st)) + + st = &chromiumConfigureState{chromePoliciesJSON: &realPolicies} + require.Equal(t, 1, cfgActionables(st)) + require.True(t, chromiumNeedsStopCycle(st)) +} + +func TestChromiumStartURLSpec(t *testing.T) { + bareHost := "roblox.com" + out, errs := chromiumStartURLSpec(&bareHost) + require.Empty(t, errs) + require.True(t, out.needsNav) + require.Equal(t, "https://roblox.com", out.url) + + plain := "https://example.com/" + out, errs = chromiumStartURLSpec(&plain) + require.Empty(t, errs) + require.True(t, out.needsNav) + require.Equal(t, plain, out.url) + + fileURL := "file:///etc/passwd" + out, errs = chromiumStartURLSpec(&fileURL) + require.Empty(t, errs) + require.Equal(t, fileURL, out.url) + + longURL := strings.Repeat("a", maxStartURLLen+1) + _, errs = chromiumStartURLSpec(&longURL) + require.NotEmpty(t, errs) +} + +func TestChromiumValidateFlags(t *testing.T) { + valid := `{"flags":["--kiosk"]}` + plan, err := chromiumValidateFlags(&valid) + require.NoError(t, err) + require.Equal(t, []string{"--kiosk"}, plan.flags) + + empty := `{"flags":[]}` + plan, err = chromiumValidateFlags(&empty) + require.NoError(t, err) + require.Nil(t, plan) + + cases := []string{ + `{bad-json`, + `{"flags":[""]}`, + `{"flags":["kiosk"]}`, + } + for _, tc := range cases { + _, err := chromiumValidateFlags(&tc) + require.Error(t, err, "case %s", tc) + var bad cfgBadRequestError + require.ErrorAs(t, err, &bad) + } +} + +func TestChromiumValidatePoliciesBadRequest(t *testing.T) { + blocked := `{"ExtensionSettings":{}}` + _, err := chromiumValidatePolicies(&blocked) + require.Error(t, err) + var bad cfgBadRequestError + require.ErrorAs(t, err, &bad) +} + +func TestChromiumParseDisplayPartsValidation(t *testing.T) { + badJSON := `{bad-json` + _, msg := chromiumParseDisplayParts(&badJSON) + require.Equal(t, "invalid display JSON", msg) + + empty := `{}` + _, msg = chromiumParseDisplayParts(&empty) + require.Equal(t, "display payload empty", msg) +} + +func TestChromiumCfgParseMultipart(t *testing.T) { + buf := bytes.NewBuffer(nil) + w := multipart.NewWriter(buf) + + require.NoError(t, w.WriteField("chrome_policies", `{"HttpsUpgradesEnabled":false}`)) + require.NoError(t, w.WriteField("strip_components", "2")) + require.NoError(t, w.WriteField("start_url", "https://kernel.example/route")) + + require.NoError(t, w.Close()) + + br := multipart.NewReader(buf, w.Boundary()) + st := &chromiumConfigureState{} + err := chromiumCfgParseMultipart(br, st) + defer st.cleanup() + require.NoError(t, err) + + require.True(t, policiesContentNonEmpty(st.chromePoliciesJSON)) + require.Equal(t, 2, st.stripComponents) + require.NotNil(t, st.startURLRaw) + require.Equal(t, "https://kernel.example/route", strings.TrimSpace(*st.startURLRaw)) +} + +func TestChromiumCfgParseMultipartValidation(t *testing.T) { + cases := []struct { + name string + build func(*testing.T, *multipart.Writer) + want string + }{ + { + name: "invalid strip_components", + build: func(t *testing.T, w *multipart.Writer) { + t.Helper() + require.NoError(t, w.WriteField("strip_components", "-1")) + }, + want: "strip_components must be a non-negative integer", + }, + { + name: "duplicate scalar", + build: func(t *testing.T, w *multipart.Writer) { + t.Helper() + require.NoError(t, w.WriteField("start_url", "https://a.example")) + require.NoError(t, w.WriteField("start_url", "https://b.example")) + }, + want: "duplicate start_url field", + }, + { + name: "incomplete extension pair", + build: func(t *testing.T, w *multipart.Writer) { + t.Helper() + require.NoError(t, w.WriteField("extensions.name", "missingzip")) + }, + want: "each extension pair needs extensions.zip_file plus extensions.name", + }, + { + name: "duplicate extension zip", + build: func(t *testing.T, w *multipart.Writer) { + t.Helper() + part, err := w.CreateFormFile("extensions.zip_file", "one.zip") + require.NoError(t, err) + _, err = io.WriteString(part, "first") + require.NoError(t, err) + part, err = w.CreateFormFile("extensions.zip_file", "two.zip") + require.NoError(t, err) + _, err = io.WriteString(part, "second") + require.NoError(t, err) + }, + want: "duplicate extensions.zip_file pair", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + buf := bytes.NewBuffer(nil) + w := multipart.NewWriter(buf) + tc.build(t, w) + require.NoError(t, w.Close()) + + st := &chromiumConfigureState{} + err := chromiumCfgParseMultipart(multipart.NewReader(buf, w.Boundary()), st) + defer st.cleanup() + require.EqualError(t, err, tc.want) + var parseErr chromiumCfgParseError + require.True(t, errors.As(err, &parseErr)) + require.False(t, parseErr.internal) + }) + } +} + +func TestChromiumCfgParseMultipartMultipleExtensionPairs(t *testing.T) { + buf := bytes.NewBuffer(nil) + w := multipart.NewWriter(buf) + + part, err := w.CreateFormFile("extensions.zip_file", "one.zip") + require.NoError(t, err) + _, err = io.WriteString(part, "not validated by parser") + require.NoError(t, err) + require.NoError(t, w.WriteField("extensions.name", "one")) + + require.NoError(t, w.WriteField("extensions.name", "two")) + part, err = w.CreateFormFile("extensions.zip_file", "two.zip") + require.NoError(t, err) + _, err = io.WriteString(part, "not validated by parser") + require.NoError(t, err) + require.NoError(t, w.Close()) + + st := &chromiumConfigureState{} + err = chromiumCfgParseMultipart(multipart.NewReader(buf, w.Boundary()), st) + defer st.cleanup() + require.NoError(t, err) + require.Len(t, st.extItems, 2) + require.Equal(t, "one", st.extItems[0].name) + require.Equal(t, "two", st.extItems[1].name) +} diff --git a/server/e2e/e2e_chromium_configure_powerset_test.go b/server/e2e/e2e_chromium_configure_powerset_test.go new file mode 100644 index 00000000..eda65b54 --- /dev/null +++ b/server/e2e/e2e_chromium_configure_powerset_test.go @@ -0,0 +1,205 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +const ( + matDisplay = 1 << iota + matPolicy + matKioskFlags + matExtension + matStartURL + + matMaxBitmask = matDisplay | matPolicy | matKioskFlags | matExtension | matStartURL // 31 +) + +// TestChromiumConfigureMultipartPowerset runs a representative matrix by default. +// Set E2E_CHROMIUM_CONFIGURE_POWERSET=1 to run every non-empty combination. +func TestChromiumConfigureMultipartPowerset(t *testing.T) { + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + extDir, err := filepath.Abs("test-extension") + require.NoError(t, err) + extZip, err := zipDirToBytes(extDir) + require.NoError(t, err) + + matrix := []int{ + matDisplay, + matPolicy | matKioskFlags, + matExtension, + matDisplay | matPolicy | matKioskFlags | matExtension | matStartURL, + } + if os.Getenv("E2E_CHROMIUM_CONFIGURE_POWERSET") == "1" { + matrix = matrix[:0] + for bits := 1; bits <= matMaxBitmask; bits++ { + matrix = append(matrix, bits) + } + } + + for _, bits := range matrix { + bits := bits + t.Run(chromiumConfigurePowersetLabel(bits), func(t *testing.T) { + + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{ + "WIDTH": "1024", + "HEIGHT": "768", + }, + }), "failed to start container") + defer func() { _ = c.Stop(context.WithoutCancel(ctx)) }() + + require.NoError(t, c.WaitReady(ctx)) + + var body bytes.Buffer + w := multipart.NewWriter(&body) + require.NoError(t, chromiumConfigurePowersetPopulate(t, w, bits, extZip)) + require.NoError(t, w.Close()) + + client, err := c.APIClient() + require.NoError(t, err) + + rsp, err := client.ChromiumConfigureWithBodyWithResponse(ctx, w.FormDataContentType(), io.NopCloser(bytes.NewReader(body.Bytes()))) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, rsp.StatusCode(), + "bits=%02x unexpected status=%s body=%s", bits, rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200, "want ok JSON") + require.True(t, rsp.JSON200.Ok) + }) + } +} + +func chromiumConfigurePowersetLabel(bits int) string { + var p []string + if bits&matDisplay != 0 { + p = append(p, "display") + } + if bits&matPolicy != 0 { + p = append(p, "policy") + } + if bits&matKioskFlags != 0 { + p = append(p, "kiosk") + } + if bits&matExtension != 0 { + p = append(p, "ext") + } + if bits&matStartURL != 0 { + p = append(p, "nav") + } + return strings.Join(p, "+") +} + +func chromiumConfigurePowersetPopulate(t *testing.T, w *multipart.Writer, bits int, extZip []byte) error { + t.Helper() + + if bits&matDisplay != 0 { + restart := true + requireIdle := true + disp := instanceoapi.PatchDisplayJSONRequestBody{ + Width: intPtr(1280), + Height: intPtr(720), + RestartChromium: &restart, + RequireIdle: &requireIdle, + } + blob, err := json.Marshal(disp) + require.NoError(t, err) + if err := w.WriteField("display", string(blob)); err != nil { + return err + } + } + + if bits&matPolicy != 0 { + // QuicAllowed false is benign and allowed by server policy registry / overrides validation. + pol := map[string]interface{}{"QuicAllowed": false} + blob, err := json.Marshal(pol) + require.NoError(t, err) + if err := w.WriteField("chrome_policies", string(blob)); err != nil { + return err + } + } + + if bits&matKioskFlags != 0 { + fl := instanceoapi.PatchChromiumFlagsJSONBody{Flags: []string{"--kiosk"}} + blob, err := json.Marshal(fl) + require.NoError(t, err) + if err := w.WriteField("chromium_flags", string(blob)); err != nil { + return err + } + } + + if bits&matExtension != 0 { + part, err := w.CreateFormFile("extensions.zip_file", "powerset-ext.zip") + if err != nil { + return err + } + if _, err := io.Copy(part, bytes.NewReader(extZip)); err != nil { + return err + } + if err := w.WriteField("extensions.name", "powerset"); err != nil { + return err + } + } + + if bits&matStartURL != 0 { + if err := w.WriteField("start_url", `data:text/html,configure`); err != nil { + return err + } + } + return nil +} + +func intPtr(i int) *int { return &i } + +// TestChromiumConfigureStartURLBareHost exercises Kernel-compatible bare host normalization. +func TestChromiumConfigureStartURLBareHost(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{"WIDTH": "1024", "HEIGHT": "768"}, + })) + defer c.Stop(ctx) + require.NoError(t, c.WaitReady(ctx)) + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + require.NoError(t, mw.WriteField("start_url", "example.com")) + require.NoError(t, mw.Close()) + + client, err := c.APIClient() + require.NoError(t, err) + + rsp, err := client.ChromiumConfigureWithBodyWithResponse(ctx, mw.FormDataContentType(), io.NopCloser(bytes.NewReader(buf.Bytes()))) + require.NoError(t, err) + require.Equal(t, http.StatusOK, rsp.StatusCode(), "%s", string(rsp.Body)) + require.True(t, rsp.JSON200.Ok) +} diff --git a/server/e2e/e2e_chromium_configure_test.go b/server/e2e/e2e_chromium_configure_test.go new file mode 100644 index 00000000..282771a5 --- /dev/null +++ b/server/e2e/e2e_chromium_configure_test.go @@ -0,0 +1,66 @@ +package e2e + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "os/exec" + "testing" + "time" + + instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestChromiumConfigureStartURLBare(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{ + "WIDTH": "1024", + "HEIGHT": "768", + }, + }), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx)) + + client, err := c.APIClient() + require.NoError(t, err) + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + startURL := `data:text/html,kernel-configure` + require.NoError(t, w.WriteField("start_url", startURL)) + require.NoError(t, w.Close()) + + rsp, err := client.ChromiumConfigureWithBodyWithResponse(ctx, w.FormDataContentType(), io.NopCloser(&buf)) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status=%s body=%s", rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200, "want ok json") + require.True(t, rsp.JSON200.Ok) + + require.Eventually(t, func() bool { + timeoutSec := 3 + pwResp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightRequest{ + Code: "return page.url();", + TimeoutSec: &timeoutSec, + }) + if err != nil || pwResp.JSON200 == nil || !pwResp.JSON200.Success { + return false + } + got, ok := pwResp.JSON200.Result.(string) + return ok && got == startURL + }, 10*time.Second, 250*time.Millisecond) +} diff --git a/server/lib/cdpclient/cdpclient.go b/server/lib/cdpclient/cdpclient.go index eff95a00..a33b6319 100644 --- a/server/lib/cdpclient/cdpclient.go +++ b/server/lib/cdpclient/cdpclient.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sync/atomic" + "time" "github.com/coder/websocket" ) @@ -34,7 +35,7 @@ func (e *cdpError) Error() string { // Client is a minimal CDP client that communicates over a browser-level // DevTools WebSocket connection. type Client struct { - conn *websocket.Conn + conn *websocket.Conn nextID atomic.Int64 } @@ -98,6 +99,88 @@ func (c *Client) send(ctx context.Context, method string, params any, sessionID } } +// DispatchStartURL closes extra page targets and dispatches a navigation on the +// first page target. It does not wait for lifecycle events; Chrome owns the +// eventual navigation result. +func DispatchStartURL(ctx context.Context, devtoolsURL, url string) error { + c, err := Dial(ctx, devtoolsURL) + if err != nil { + return fmt.Errorf("dial devtools: %w", err) + } + defer c.Close() + + targetsResult, err := c.send(ctx, "Target.getTargets", nil, "") + if err != nil { + return fmt.Errorf("Target.getTargets: %w", err) + } + + var targets struct { + TargetInfos []struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + } `json:"targetInfos"` + } + if err := json.Unmarshal(targetsResult, &targets); err != nil { + return fmt.Errorf("unmarshal targets: %w", err) + } + + var pageTargetID string + for _, t := range targets.TargetInfos { + if t.Type != "page" { + continue + } + if pageTargetID == "" { + pageTargetID = t.TargetID + continue + } + _, _ = c.send(ctx, "Target.closeTarget", map[string]any{ + "targetId": t.TargetID, + }, "") + } + if pageTargetID == "" { + createResult, err := c.send(ctx, "Target.createTarget", map[string]any{ + "url": "about:blank", + }, "") + if err != nil { + return fmt.Errorf("Target.createTarget: %w", err) + } + var created struct { + TargetID string `json:"targetId"` + } + if err := json.Unmarshal(createResult, &created); err != nil { + return fmt.Errorf("unmarshal create target: %w", err) + } + pageTargetID = created.TargetID + } + + attachResult, err := c.send(ctx, "Target.attachToTarget", map[string]any{ + "targetId": pageTargetID, + "flatten": true, + }, "") + if err != nil { + return fmt.Errorf("Target.attachToTarget: %w", err) + } + + var attach struct { + SessionID string `json:"sessionId"` + } + if err := json.Unmarshal(attachResult, &attach); err != nil { + return fmt.Errorf("unmarshal attach: %w", err) + } + defer func() { + detachCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _ = c.send(detachCtx, "Target.detachFromTarget", map[string]any{ + "sessionId": attach.SessionID, + }, "") + }() + + if _, err := c.send(ctx, "Page.navigate", map[string]any{"url": url}, attach.SessionID); err != nil { + return fmt.Errorf("Page.navigate: %w", err) + } + return nil +} + // SetDeviceMetricsOverride sets the viewport dimensions on the first page // target found in the browser. It attaches to the target with a flattened // session, sends Emulation.setDeviceMetricsOverride, then detaches. @@ -144,10 +227,10 @@ func (c *Client) SetDeviceMetricsOverride(ctx context.Context, width, height int } _, err = c.send(ctx, "Emulation.setDeviceMetricsOverride", map[string]any{ - "width": width, - "height": height, + "width": width, + "height": height, "deviceScaleFactor": 1, - "mobile": false, + "mobile": false, }, attach.SessionID) if err != nil { return fmt.Errorf("Emulation.setDeviceMetricsOverride: %w", err) diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 2269d704..c720f8b3 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -437,6 +437,57 @@ func (e BrowserTargetType) Valid() bool { } } +// Defines values for ChromiumConfigureErrorPhase. +const ( + ConfigurePhase ChromiumConfigureErrorPhase = "configure_phase" + NavigatePhase ChromiumConfigureErrorPhase = "navigate_phase" +) + +// Valid indicates whether the value is a known member of the ChromiumConfigureErrorPhase enum. +func (e ChromiumConfigureErrorPhase) Valid() bool { + switch e { + case ConfigurePhase: + return true + case NavigatePhase: + return true + default: + return false + } +} + +// Defines values for ChromiumConfigureErrorStep. +const ( + ChromePolicies ChromiumConfigureErrorStep = "chrome_policies" + ChromiumFlags ChromiumConfigureErrorStep = "chromium_flags" + Display ChromiumConfigureErrorStep = "display" + Extensions ChromiumConfigureErrorStep = "extensions" + Profile ChromiumConfigureErrorStep = "profile" + StartChromium ChromiumConfigureErrorStep = "start_chromium" + StopChromium ChromiumConfigureErrorStep = "stop_chromium" +) + +// Valid indicates whether the value is a known member of the ChromiumConfigureErrorStep enum. +func (e ChromiumConfigureErrorStep) Valid() bool { + switch e { + case ChromePolicies: + return true + case ChromiumFlags: + return true + case Display: + return true + case Extensions: + return true + case Profile: + return true + case StartChromium: + return true + case StopChromium: + return true + default: + return false + } +} + // Defines values for ClickMouseRequestButton. const ( ClickMouseRequestButtonBack ClickMouseRequestButton = "back" @@ -1817,6 +1868,23 @@ type BrowserTelemetryConfig struct { Browser *BrowserTelemetryCategoriesConfig `json:"browser,omitempty"` } +// ChromiumConfigureError Failure from batched chromium configure — includes which phase failed. +type ChromiumConfigureError struct { + Message string `json:"message"` + + // Phase configure_phase maps to restart/filesystem/policy/extension/profile/display work; navigate_phase is retained for compatibility. + Phase ChromiumConfigureErrorPhase `json:"phase"` + + // Step Optional configure step that failed. + Step *ChromiumConfigureErrorStep `json:"step,omitempty"` +} + +// ChromiumConfigureErrorPhase configure_phase maps to restart/filesystem/policy/extension/profile/display work; navigate_phase is retained for compatibility. +type ChromiumConfigureErrorPhase string + +// ChromiumConfigureErrorStep Optional configure step that failed. +type ChromiumConfigureErrorStep string + // ClickMouseRequest defines model for ClickMouseRequest. type ClickMouseRequest struct { // Button Mouse button to interact with @@ -2486,6 +2554,33 @@ type UploadExtensionsAndRestartMultipartBody struct { } `json:"extensions"` } +// ChromiumConfigureMultipartBody defines parameters for ChromiumConfigure. +type ChromiumConfigureMultipartBody struct { + // ChromePolicies UTF-8 JSON policy override map — same semantics as PATCH /chromium/policies. + ChromePolicies *string `json:"chrome_policies,omitempty"` + + // ChromiumFlags UTF-8 JSON object `{"flags":["--kiosk"]}` — same semantics as PATCH /chromium/flags. + ChromiumFlags *string `json:"chromium_flags,omitempty"` + + // Display UTF-8 JSON object matching `#/components/schemas/PatchDisplayRequest` (width/height/etc.). When combined with restart-triggering fields, the resize is applied while Chromium is stopped and Chromium is started once at the end. + Display *string `json:"display,omitempty"` + + // Extensions Extension zips paired with consecutive extensions.name fields (same as upload-extensions-and-restart). + Extensions *[]struct { + Name string `json:"name"` + ZipFile openapi_types.File `json:"zip_file"` + } `json:"extensions,omitempty"` + + // ProfileArchive tar.zst archive containing the desired `/home/kernel/user-data` profile contents. Prefer archives whose root entries are the profile files/directories themselves (for example `Default/Preferences`). Use `strip_components` only when uploading an archive that includes leading wrapper directories. + ProfileArchive *openapi_types.File `json:"profile_archive,omitempty"` + + // StartUrl 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. + StartUrl *string `json:"start_url,omitempty"` + + // StripComponents Optional number of leading path components to strip from profile_archive entries (non-negative integer as text). + StripComponents *string `json:"strip_components,omitempty"` +} + // DownloadDirZipParams defines parameters for DownloadDirZip. type DownloadDirZipParams struct { // Path Absolute directory path to archive and download. @@ -2629,6 +2724,9 @@ type ScrollJSONRequestBody = ScrollRequest // TypeTextJSONRequestBody defines body for TypeText for application/json ContentType. type TypeTextJSONRequestBody = TypeTextRequest +// ChromiumConfigureMultipartRequestBody defines body for ChromiumConfigure for multipart/form-data ContentType. +type ChromiumConfigureMultipartRequestBody ChromiumConfigureMultipartBody + // PatchDisplayJSONRequestBody defines body for PatchDisplay for application/json ContentType. type PatchDisplayJSONRequestBody = PatchDisplayRequest @@ -3526,6 +3624,9 @@ type ClientInterface interface { TypeText(ctx context.Context, body TypeTextJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ChromiumConfigureWithBody request with any body + ChromiumConfigureWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // PatchDisplayWithBody request with any body PatchDisplayWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -4006,6 +4107,18 @@ func (c *Client) TypeText(ctx context.Context, body TypeTextJSONRequestBody, req return c.Client.Do(req) } +func (c *Client) ChromiumConfigureWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewChromiumConfigureRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) PatchDisplayWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewPatchDisplayRequestWithBody(c.Server, contentType, body) if err != nil { @@ -5265,6 +5378,35 @@ func NewTypeTextRequestWithBody(server string, contentType string, body io.Reade return req, nil } +// NewChromiumConfigureRequestWithBody generates requests for ChromiumConfigure with any type of body +func NewChromiumConfigureRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/configure") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewPatchDisplayRequest calls the generic PatchDisplay builder with application/json body func NewPatchDisplayRequest(server string, body PatchDisplayJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -6977,6 +7119,9 @@ type ClientWithResponsesInterface interface { TypeTextWithResponse(ctx context.Context, body TypeTextJSONRequestBody, reqEditors ...RequestEditorFn) (*TypeTextResponse, error) + // ChromiumConfigureWithBodyWithResponse request with any body + ChromiumConfigureWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ChromiumConfigureResponse, error) + // PatchDisplayWithBodyWithResponse request with any body PatchDisplayWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchDisplayResponse, error) @@ -7479,6 +7624,31 @@ func (r TypeTextResponse) StatusCode() int { return 0 } +type ChromiumConfigureResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *OkResponse + JSON400 *BadRequestError + JSON409 *ConflictError + JSON500 *ChromiumConfigureError +} + +// Status returns HTTPResponse.Status +func (r ChromiumConfigureResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ChromiumConfigureResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type PatchDisplayResponse struct { Body []byte HTTPResponse *http.Response @@ -8644,6 +8814,15 @@ func (c *ClientWithResponses) TypeTextWithResponse(ctx context.Context, body Typ return ParseTypeTextResponse(rsp) } +// ChromiumConfigureWithBodyWithResponse request with arbitrary body returning *ChromiumConfigureResponse +func (c *ClientWithResponses) ChromiumConfigureWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ChromiumConfigureResponse, error) { + rsp, err := c.ChromiumConfigureWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseChromiumConfigureResponse(rsp) +} + // PatchDisplayWithBodyWithResponse request with arbitrary body returning *PatchDisplayResponse func (c *ClientWithResponses) PatchDisplayWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchDisplayResponse, error) { rsp, err := c.PatchDisplayWithBody(ctx, contentType, body, reqEditors...) @@ -9649,6 +9828,53 @@ func ParseTypeTextResponse(rsp *http.Response) (*TypeTextResponse, error) { return response, nil } +// ParseChromiumConfigureResponse parses an HTTP response from a ChromiumConfigureWithResponse call +func ParseChromiumConfigureResponse(rsp *http.Response) (*ChromiumConfigureResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ChromiumConfigureResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest OkResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ConflictError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ChromiumConfigureError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParsePatchDisplayResponse parses an HTTP response from a PatchDisplayWithResponse call func ParsePatchDisplayResponse(rsp *http.Response) (*PatchDisplayResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -11218,6 +11444,9 @@ type ServerInterface interface { // Type text on the host computer // (POST /computer/type) TypeText(w http.ResponseWriter, r *http.Request) + // Apply batched session and browser configuration plus optional navigation + // (POST /configure) + ChromiumConfigure(w http.ResponseWriter, r *http.Request) // Update display configuration // (PATCH /display) PatchDisplay(w http.ResponseWriter, r *http.Request) @@ -11431,6 +11660,12 @@ func (_ Unimplemented) TypeText(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Apply batched session and browser configuration plus optional navigation +// (POST /configure) +func (_ Unimplemented) ChromiumConfigure(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Update display configuration // (PATCH /display) func (_ Unimplemented) PatchDisplay(w http.ResponseWriter, r *http.Request) { @@ -11884,6 +12119,20 @@ func (siw *ServerInterfaceWrapper) TypeText(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// ChromiumConfigure operation middleware +func (siw *ServerInterfaceWrapper) ChromiumConfigure(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ChromiumConfigure(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // PatchDisplay operation middleware func (siw *ServerInterfaceWrapper) PatchDisplay(w http.ResponseWriter, r *http.Request) { @@ -12884,6 +13133,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/computer/type", wrapper.TypeText) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/configure", wrapper.ChromiumConfigure) + }) r.Group(func(r chi.Router) { r.Patch(options.BaseURL+"/display", wrapper.PatchDisplay) }) @@ -13517,6 +13769,50 @@ func (response TypeText500JSONResponse) VisitTypeTextResponse(w http.ResponseWri return json.NewEncoder(w).Encode(response) } +type ChromiumConfigureRequestObject struct { + Body *multipart.Reader +} + +type ChromiumConfigureResponseObject interface { + VisitChromiumConfigureResponse(w http.ResponseWriter) error +} + +type ChromiumConfigure200JSONResponse OkResponse + +func (response ChromiumConfigure200JSONResponse) VisitChromiumConfigureResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ChromiumConfigure400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response ChromiumConfigure400JSONResponse) VisitChromiumConfigureResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type ChromiumConfigure409JSONResponse struct{ ConflictErrorJSONResponse } + +func (response ChromiumConfigure409JSONResponse) VisitChromiumConfigureResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type ChromiumConfigure500JSONResponse ChromiumConfigureError + +func (response ChromiumConfigure500JSONResponse) VisitChromiumConfigureResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type PatchDisplayRequestObject struct { Body *PatchDisplayJSONRequestBody } @@ -15301,6 +15597,9 @@ type StrictServerInterface interface { // Type text on the host computer // (POST /computer/type) TypeText(ctx context.Context, request TypeTextRequestObject) (TypeTextResponseObject, error) + // Apply batched session and browser configuration plus optional navigation + // (POST /configure) + ChromiumConfigure(ctx context.Context, request ChromiumConfigureRequestObject) (ChromiumConfigureResponseObject, error) // Update display configuration // (PATCH /display) PatchDisplay(ctx context.Context, request PatchDisplayRequestObject) (PatchDisplayResponseObject, error) @@ -15903,6 +16202,37 @@ func (sh *strictHandler) TypeText(w http.ResponseWriter, r *http.Request) { } } +// ChromiumConfigure operation middleware +func (sh *strictHandler) ChromiumConfigure(w http.ResponseWriter, r *http.Request) { + var request ChromiumConfigureRequestObject + + if reader, err := r.MultipartReader(); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err)) + return + } else { + request.Body = reader + } + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ChromiumConfigure(ctx, request.(ChromiumConfigureRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ChromiumConfigure") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ChromiumConfigureResponseObject); ok { + if err := validResponse.VisitChromiumConfigureResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // PatchDisplay operation middleware func (sh *strictHandler) PatchDisplay(w http.ResponseWriter, r *http.Request) { var request PatchDisplayRequestObject @@ -17042,283 +17372,302 @@ func (sh *strictHandler) StreamTelemetryEvents(w http.ResponseWriter, r *http.Re // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "/wEAAP//c4aJWK9pAQA=", + "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", + "GJ8K0HgBAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index 1c913ed1..0b11325d 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1169,6 +1169,90 @@ paths: $ref: "#/components/responses/BadRequestError" "500": $ref: "#/components/responses/InternalError" + + /configure: + post: + summary: Apply batched session and browser configuration plus optional navigation + description: | + Optional multipart parts apply configuration while Chromium stays stopped once (policy, + flags, extensions, profile archive, optional display sizing), then Chromium is started exactly + once and DevTools readiness is awaited. Optional `start_url` dispatches a best-effort + navigation after readiness without waiting for page load. Bare hosts are normalized to + `https://`. Omit any part you do not need. At least one actionable part must be present. + Required configuration steps run in this order: policies, extensions, display, flags, + then profile archive. Chromium is started only once at the end. The endpoint is not + transactional: if a later required step fails, earlier successful side effects may remain + on the instance while the non-2xx error propagates. + Prefer this over separate `/chromium/*` and `/display` calls when multiple restart-triggering + steps apply in one session configure. + operationId: chromiumConfigure + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + display: + description: >- + UTF-8 JSON object matching `#/components/schemas/PatchDisplayRequest` (width/height/etc.). + When combined with restart-triggering fields, the resize is applied while Chromium + is stopped and Chromium is started once at the end. + type: string + chromium_flags: + description: >- + UTF-8 JSON object `{"flags":["--kiosk"]}` — same semantics as PATCH /chromium/flags. + type: string + chrome_policies: + description: UTF-8 JSON policy override map — same semantics as PATCH /chromium/policies. + type: string + profile_archive: + description: >- + tar.zst archive containing the desired `/home/kernel/user-data` profile contents. + Prefer archives whose root entries are the profile files/directories themselves + (for example `Default/Preferences`). Use `strip_components` only when uploading + an archive that includes leading wrapper directories. + type: string + format: binary + strip_components: + description: Optional number of leading path components to strip from profile_archive entries (non-negative integer as text). + type: string + start_url: + description: >- + 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 + extensions: + type: array + description: Extension zips paired with consecutive extensions.name fields (same as upload-extensions-and-restart). + items: + type: object + properties: + zip_file: + type: string + format: binary + name: + type: string + pattern: "^[A-Za-z0-9._-]{1,255}$" + required: [zip_file, name] + responses: + "200": + description: Configuration applied; optional navigate completed successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/OkResponse" + "400": + $ref: "#/components/responses/BadRequestError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + description: Configure failure with structured phase + content: + application/json: + schema: + $ref: "#/components/schemas/ChromiumConfigureError" + /playwright/execute: post: summary: Execute Playwright/TypeScript code against the browser @@ -2491,6 +2575,31 @@ components: properties: message: type: string + ChromiumConfigureError: + type: object + description: Failure from batched chromium configure — includes which phase failed. + required: [phase, message] + additionalProperties: false + properties: + phase: + type: string + enum: + - configure_phase + - navigate_phase + description: configure_phase maps to restart/filesystem/policy/extension/profile/display work; navigate_phase is retained for compatibility. + message: + type: string + step: + type: string + description: Optional configure step that failed. + enum: + - stop_chromium + - start_chromium + - chrome_policies + - extensions + - display + - chromium_flags + - profile RecorderInfo: type: object required: [id, isRecording]