Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions server/cmd/api/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ type ApiService struct {
upstreamMgr *devtoolsproxy.UpstreamManager
stz scaletozero.PinnedController

// chromiumConfigMu serializes configuration changes that may restart Chromium
// or mutate its runtime flags and policies.
chromiumConfigMu sync.Mutex

// inputMu serializes input-related operations (mouse, keyboard, screenshot)
inputMu sync.Mutex

Expand Down
126 changes: 95 additions & 31 deletions server/cmd/api/api/chromium.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,38 @@ type extensionZipItem struct {
name string
}

// chromiumFlagsPath is the runtime flags file read by the chromium-launcher at startup.
const chromiumFlagsPath = "/chromium/flags"
const (
// chromiumFlagsPath is the runtime flags file read by the chromium-launcher at startup.
chromiumFlagsPath = "/chromium/flags"
extensionsBaseDir = "/home/kernel/extensions"
)

// UploadExtensionsAndRestart handles multipart upload of one or more extension zips, extracts
// them under /home/kernel/extensions/<name>, writes /chromium/flags to enable them, restarts
// Chromium via supervisord, and waits (via UpstreamManager) until DevTools is ready.
// UploadExtensionsAndRestart uploads extensions and always restarts Chromium.
func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oapi.UploadExtensionsAndRestartRequestObject) (oapi.UploadExtensionsAndRestartResponseObject, error) {
return s.uploadExtensions(ctx, request, true)
}

// UploadExtensions uploads extensions and activates ordinary unpacked extensions over CDP.
func (s *ApiService) UploadExtensions(ctx context.Context, request oapi.UploadExtensionsRequestObject) (oapi.UploadExtensionsResponseObject, error) {
response, err := s.uploadExtensions(ctx, oapi.UploadExtensionsAndRestartRequestObject{Body: request.Body}, false)
if err != nil {
return nil, err
}

switch response := response.(type) {
case oapi.UploadExtensionsAndRestart201Response:
return oapi.UploadExtensions201Response{}, nil
case oapi.UploadExtensionsAndRestart400JSONResponse:
return oapi.UploadExtensions400JSONResponse{BadRequestErrorJSONResponse: response.BadRequestErrorJSONResponse}, nil
case oapi.UploadExtensionsAndRestart500JSONResponse:
return oapi.UploadExtensions500JSONResponse{InternalErrorJSONResponse: response.InternalErrorJSONResponse}, nil
default:
logger.FromContext(ctx).Error("unexpected extension upload response", "type", fmt.Sprintf("%T", response))
return oapi.UploadExtensions500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil
}
}

func (s *ApiService) uploadExtensions(ctx context.Context, request oapi.UploadExtensionsAndRestartRequestObject, forceRestart bool) (oapi.UploadExtensionsAndRestartResponseObject, error) {
log := logger.FromContext(ctx)
start := time.Now()
log.Info("upload extensions: begin")
Expand Down Expand Up @@ -145,41 +170,55 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap
extItems = append(extItems, extensionZipItem{zipTemp: p.zipTemp, name: p.name})
}

reqMsg, err := s.applyExtensionZipItems(ctx, extItems)
s.chromiumConfigMu.Lock()
defer s.chromiumConfigMu.Unlock()

requiresRestart, 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
restarted := forceRestart || requiresRestart
if restarted {
if err := s.restartChromiumAndWait(ctx, "extension upload"); err != nil {
return oapi.UploadExtensionsAndRestart500JSONResponse{
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: err.Error()},
}, nil
}
} else if loadErr := s.loadUnpackedExtensions(ctx, extItems); loadErr != nil {
log.Warn("CDP extension load failed, restarting Chromium", "error", loadErr)
if restartErr := s.restartChromiumAndWait(ctx, "extension upload fallback"); restartErr != nil {
return oapi.UploadExtensionsAndRestart500JSONResponse{
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
Message: fmt.Sprintf("CDP extension load failed (%v), and fallback restart failed: %v", loadErr, restartErr),
},
}, nil
}
restarted = true
}

log.Info("devtools ready", "elapsed", time.Since(start).String())
log.Info("extensions ready", "restarted", restarted, "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) {
// applyExtensionZipItems installs name+zipTemp extension pairs and persists their startup
// configuration. The boolean result reports whether enterprise policy requires a restart.
func (s *ApiService) applyExtensionZipItems(ctx context.Context, items []extensionZipItem) (bool, string, 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)
if err := os.MkdirAll(extensionsBaseDir, 0o755); err != nil {
return false, "", fmt.Errorf("failed to create extension base dir: %w", err)
}

for _, p := range items {
dest := filepath.Join(extBase, p.name)
dest := filepath.Join(extensionsBaseDir, p.name)
if _, err := os.Stat(dest); err == nil {
return fmt.Sprintf("extension name already exists: %s", p.name), nil
return false, 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 "", fmt.Errorf("failed to check extension dir: %w", err)
return false, "", fmt.Errorf("failed to check extension dir: %w", err)
}
}

Expand All @@ -197,15 +236,15 @@ func (s *ApiService) applyExtensionZipItems(ctx context.Context, items []extensi
}()

for _, p := range items {
dest := filepath.Join(extBase, p.name)
dest := filepath.Join(extensionsBaseDir, p.name)
if err := os.MkdirAll(dest, 0o755); err != nil {
log.Error("failed to create extension dir", "error", err)
return "", fmt.Errorf("failed to create extension dir: %w", err)
return false, "", 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 "invalid zip file", nil
return false, "invalid zip file", nil
}

updateXMLPath := filepath.Join(dest, "update.xml")
Expand All @@ -215,23 +254,24 @@ func (s *ApiService) applyExtensionZipItems(ctx context.Context, items []extensi

if err := exec.Command("chown", "-R", "kernel:kernel", dest).Run(); err != nil {
log.Error("failed to chown extension dir", "error", err)
return "", fmt.Errorf("failed to chown extension dir: %w", err)
return false, "", fmt.Errorf("failed to chown extension dir: %w", err)
}

log.Info("installed extension", "name", p.name)
}

var pathsNeedingFlags []string
requiresRestart := false

for _, p := range items {
extensionPath := filepath.Join(extBase, p.name)
extensionPath := filepath.Join(extensionsBaseDir, p.name)
extensionName := p.name
manifestPath := filepath.Join(extensionPath, "manifest.json")
updateXMLPath := filepath.Join(extensionPath, "update.xml")

requiresEntPolicy, err := s.policy.RequiresEnterprisePolicy(manifestPath)
if err != nil {
log.Warn("failed to read manifest for policy check", "error", err, "extension", extensionName)
return false, fmt.Sprintf("invalid extension %s: %v", extensionName, err), nil
}

chromeExtensionID := extensionName
Expand All @@ -252,7 +292,7 @@ func (s *ApiService) applyExtensionZipItems(ctx context.Context, items []extensi

if _, err := os.Stat(updateXMLPath); err == nil {
if extractionErr != nil {
return fmt.Sprintf("extension %s requires enterprise policy but update.xml is invalid: %v", extensionName, extractionErr), nil
return false, 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)
Expand All @@ -274,14 +314,16 @@ func (s *ApiService) applyExtensionZipItems(ctx context.Context, items []extensi
"name", extensionName, "hasUpdateXML", hasUpdateXML, "hasCRX", hasCRX)
requiresEntPolicy = false
pathsNeedingFlags = append(pathsNeedingFlags, extensionPath)
} else {
requiresRestart = true
}
} else {
pathsNeedingFlags = append(pathsNeedingFlags, extensionPath)
}

if err := s.policy.AddExtension(extensionName, chromeExtensionID, extensionPath, requiresEntPolicy); err != nil {
log.Error("failed to update enterprise policy", "error", err, "extension", extensionName)
return "", fmt.Errorf("failed to update enterprise policy for %s: %w", extensionName, err)
return false, "", fmt.Errorf("failed to update enterprise policy for %s: %w", extensionName, err)
}

log.Info("updated enterprise policy", "extension", extensionName, "chromeExtensionID", chromeExtensionID, "requiresEnterprisePolicy", requiresEntPolicy)
Expand All @@ -295,11 +337,27 @@ func (s *ApiService) applyExtensionZipItems(ctx context.Context, items []extensi
}

if _, err := s.mergeAndWriteChromiumFlags(ctx, newTokens); err != nil {
return "", err
return false, "", err
}

success = true
return "", nil
return requiresRestart, "", nil
}

func (s *ApiService) loadUnpackedExtensions(ctx context.Context, items []extensionZipItem) error {
log := logger.FromContext(ctx)
timeout := time.Duration(len(items)) * 10 * time.Second
return s.withCDPClientTimeout(ctx, timeout, func(cdpCtx context.Context, client *cdpclient.Client) error {
for _, item := range items {
path := filepath.Join(extensionsBaseDir, item.name)
id, err := client.LoadUnpackedExtension(cdpCtx, path)
if err != nil {
return fmt.Errorf("failed to load extension %s: %w", item.name, err)
}
log.Info("loaded unpacked extension over CDP", "name", item.name, "id", id)
}
return nil
})
}

// mergeAndWriteChromiumFlags reads existing flags, merges them with new flags,
Expand Down Expand Up @@ -574,6 +632,9 @@ func (s *ApiService) PatchChromiumPolicies(ctx context.Context, request oapi.Pat
return oapi.PatchChromiumPolicies400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil
}

s.chromiumConfigMu.Lock()
defer s.chromiumConfigMu.Unlock()

if err := s.policy.ApplyOverrides(overrides); err != nil {
if strings.Contains(err.Error(), "invalid chromium policy overrides") || strings.Contains(err.Error(), "cannot be overridden") {
return oapi.PatchChromiumPolicies400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil
Expand Down Expand Up @@ -619,6 +680,9 @@ func (s *ApiService) PatchChromiumFlags(ctx context.Context, request oapi.PatchC
}
}

s.chromiumConfigMu.Lock()
defer s.chromiumConfigMu.Unlock()

// Merge and write flags
if _, err := s.mergeAndWriteChromiumFlags(ctx, request.Body.Flags); err != nil {
return oapi.PatchChromiumFlags500JSONResponse{
Expand Down
8 changes: 6 additions & 2 deletions server/cmd/api/api/chromium_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ func (s *ApiService) ChromiumConfigure(ctx context.Context, request oapi.Chromiu
return cfg400("no configuration fields provided"), nil
}

s.chromiumConfigMu.Lock()
defer s.chromiumConfigMu.Unlock()
Comment thread
cursor[bot] marked this conversation as resolved.

needsStop := chromiumNeedsStopCycle(st)
chromiumStopped := false
restartAfterStop := func() error {
Expand Down Expand Up @@ -696,7 +699,7 @@ func chromiumDisplayApplyWhileStopped(ctx context.Context, s *ApiService, plan *
}

func chromiumRunPatchDisplay(ctx context.Context, s *ApiService, body *oapi.PatchDisplayJSONRequestBody) oapi.ChromiumConfigureResponseObject {
resp, err := s.PatchDisplay(ctx, oapi.PatchDisplayRequestObject{Body: body})
resp, err := s.patchDisplayLocked(ctx, oapi.PatchDisplayRequestObject{Body: body})
if err != nil {
return cfg500ConfigureStep(chromiumConfigureStepDisplay, err.Error())
}
Expand Down Expand Up @@ -752,7 +755,8 @@ func chromiumApplyExtensions(ctx context.Context, s *ApiService, items []extensi
if len(items) == 0 {
return "", nil
}
return s.applyExtensionZipItems(ctx, items)
_, reqMsg, err := s.applyExtensionZipItems(ctx, items)
return reqMsg, err
}

func chromiumValidateFlags(raw *string) (*chromiumFlagsPlan, error) {
Expand Down
14 changes: 11 additions & 3 deletions server/cmd/api/api/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import (
// This method automatically detects whether the system is running with Xorg (headful)
// or Xvfb (headless) and uses the appropriate method to change resolution.
func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequestObject) (oapi.PatchDisplayResponseObject, error) {
s.chromiumConfigMu.Lock()
defer s.chromiumConfigMu.Unlock()
return s.patchDisplayLocked(ctx, req)
}

func (s *ApiService) patchDisplayLocked(ctx context.Context, req oapi.PatchDisplayRequestObject) (oapi.PatchDisplayResponseObject, error) {
log := logger.FromContext(ctx)

if req.Body == nil {
Expand Down Expand Up @@ -394,14 +400,16 @@ func (s *ApiService) backgroundResizeXvfb(ctx context.Context, width, height int

// withCDPClient dials the current devtools upstream with a 10s timeout,
// hands the connected client to fn, and closes the connection on return.
// Lets the small per-call CDP helpers below avoid duplicating the dial +
// timeout + defer-close scaffolding.
func (s *ApiService) withCDPClient(ctx context.Context, fn func(context.Context, *cdpclient.Client) error) error {
return s.withCDPClientTimeout(ctx, 10*time.Second, fn)
}

func (s *ApiService) withCDPClientTimeout(ctx context.Context, timeout time.Duration, fn func(context.Context, *cdpclient.Client) error) error {
upstreamURL := s.upstreamMgr.Current()
if upstreamURL == "" {
return fmt.Errorf("devtools upstream not available")
}
cdpCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
cdpCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client, err := cdpclient.Dial(cdpCtx, upstreamURL)
if err != nil {
Expand Down
24 changes: 24 additions & 0 deletions server/cmd/api/api/display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,3 +570,27 @@ func TestAdjustParamsForRemainingBudget(t *testing.T) {
assert.Nil(t, adjusted.MaxDurationInSeconds, "should remain nil when not set")
})
}

func TestPatchDisplayLockedDoesNotRelockChromiumConfig(t *testing.T) {
s := &ApiService{}
s.chromiumConfigMu.Lock()
defer s.chromiumConfigMu.Unlock()

type result struct {
resp oapi.PatchDisplayResponseObject
err error
}
done := make(chan result, 1)
go func() {
resp, err := s.patchDisplayLocked(context.Background(), oapi.PatchDisplayRequestObject{})
done <- result{resp: resp, err: err}
}()

select {
case got := <-done:
require.NoError(t, got.err)
require.IsType(t, oapi.PatchDisplay400JSONResponse{}, got.resp)
case <-time.After(time.Second):
t.Fatal("patchDisplayLocked tried to reacquire chromiumConfigMu")
}
}
Loading
Loading