Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
176 changes: 119 additions & 57 deletions server/cmd/api/api/chromium.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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"

Expand Down Expand Up @@ -130,52 +136,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/<actual-name>/
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 {
Expand All @@ -184,14 +228,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 {
Expand All @@ -205,25 +246,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 {
Expand All @@ -235,62 +268,37 @@ 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)
requiresEntPolicy = false
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{
fmt.Sprintf("--load-extension=%s", strings.Join(pathsNeedingFlags, ",")),
}
}

// 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,
Expand Down Expand Up @@ -348,7 +356,7 @@ func (s *ApiService) restartChromiumAndWait(ctx context.Context, operation strin
go func() {
cmdCtx, cancelCmd := context.WithTimeout(context.WithoutCancel(ctx), 1*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("restart", "chromium")...).CombinedOutput()
if err != nil {
log.Error("failed to restart chromium", "error", err, "out", string(out))
errCh <- fmt.Errorf("supervisorctl restart failed: %w", err)
Expand All @@ -370,6 +378,60 @@ func (s *ApiService) restartChromiumAndWait(ctx context.Context, operation strin
}
}

const supervisorCtlConf = "/etc/supervisor/supervisord.conf"

func supervisorctlArgv(verb string, prog string) []string {
return []string{"-c", supervisorCtlConf, verb, prog}
}

// 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))
return fmt.Errorf("supervisorctl stop chromium failed: %w", err)
}
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()

updates, cancelSub := s.upstreamMgr.Subscribe()
defer cancelSub()

errCh := make(chan error, 1)
log.Info("starting chromium via supervisorctl", "operation", operation)
go func() {
cmdCtx, cancelCmd := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancelCmd()
out, err := exec.CommandContext(cmdCtx, "supervisorctl", supervisorctlArgv("start", "chromium")...).CombinedOutput()
if err != nil {
log.Error("failed to start chromium", "error", err, "out", string(out))
errCh <- fmt.Errorf("supervisorctl start chromium failed: %w", err)
}
}()

timeout := time.NewTimer(15 * time.Second)
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")
}
}

// PatchChromiumPolicies applies user-provided Chromium enterprise policy overrides
// to policy.json, restarts Chromium, and waits for DevTools to be ready.
func (s *ApiService) PatchChromiumPolicies(ctx context.Context, request oapi.PatchChromiumPoliciesRequestObject) (oapi.PatchChromiumPoliciesResponseObject, error) {
Expand Down
Loading
Loading