Skip to content
Merged
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
44 changes: 25 additions & 19 deletions server/cmd/api/api/chromium.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,15 @@ func (s *ApiService) uploadExtensions(ctx context.Context, mr *multipart.Reader,
s.chromiumConfigMu.Lock()
defer s.chromiumConfigMu.Unlock()

requiresRestart, transaction, reqMsg, err := s.commitPreparedExtensions(ctx, prepared)
transaction, reqMsg, err := s.commitPreparedExtensions(ctx, prepared)
if reqMsg != "" {
return badExtensionUpload(reqMsg)
}
if err != nil {
return internalExtensionUpload(err.Error())
}

restarted := forceRestart || requiresRestart
restarted := forceRestart || prepared.requiresRestart
var loadErr error
if restarted {
if err := s.restartChromiumAndWait(ctx, "extension upload"); err != nil {
Expand Down Expand Up @@ -259,22 +259,25 @@ func (batch *preparedExtensionBatch) cleanup() {
}
}

// 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) {
// installExtensionZipItems extracts, validates, and persists extension archives.
// The caller must hold chromiumConfigMu while it commits the prepared batch.
func (s *ApiService) installExtensionZipItems(ctx context.Context, items []extensionZipItem) (string, error) {
if len(items) == 0 {
return "", nil
}
prepared, reqMsg, err := s.prepareExtensionZipItems(ctx, items)
if prepared != nil {
defer prepared.cleanup()
}
if reqMsg != "" || err != nil {
return false, reqMsg, err
return reqMsg, err
}
requiresRestart, _, reqMsg, err := s.commitPreparedExtensions(ctx, prepared)
return requiresRestart, reqMsg, err
_, reqMsg, err = s.commitPreparedExtensions(ctx, prepared)
return reqMsg, err
}

// prepareExtensionZipItems performs archive extraction and validation before the global Chromium
// configuration lock is acquired. commitPreparedExtensions rechecks destination names under lock.
// prepareExtensionZipItems extracts and validates archives into staging paths.
// commitPreparedExtensions rechecks destination names while the caller holds the config lock.
func (s *ApiService) prepareExtensionZipItems(ctx context.Context, items []extensionZipItem) (*preparedExtensionBatch, string, error) {
log := logger.FromContext(ctx)
if err := os.MkdirAll(extensionsBaseDir, 0o755); err != nil {
Expand Down Expand Up @@ -438,22 +441,25 @@ func (batch *committedExtensionBatch) rollback() error {
return rollbackErr
}

func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *preparedExtensionBatch) (requiresRestart bool, transaction *committedExtensionBatch, reqMsg string, err error) {
// commitPreparedExtensions moves a prepared batch into place and updates policy and
// flags, rolling back partial commit failures. Callers may retain the returned
// transaction to roll back a later activation failure.
func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *preparedExtensionBatch) (transaction *committedExtensionBatch, reqMsg string, err error) {
for _, extension := range batch.extensions {
if _, statErr := os.Stat(extension.finalPath); statErr == nil {
return false, nil, fmt.Sprintf("extension name already exists: %s", extension.name), nil
return nil, fmt.Sprintf("extension name already exists: %s", extension.name), nil
} else if !os.IsNotExist(statErr) {
return false, nil, "", fmt.Errorf("failed to check extension dir: %w", statErr)
return nil, "", fmt.Errorf("failed to check extension dir: %w", statErr)
}
}

flagsSnapshot, err := captureOptionalFileSnapshot(chromiumFlagsPath)
if err != nil {
return false, nil, "", fmt.Errorf("failed to snapshot chromium flags: %w", err)
return nil, "", fmt.Errorf("failed to snapshot chromium flags: %w", err)
}
policySnapshot, err := captureOptionalFileSnapshot(policy.PolicyPath)
if err != nil {
return false, nil, "", fmt.Errorf("failed to snapshot chromium policy: %w", err)
return nil, "", fmt.Errorf("failed to snapshot chromium policy: %w", err)
}

transaction = &committedExtensionBatch{
Expand All @@ -477,7 +483,7 @@ func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *prepar
registrations := make([]policy.ExtensionRegistration, 0, len(batch.extensions))
for _, extension := range batch.extensions {
if err := os.Rename(extension.stagingPath, extension.finalPath); err != nil {
return false, nil, "", fmt.Errorf("commit extension directory %s: %w", extension.name, err)
return nil, "", fmt.Errorf("commit extension directory %s: %w", extension.name, err)
}
transaction.paths = append(transaction.paths, extension.finalPath)
registrations = append(registrations, policy.ExtensionRegistration{
Expand All @@ -488,15 +494,15 @@ func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *prepar
}

if err := s.policy.AddExtensions(registrations); err != nil {
return false, nil, "", fmt.Errorf("failed to update enterprise policy: %w", err)
return nil, "", fmt.Errorf("failed to update enterprise policy: %w", err)
}

var newTokens []string
if len(batch.flagPaths) > 0 {
newTokens = []string{fmt.Sprintf("--load-extension=%s", strings.Join(batch.flagPaths, ","))}
}
if _, err := s.mergeAndWriteChromiumFlags(ctx, newTokens); err != nil {
return false, nil, "", err
return nil, "", err
}

committed = true
Expand All @@ -506,7 +512,7 @@ func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *prepar
"chromeExtensionID", extension.chromeExtensionID,
"requiresEnterprisePolicy", extension.requiresEnterprisePolicy)
}
return batch.requiresRestart, transaction, "", nil
return transaction, "", nil
}

func (s *ApiService) loadUnpackedExtensions(ctx context.Context, extensions []preparedExtension) error {
Expand Down
120 changes: 103 additions & 17 deletions server/cmd/api/api/chromium_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ func (st *chromiumConfigureState) cleanup() {
func (s *ApiService) ChromiumConfigure(ctx context.Context, request oapi.ChromiumConfigureRequestObject) (oapi.ChromiumConfigureResponseObject, error) {
start := time.Now()

extensionLoadStrategy, strategyErr := chromiumConfigureExtensionLoadStrategy(request.Params)
if strategyErr != "" {
return cfg400(strategyErr), nil
}

if request.Body == nil {
return cfg400("request body required"), nil
}
Expand All @@ -77,15 +82,36 @@ func (s *ApiService) ChromiumConfigure(ctx context.Context, request oapi.Chromiu
return cfg400("no configuration fields provided"), nil
}

mode := chromiumConfigureModeFor(st, extensionLoadStrategy)
var preparedExtensions *preparedExtensionBatch
if mode == chromiumConfigureModePreferCDPExtensions {
var reqMsg string
var err error
preparedExtensions, reqMsg, err = s.prepareExtensionZipItems(ctx, st.extItems)
if preparedExtensions != nil {
defer preparedExtensions.cleanup()
}
if reqMsg != "" {
return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsg)), nil
}
if err != nil {
return cfg500ConfigureStep(chromiumConfigureStepExtensions, err.Error()), nil
}
}

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

var configureResp oapi.ChromiumConfigureResponseObject
switch chromiumConfigureModeFor(st) {
switch mode {
case chromiumConfigureModeLive:
configureResp = s.chromiumConfigureLive(ctx, st, spec)
case chromiumConfigureModeRestart:
configureResp = s.chromiumConfigureRestart(ctx, st, spec)
configureResp = s.chromiumConfigureRestart(ctx, st, spec, func() (string, error) {
return s.installExtensionZipItems(ctx, st.extItems)
}, nil)
case chromiumConfigureModePreferCDPExtensions:
configureResp = s.chromiumConfigurePreferCDPExtensions(ctx, st, spec, preparedExtensions)
default:
return cfg500Configure("unhandled configure mode"), nil
}
Expand All @@ -102,15 +128,31 @@ type chromiumConfigureMode uint8
const (
chromiumConfigureModeLive chromiumConfigureMode = iota
chromiumConfigureModeRestart
chromiumConfigureModePreferCDPExtensions
)

func chromiumConfigureModeFor(st *chromiumConfigureState) chromiumConfigureMode {
func chromiumConfigureExtensionLoadStrategy(params oapi.ChromiumConfigureParams) (oapi.ChromiumConfigureParamsExtensionLoadStrategy, string) {
if params.ExtensionLoadStrategy == nil {
return oapi.Restart, ""
}
if !params.ExtensionLoadStrategy.Valid() {
return "", "extension_load_strategy must be restart or prefer_cdp"
}
return *params.ExtensionLoadStrategy, ""
}

func chromiumConfigureModeFor(st *chromiumConfigureState, strategy oapi.ChromiumConfigureParamsExtensionLoadStrategy) chromiumConfigureMode {
if st.hasProfile ||
len(st.extItems) > 0 ||
policiesContentNonEmpty(st.chromePoliciesJSON) ||
flagsContentNonEmpty(st.chromiumFlagsJSON) {
return chromiumConfigureModeRestart
}
if len(st.extItems) > 0 {
if strategy == oapi.PreferCdp {
return chromiumConfigureModePreferCDPExtensions
}
return chromiumConfigureModeRestart
}
return chromiumConfigureModeLive
}

Expand Down Expand Up @@ -140,7 +182,50 @@ func chromiumConfigureNavigate(ctx context.Context, s *ApiService, spec startURL
}
}

func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumConfigureState, spec startURLParsed) (resp oapi.ChromiumConfigureResponseObject) {
func (s *ApiService) chromiumConfigurePreferCDPExtensions(ctx context.Context, st *chromiumConfigureState, spec startURLParsed, prepared *preparedExtensionBatch) oapi.ChromiumConfigureResponseObject {
commitExtensions := func() (string, error) {
_, reqMsg, err := s.commitPreparedExtensions(ctx, prepared)
return reqMsg, err
}
if prepared.requiresRestart {
return s.chromiumConfigureRestart(ctx, st, spec, commitExtensions, func() oapi.ChromiumConfigureResponseObject {
if err := s.verifyUnpackedExtensions(ctx, prepared.extensions); err != nil {
return cfg500ConfigureStep(chromiumConfigureStepExtensions, err.Error())
}
return nil
})
}

// Configure keeps the default restart path's non-transactional install semantics.
// Rolling back after fallback verification would require a second restart.
reqMsg, err := commitExtensions()
if reqMsg != "" {
return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsg))
}
if err != nil {
return cfg500ConfigureStep(chromiumConfigureStepExtensions, err.Error())
}

loadErr := s.loadUnpackedExtensions(ctx, prepared.extensions)
if loadErr == nil {
return s.chromiumConfigureLive(ctx, st, spec)
}
logger.FromContext(ctx).Warn("CDP extension load failed during configure, restarting Chromium", "error", loadErr)

// Extensions are already persisted. The fallback re-launches Chromium with
// the merged flags and still applies any pending display change.
return s.chromiumConfigureRestart(ctx, st, spec, nil, func() oapi.ChromiumConfigureResponseObject {
if err := s.verifyUnpackedExtensions(ctx, prepared.extensions); err != nil {
return cfg500ConfigureStep(chromiumConfigureStepExtensions, errors.Join(loadErr, err).Error())
}
return nil
})
}

type chromiumConfigureExtensionInstaller func() (string, error)
type chromiumConfigurePostRestartCheck func() oapi.ChromiumConfigureResponseObject

func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumConfigureState, spec startURLParsed, installExtensions chromiumConfigureExtensionInstaller, postRestartCheck chromiumConfigurePostRestartCheck) (resp oapi.ChromiumConfigureResponseObject) {
var stoppedRecordings []stoppedRecordingInfo
chromiumStopped := false
restartAfterStop := func() error {
Expand Down Expand Up @@ -183,10 +268,14 @@ func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumC
return cfgResponseFromStepError(chromiumConfigureStepPolicies, err)
}

if reqMsgs, ierr := chromiumApplyExtensions(ctx, s, st.extItems); reqMsgs != "" {
return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsgs))
} else if ierr != nil {
return cfg500ConfigureStep(chromiumConfigureStepExtensions, ierr.Error())
if installExtensions != nil {
extensionReqMsg, extensionErr := installExtensions()
if extensionReqMsg != "" {
return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, extensionReqMsg))
}
if extensionErr != nil {
return cfg500ConfigureStep(chromiumConfigureStepExtensions, extensionErr.Error())
}
}

if st.displayJSON != nil && strings.TrimSpace(*st.displayJSON) != "" {
Expand Down Expand Up @@ -235,6 +324,11 @@ func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumC
if err := restartAfterStop(); err != nil {
return cfg500ConfigureStep(chromiumConfigureStepStart, err.Error())
}
if postRestartCheck != nil {
if resp := postRestartCheck(); resp != nil {
return resp
}
}
chromiumConfigureNavigate(ctx, s, spec)
if len(stoppedRecordings) > 0 {
go s.startNewRecordingSegments(context.WithoutCancel(ctx), stoppedRecordings)
Expand Down Expand Up @@ -804,14 +898,6 @@ func chromiumApplyPolicies(ctx context.Context, s *ApiService, overrides policy.
return nil
}

func chromiumApplyExtensions(ctx context.Context, s *ApiService, items []extensionZipItem) (string, error) {
if len(items) == 0 {
return "", nil
}
_, reqMsg, err := s.applyExtensionZipItems(ctx, items)
return reqMsg, err
}

func chromiumValidateFlags(raw *string) (*chromiumFlagsPlan, error) {
if raw == nil || strings.TrimSpace(*raw) == "" {
return nil, nil
Expand Down
71 changes: 55 additions & 16 deletions server/cmd/api/api/chromium_configure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"testing"

oapi "github.com/kernel/kernel-images/server/lib/oapi"
"github.com/stretchr/testify/require"
)

Expand All @@ -33,31 +34,69 @@ func TestChromiumConfigureModeFor(t *testing.T) {
stringPtr := func(value string) *string { return &value }

tests := []struct {
name string
state chromiumConfigureState
want chromiumConfigureMode
name string
state chromiumConfigureState
strategy oapi.ChromiumConfigureParamsExtensionLoadStrategy
want chromiumConfigureMode
}{
{name: "no restart fields", want: chromiumConfigureModeLive},
{name: "display only", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`)}, want: chromiumConfigureModeLive},
{name: "start URL only", state: chromiumConfigureState{startURLRaw: stringPtr("https://example.com")}, want: chromiumConfigureModeLive},
{name: "empty policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{}`)}, want: chromiumConfigureModeLive},
{name: "nonempty policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{"QuicAllowed":false}`)}, want: chromiumConfigureModeRestart},
{name: "invalid policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{bad-json`)}, want: chromiumConfigureModeRestart},
{name: "empty flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{"flags":[]}`)}, want: chromiumConfigureModeLive},
{name: "nonempty flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{"flags":["--kiosk"]}`)}, want: chromiumConfigureModeRestart},
{name: "invalid flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{bad-json`)}, want: chromiumConfigureModeRestart},
{name: "profile", state: chromiumConfigureState{hasProfile: true}, want: chromiumConfigureModeRestart},
{name: "extensions", state: chromiumConfigureState{extItems: []extensionZipItem{{name: "test"}}}, want: chromiumConfigureModeRestart},
{name: "display and extension", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`), extItems: []extensionZipItem{{name: "test"}}}, want: chromiumConfigureModeRestart},
{name: "no restart fields", strategy: oapi.Restart, want: chromiumConfigureModeLive},
{name: "display only", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`)}, strategy: oapi.Restart, want: chromiumConfigureModeLive},
{name: "start URL only", state: chromiumConfigureState{startURLRaw: stringPtr("https://example.com")}, strategy: oapi.Restart, want: chromiumConfigureModeLive},
{name: "empty policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{}`)}, strategy: oapi.Restart, want: chromiumConfigureModeLive},
{name: "nonempty policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{"QuicAllowed":false}`)}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart},
{name: "invalid policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{bad-json`)}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart},
{name: "empty flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{"flags":[]}`)}, strategy: oapi.Restart, want: chromiumConfigureModeLive},
{name: "nonempty flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{"flags":["--kiosk"]}`)}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart},
{name: "invalid flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{bad-json`)}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart},
{name: "profile", state: chromiumConfigureState{hasProfile: true}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart},
{name: "extensions default restart", state: chromiumConfigureState{extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.Restart, want: chromiumConfigureModeRestart},
{name: "extensions prefer CDP", state: chromiumConfigureState{extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.PreferCdp, want: chromiumConfigureModePreferCDPExtensions},
{name: "display and extensions prefer CDP", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`), extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.PreferCdp, want: chromiumConfigureModePreferCDPExtensions},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, chromiumConfigureModeFor(&tt.state))
require.Equal(t, tt.want, chromiumConfigureModeFor(&tt.state, tt.strategy))
})
}
}

func TestChromiumConfigureExtensionLoadStrategy(t *testing.T) {
strategy, msg := chromiumConfigureExtensionLoadStrategy(oapi.ChromiumConfigureParams{})
require.Empty(t, msg)
require.Equal(t, oapi.Restart, strategy)

preferCDP := oapi.PreferCdp
strategy, msg = chromiumConfigureExtensionLoadStrategy(oapi.ChromiumConfigureParams{ExtensionLoadStrategy: &preferCDP})
require.Empty(t, msg)
require.Equal(t, oapi.PreferCdp, strategy)

invalid := oapi.ChromiumConfigureParamsExtensionLoadStrategy("invalid")
_, msg = chromiumConfigureExtensionLoadStrategy(oapi.ChromiumConfigureParams{ExtensionLoadStrategy: &invalid})
require.Equal(t, "extension_load_strategy must be restart or prefer_cdp", msg)
}

func TestChromiumConfigureGeneratedClientQuery(t *testing.T) {
preferCDP := oapi.PreferCdp
req, err := oapi.NewChromiumConfigureRequestWithBody(
"http://example.test",
&oapi.ChromiumConfigureParams{ExtensionLoadStrategy: &preferCDP},
"multipart/form-data; boundary=test",
strings.NewReader("--test--"),
)
require.NoError(t, err)
require.Equal(t, "prefer_cdp", req.URL.Query().Get("extension_load_strategy"))

req, err = oapi.NewChromiumConfigureRequestWithBody(
"http://example.test",
nil,
"multipart/form-data; boundary=test",
strings.NewReader("--test--"),
)
require.NoError(t, err)
require.Empty(t, req.URL.RawQuery)
}

func TestChromiumConfigureActionables(t *testing.T) {
emptyFlags := `{"flags":[]}`
realFlags := `{"flags":["--kiosk"]}`
Expand Down
Loading
Loading