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
2 changes: 1 addition & 1 deletion images/chromium-headful/run-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ RUN_ARGS=(
-e WIDTH=1920
-e TZ=${TZ:-'America/Los_Angeles'}
-e RUN_AS_ROOT="$RUN_AS_ROOT"
--mount type=bind,src="$FLAGS_FILE",dst=/chromium/flags,ro
--mount type=bind,src="$FLAGS_FILE",dst=/chromium/flags
)

if [[ -n "${PLAYWRIGHT_ENGINE:-}" ]]; then
Expand Down
90 changes: 74 additions & 16 deletions server/cmd/api/api/chromium.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/onkernel/kernel-images/server/lib/chromiumflags"
"github.com/onkernel/kernel-images/server/lib/logger"
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
"github.com/onkernel/kernel-images/server/lib/policy"
"github.com/onkernel/kernel-images/server/lib/ziputil"
)

Expand Down Expand Up @@ -157,49 +158,106 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap
log.Error("failed to chown extension dir", "error", err)
return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to chown extension dir"}}, nil
}

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 {
extensionPath := filepath.Join(extBase, p.name)
extensionID := s.policy.GenerateExtensionID(p.name)
extensionName := p.name
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", p.name)
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
extractionErr := error(nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: var extractionErr error is more idiomatic

if extractedID, err := policy.ExtractExtensionIDFromUpdateXML(updateXMLPath); err == nil {
chromeExtensionID = extractedID
log.Info("extracted Chrome extension ID from update.xml", "name", extensionName, "chromeExtensionID", chromeExtensionID)
} else {
extractionErr = err
log.Info("no Chrome extension ID in update.xml, using name as ID", "name", extensionName, "error", err)
}

if requiresEntPolicy {
log.Info("extension requires enterprise policy", "name", p.name)
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
}
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 {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".crx" {
hasCRX = true
log.Info("found .crx file in extension zip", "name", extensionName, "crx_file", entry.Name())
break
}
}
}

// Fail if policy extension is missing required files
if !hasUpdateXML || !hasCRX {
return oapi.UploadExtensionsAndRestart400JSONResponse{
BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
Message: fmt.Sprintf("extension %s requires enterprise policy (ExtensionInstallForcelist) but is missing required files: update.xml (present: %v), .crx file (present: %v). These files are required for Chrome to install the extension.", extensionName, hasUpdateXML, hasCRX),
},
}, nil
}
} else {
// Only add --load-extension flags for non-policy extensions
pathsNeedingFlags = append(pathsNeedingFlags, extensionPath)
}

// Add to enterprise policy
if err := s.policy.AddExtension(extensionID, extensionPath, requiresEntPolicy); err != nil {
log.Error("failed to update enterprise policy", "error", err, "extension", p.name)
// 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", p.name, err),
Message: fmt.Sprintf("failed to update enterprise policy for %s: %v", extensionName, err),
},
}, nil
}

log.Info("updated enterprise policy", "extension", p.name, "id", extensionID, "requiresEnterprisePolicy", requiresEntPolicy)
log.Info("updated enterprise policy", "extension", extensionName, "chromeExtensionID", chromeExtensionID, "requiresEnterprisePolicy", requiresEntPolicy)
}

// Build flags overlay file in /chromium/flags, merging with existing flags
var paths []string
for _, p := range items {
paths = append(paths, filepath.Join(extBase, p.name))
}

// Create new flags for the uploaded extensions
newTokens := []string{
fmt.Sprintf("--disable-extensions-except=%s", strings.Join(paths, ",")),
fmt.Sprintf("--load-extension=%s", strings.Join(paths, ",")),
// Only add --load-extension flags for extensions that don't use policy installation
var newTokens []string
if len(pathsNeedingFlags) > 0 {
newTokens = []string{
fmt.Sprintf("--disable-extensions-except=%s", strings.Join(pathsNeedingFlags, ",")),
fmt.Sprintf("--load-extension=%s", strings.Join(pathsNeedingFlags, ",")),
}
}

// Merge and write flags
Expand Down
58 changes: 58 additions & 0 deletions server/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,64 @@ func main() {
apiService.HandleProcessAttach(w, r, id)
})

// Serve extension files for Chrome policy-installed extensions
// This allows Chrome to download .crx and update.xml files via HTTP
extensionsDir := "/home/kernel/extensions"
r.Get("/extensions/*", func(w http.ResponseWriter, r *http.Request) {
// Serve files from /home/kernel/extensions/
fs := http.StripPrefix("/extensions/", http.FileServer(http.Dir(extensionsDir)))
fs.ServeHTTP(w, r)
})

// Serve update.xml at root for Chrome enterprise policy
// This serves the first update.xml found in any extension directory
r.Get("/update.xml", func(w http.ResponseWriter, r *http.Request) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this serves the first update.xml found based on directory iteration order, which isn't deterministic. if there are multiple extensions with update.xml files, this could return the wrong one. is this route needed given /extensions/{name}/update.xml already works via the wildcard route above?

// Try to find update.xml in the first extension directory
entries, err := os.ReadDir(extensionsDir)
if err != nil {
http.Error(w, "extensions directory not found", http.StatusNotFound)
return
}

for _, entry := range entries {
if entry.IsDir() {
updateXMLPath := fmt.Sprintf("%s/%s/update.xml", extensionsDir, entry.Name())
if _, err := os.Stat(updateXMLPath); err == nil {
http.ServeFile(w, r, updateXMLPath)
return
}
}
}

http.Error(w, "update.xml not found", http.StatusNotFound)
})

// Serve CRX files at root for Chrome enterprise policy
// This allows simple codebase URLs like http://host:port/extension-name.crx
r.Get("/{filename}.crx", func(w http.ResponseWriter, r *http.Request) {
// Extract the filename from the URL path
filename := chi.URLParam(r, "filename") + ".crx"

// Search for the CRX file in all extension directories
entries, err := os.ReadDir(extensionsDir)
if err != nil {
http.Error(w, "extensions directory not found", http.StatusNotFound)
return
}

for _, entry := range entries {
if entry.IsDir() {
crxPath := fmt.Sprintf("%s/%s/%s", extensionsDir, entry.Name(), filename)
if _, err := os.Stat(crxPath); err == nil {
http.ServeFile(w, r, crxPath)
return
}
}
}

http.Error(w, "crx file not found", http.StatusNotFound)
})

srv := &http.Server{
Addr: fmt.Sprintf(":%d", config.Port),
Handler: r,
Expand Down
112 changes: 0 additions & 112 deletions server/e2e/e2e_chromium_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -804,115 +804,3 @@ func listCDPTargets(ctx context.Context) ([]map[string]interface{}, error) {

return targets, nil
}

func TestWebBotAuthInstallation(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test was verifying the web-bot-auth policy installation flow. rather than deleting it, could we update it to test the new behavior? the new flow (requiring update.xml + .crx, ExtensionInstallForcelist, update_url instead of path) seems important enough to have e2e coverage.

image := headlessImage
name := containerName + "-web-bot-auth"

logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo}))
baseCtx := logctx.AddToContext(context.Background(), logger)

if _, err := exec.LookPath("docker"); err != nil {
require.NoError(t, err, "docker not available: %v", err)
}

// Clean slate
_ = stopContainer(baseCtx, name)

env := map[string]string{}

// Start container
_, exitCh, err := runContainer(baseCtx, image, name, env)
require.NoError(t, err, "failed to start container: %v", err)
defer stopContainer(baseCtx, name)

ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute)
defer cancel()

logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml")
require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err)

// Build mock web-bot-auth extension zip in-memory
extDir := t.TempDir()
manifest := `{
"manifest_version": 3,
"version": "1.0.0",
"name": "Web Bot Auth Mock",
"description": "Mock web-bot-auth extension for testing",
"permissions": [
"webRequest",
"webRequestBlocking"
],
"host_permissions": [
"*://*/*"
]
}`
err = os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0600)
require.NoError(t, err, "write manifest: %v", err)

extZip, err := zipDirToBytes(extDir)
require.NoError(t, err, "zip ext: %v", err)

// Upload extension using the API
{
client, err := apiClient()
require.NoError(t, err)
var body bytes.Buffer
w := multipart.NewWriter(&body)
fw, err := w.CreateFormFile("extensions.zip_file", "web-bot-auth.zip")
require.NoError(t, err)
_, err = io.Copy(fw, bytes.NewReader(extZip))
require.NoError(t, err)
err = w.WriteField("extensions.name", "web-bot-auth")
require.NoError(t, err)
err = w.Close()
require.NoError(t, err)

logger.Info("[test]", "action", "uploading web-bot-auth extension")
start := time.Now()
rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body)
elapsed := time.Since(start)
require.NoError(t, err, "uploadExtensionsAndRestart request error: %v", err)
require.Equal(t, http.StatusCreated, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body))
logger.Info("[test]", "action", "extension uploaded", "elapsed", elapsed.String())
}

// Verify the policy.json file contains the correct web-bot-auth configuration
{
logger.Info("[test]", "action", "reading policy.json")
policyContent, err := execCombinedOutput(ctx, "cat", []string{"/etc/chromium/policies/managed/policy.json"})
require.NoError(t, err, "failed to read policy.json: %v", err)

logger.Info("[test]", "policy_content", policyContent)

var policy map[string]interface{}
err = json.Unmarshal([]byte(policyContent), &policy)
require.NoError(t, err, "failed to parse policy.json: %v", err)

// Check ExtensionSettings exists
extensionSettings, ok := policy["ExtensionSettings"].(map[string]interface{})
require.True(t, ok, "ExtensionSettings not found in policy.json")

// Check web-bot-auth entry exists
webBotAuth, ok := extensionSettings["web-bot-auth"].(map[string]interface{})
require.True(t, ok, "web-bot-auth entry not found in ExtensionSettings")

// Verify installation_mode is force_installed
installationMode, ok := webBotAuth["installation_mode"].(string)
require.True(t, ok, "installation_mode not found in web-bot-auth entry")
require.Equal(t, "force_installed", installationMode, "expected installation_mode to be force_installed")

// Verify path
path, ok := webBotAuth["path"].(string)
require.True(t, ok, "path not found in web-bot-auth entry")
require.Equal(t, "/home/kernel/extensions/web-bot-auth", path, "expected path to be /home/kernel/extensions/web-bot-auth")

// Verify runtime_allowed_hosts
runtimeAllowedHosts, ok := webBotAuth["runtime_allowed_hosts"].([]interface{})
require.True(t, ok, "runtime_allowed_hosts not found in web-bot-auth entry")
require.Len(t, runtimeAllowedHosts, 1, "expected runtime_allowed_hosts to have 1 entry")
require.Equal(t, "*://*/*", runtimeAllowedHosts[0].(string), "expected runtime_allowed_hosts to contain *://*/*")

logger.Info("[test]", "result", "web-bot-auth policy verified successfully")
}
}
Loading
Loading