diff --git a/.github/workflows/chromium-launcher-test.yaml b/.github/workflows/chromium-launcher-test.yaml new file mode 100644 index 00000000..9dc75e01 --- /dev/null +++ b/.github/workflows/chromium-launcher-test.yaml @@ -0,0 +1,28 @@ +name: Test chromium-launcher + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "server/go.mod" + cache: true + + - name: Run chromium-launcher unit tests + run: go test ./cmd/chromium-launcher -v + working-directory: server diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 9a1c9da2..afdb02db 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -10,9 +10,15 @@ COPY server/go.sum ./ RUN go mod download COPY server/ . + +# Build kernel-images API RUN GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ go build -ldflags="-s -w" -o /out/kernel-images-api ./cmd/api +# Build chromium launcher +RUN GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ + go build -ldflags="-s -w" -o /out/chromium-launcher ./cmd/chromium-launcher + # webrtc client FROM node:22-bullseye-slim AS client WORKDIR /src @@ -89,7 +95,6 @@ RUN apt-get update && \ # Userland apps sudo add-apt-repository ppa:mozillateam/ppa && \ sudo apt-get install -y --no-install-recommends \ - chromium-browser \ libreoffice \ x11-apps \ xpdf \ @@ -169,14 +174,13 @@ COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/ COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so COPY images/chromium-headful/image-chromium/ / -COPY images/chromium-headful/start-chromium.sh /images/chromium-headful/start-chromium.sh -RUN chmod +x /images/chromium-headful/start-chromium.sh COPY images/chromium-headful/wrapper.sh /wrapper.sh COPY images/chromium-headful/supervisord.conf /etc/supervisor/supervisord.conf COPY images/chromium-headful/supervisor/services/ /etc/supervisor/conf.d/services/ # copy the kernel-images API binary built in the builder stage COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api +COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher RUN useradd -m -s /bin/bash kernel diff --git a/images/chromium-headful/start-chromium.sh b/images/chromium-headful/start-chromium.sh deleted file mode 100644 index df658215..00000000 --- a/images/chromium-headful/start-chromium.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash - -set -o pipefail -o errexit -o nounset - -# This script is launched by supervisord to start Chromium in the foreground. -# It mirrors the logic previously embedded in wrapper.sh. - -echo "Starting Chromium launcher" - -# Resolve internal port for the remote debugging interface -INTERNAL_PORT="${INTERNAL_PORT:-9223}" - -# Load additional Chromium flags from env and optional file -CHROMIUM_FLAGS="${CHROMIUM_FLAGS:-}" -if [[ -f /chromium/flags ]]; then - CHROMIUM_FLAGS="$CHROMIUM_FLAGS $(cat /chromium/flags)" -fi -echo "CHROMIUM_FLAGS: $CHROMIUM_FLAGS" - -# Always use display :1 and point DBus to the system bus socket -export DISPLAY=":1" -export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" - -RUN_AS_ROOT="${RUN_AS_ROOT:-false}" - -if [[ "$RUN_AS_ROOT" == "true" ]]; then - echo "Running chromium as root" - exec chromium \ - --remote-debugging-port="$INTERNAL_PORT" \ - --user-data-dir=/home/kernel/user-data \ - --password-store=basic \ - --no-first-run \ - ${CHROMIUM_FLAGS:-} -else - echo "Running chromium as kernel user" - exec runuser -u kernel -- env \ - DISPLAY=":1" \ - DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" \ - XDG_CONFIG_HOME=/home/kernel/.config \ - XDG_CACHE_HOME=/home/kernel/.cache \ - HOME=/home/kernel \ - chromium \ - --remote-debugging-port="$INTERNAL_PORT" \ - --user-data-dir=/home/kernel/user-data \ - --password-store=basic \ - --no-first-run \ - ${CHROMIUM_FLAGS:-} -fi diff --git a/images/chromium-headful/supervisor/services/chromium.conf b/images/chromium-headful/supervisor/services/chromium.conf index 53265b0f..07bfe026 100644 --- a/images/chromium-headful/supervisor/services/chromium.conf +++ b/images/chromium-headful/supervisor/services/chromium.conf @@ -1,5 +1,5 @@ [program:chromium] -command=/bin/bash -lc '/images/chromium-headful/start-chromium.sh' +command=/usr/local/bin/chromium-launcher autostart=false autorestart=true startsecs=5 diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index e4bd956b..d13029f2 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -11,11 +11,16 @@ COPY server/go.mod ./ COPY server/go.sum ./ RUN go mod download -# Copy the rest of the server source and build the binary COPY server/ . + +# Build kernel-images API RUN GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ go build -ldflags="-s -w" -o /out/kernel-images-api ./cmd/api +# Build chromium launcher +RUN GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ + go build -ldflags="-s -w" -o /out/chromium-launcher ./cmd/chromium-launcher + FROM docker.io/ubuntu:22.04 RUN set -xe; \ @@ -72,12 +77,11 @@ ENV WITHDOCKER=true # Create a non-root user with a home directory RUN useradd -m -s /bin/bash kernel -# Xvfb helper and supervisor-managed start scripts -COPY images/chromium-headless/image/start-chromium.sh /images/chromium-headless/image/start-chromium.sh +# supervisor start scripts COPY images/chromium-headless/image/start-xvfb.sh /images/chromium-headless/image/start-xvfb.sh -RUN chmod +x /images/chromium-headless/image/start-chromium.sh /images/chromium-headless/image/start-xvfb.sh +RUN chmod +x /images/chromium-headless/image/start-xvfb.sh -# Wrapper script set environment +# Wrapper script to set environment COPY images/chromium-headless/image/wrapper.sh /usr/bin/wrapper.sh # Supervisord configuration @@ -86,5 +90,6 @@ COPY images/chromium-headless/image/supervisor/services/ /etc/supervisor/conf.d/ # Copy the kernel-images API binary built in the builder stage COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api +COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher ENTRYPOINT [ "/usr/bin/wrapper.sh" ] diff --git a/images/chromium-headless/image/start-chromium.sh b/images/chromium-headless/image/start-chromium.sh deleted file mode 100644 index 89d29560..00000000 --- a/images/chromium-headless/image/start-chromium.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash - -set -o pipefail -o errexit -o nounset - -echo "Starting Chromium launcher (headless)" - -# Resolve internal port for the remote debugging interface -INTERNAL_PORT="${INTERNAL_PORT:-9223}" - -# Load additional Chromium flags from env and optional file -CHROMIUM_FLAGS="${CHROMIUM_FLAGS:-}" -if [[ -f /chromium/flags ]]; then - CHROMIUM_FLAGS="$CHROMIUM_FLAGS $(cat /chromium/flags)" -fi -echo "CHROMIUM_FLAGS: $CHROMIUM_FLAGS" - -# Always use display :1 and point DBus to the system bus socket -export DISPLAY=":1" -export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" - -RUN_AS_ROOT="${RUN_AS_ROOT:-false}" - -if [[ "$RUN_AS_ROOT" == "true" ]]; then - exec chromium \ - --headless=new \ - --remote-debugging-port="$INTERNAL_PORT" \ - --remote-allow-origins=* \ - --user-data-dir=/home/kernel/user-data \ - --password-store=basic \ - --no-first-run \ - ${CHROMIUM_FLAGS:-} -else - echo "Running chromium as kernel user" - exec runuser -u kernel -- env \ - DISPLAY=":1" \ - DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" \ - XDG_CONFIG_HOME=/home/kernel/.config \ - XDG_CACHE_HOME=/home/kernel/.cache \ - HOME=/home/kernel \ - chromium \ - --headless=new \ - --remote-debugging-port="$INTERNAL_PORT" \ - --remote-allow-origins=* \ - --user-data-dir=/home/kernel/user-data \ - --password-store=basic \ - --no-first-run \ - ${CHROMIUM_FLAGS:-} -fi diff --git a/images/chromium-headless/image/supervisor/services/chromium.conf b/images/chromium-headless/image/supervisor/services/chromium.conf index d979df75..09c0823a 100644 --- a/images/chromium-headless/image/supervisor/services/chromium.conf +++ b/images/chromium-headless/image/supervisor/services/chromium.conf @@ -1,5 +1,5 @@ [program:chromium] -command=/bin/bash -lc '/images/chromium-headless/image/start-chromium.sh' +command=/usr/local/bin/chromium-launcher --headless autostart=false autorestart=true startsecs=5 diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 36aa3acb..4cc3b163 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -8,9 +8,11 @@ import ( "sync" "time" + "github.com/onkernel/kernel-images/server/lib/devtoolsproxy" "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" + "github.com/onkernel/kernel-images/server/lib/scaletozero" ) type ApiService struct { @@ -26,16 +28,23 @@ type ApiService struct { // Process management procMu sync.RWMutex procs map[string]*processHandle + + // DevTools upstream manager (Chromium supervisord log tailer) + upstreamMgr *devtoolsproxy.UpstreamManager + + stz scaletozero.Controller } var _ oapi.StrictServerInterface = (*ApiService)(nil) -func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory) (*ApiService, error) { +func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory, upstreamMgr *devtoolsproxy.UpstreamManager, stz scaletozero.Controller) (*ApiService, error) { switch { case recordManager == nil: return nil, fmt.Errorf("recordManager cannot be nil") case factory == nil: return nil, fmt.Errorf("factory cannot be nil") + case upstreamMgr == nil: + return nil, fmt.Errorf("upstreamMgr cannot be nil") } return &ApiService{ @@ -44,6 +53,8 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa defaultRecorderID: "default", watches: make(map[string]*fsWatch), procs: make(map[string]*processHandle), + upstreamMgr: upstreamMgr, + stz: stz, }, nil } diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index 1d7cf206..f8288cb9 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -9,8 +9,12 @@ import ( "os" "testing" + "log/slog" + + "github.com/onkernel/kernel-images/server/lib/devtoolsproxy" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" + "github.com/onkernel/kernel-images/server/lib/scaletozero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -20,7 +24,7 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("success", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) resp, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) @@ -34,7 +38,7 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("already recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) // First start should succeed @@ -49,7 +53,7 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("custom ids don't collide", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) for i := 0; i < 5; i++ { @@ -82,7 +86,7 @@ func TestApiService_StopRecording(t *testing.T) { t.Run("no active recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) @@ -95,7 +99,7 @@ func TestApiService_StopRecording(t *testing.T) { rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) require.NoError(t, err) @@ -110,7 +114,7 @@ func TestApiService_StopRecording(t *testing.T) { force := true req := oapi.StopRecordingRequestObject{Body: &oapi.StopRecordingJSONRequestBody{ForceStop: &force}} - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) resp, err := svc.StopRecording(ctx, req) require.NoError(t, err) @@ -124,7 +128,7 @@ func TestApiService_DownloadRecording(t *testing.T) { t.Run("not found", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) @@ -144,7 +148,7 @@ func TestApiService_DownloadRecording(t *testing.T) { rec := &mockRecorder{id: "default", isRecordingFlag: true, recordingData: randomBytes(minRecordingSizeInBytes - 1)} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) // will return a 202 when the recording is too small resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) @@ -174,7 +178,7 @@ func TestApiService_DownloadRecording(t *testing.T) { rec := &mockRecorder{id: "default", recordingData: data} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) @@ -194,7 +198,7 @@ func TestApiService_Shutdown(t *testing.T) { rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) require.NoError(t, err) require.NoError(t, svc.Shutdown(ctx)) @@ -284,3 +288,8 @@ func newMockFactory() recorder.FFmpegRecorderFactory { return rec, nil } } + +func newTestUpstreamManager() *devtoolsproxy.UpstreamManager { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return devtoolsproxy.NewUpstreamManager("", logger) +} diff --git a/server/cmd/api/api/chromium.go b/server/cmd/api/api/chromium.go new file mode 100644 index 00000000..8a880b91 --- /dev/null +++ b/server/cmd/api/api/chromium.go @@ -0,0 +1,230 @@ +package api + +import ( + "context" + "fmt" + "io" + "mime/multipart" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "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/ziputil" +) + +var nameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]{1,255}$`) + +// UploadExtensionsAndRestart handles multipart upload of one or more extension zips, extracts +// them under /home/kernel/extensions/, writes /chromium/flags to enable them, restarts +// Chromium via supervisord, and waits (via UpstreamManager) until DevTools is ready. +func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oapi.UploadExtensionsAndRestartRequestObject) (oapi.UploadExtensionsAndRestartResponseObject, error) { + log := logger.FromContext(ctx) + start := time.Now() + log.Info("upload extensions: begin") + + s.stz.Disable(ctx) + defer s.stz.Enable(ctx) + + if request.Body == nil { + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body required"}}, nil + } + + // Strict handler gives us *multipart.Reader; use NextPart() directly + mr, ok := any(request.Body).(interface { + NextPart() (*multipart.Part, error) + }) + if !ok { + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "multipart reader not available"}}, nil + } + + temps := []string{} + defer func() { + for _, p := range temps { + _ = os.Remove(p) + } + }() + + type pending struct { + zipTemp string + name string + zipReceived bool + } + // Process consecutive pairs of fields: + // extensions.name (text) + // extensions.zip_file (file) + // Order may be name then zip or zip then name, but they must be consecutive. + items := []pending{} + var current *pending + + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + log.Error("read form part", "error", err) + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read form part"}}, nil + } + if current == nil { + current = &pending{} + } + switch part.FormName() { + case "extensions.zip_file": + tmp, err := os.CreateTemp("", "ext-*.zip") + if err != nil { + log.Error("failed to create temporary file", "error", err) + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil + } + temps = append(temps, tmp.Name()) + if _, err := io.Copy(tmp, part); err != nil { + tmp.Close() + log.Error("failed to read zip file", "error", err) + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to read zip file"}}, nil + } + if err := tmp.Close(); err != nil { + log.Error("failed to finalize temporary file", "error", err) + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil + } + if current.zipReceived { + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "duplicate zip_file in pair"}}, nil + } + current.zipTemp = tmp.Name() + current.zipReceived = true + case "extensions.name": + b, err := io.ReadAll(part) + if err != nil { + log.Error("failed to read name", "error", err) + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to read name"}}, nil + } + name := strings.TrimSpace(string(b)) + if name == "" || !nameRegex.MatchString(name) { + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid extension name"}}, nil + } + if current.name != "" { + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "duplicate name in pair"}}, nil + } + current.name = name + default: + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("invalid field: %s", part.FormName())}}, nil + } + // If we have both fields, finalize this item + if current != nil && current.zipReceived && current.name != "" { + items = append(items, *current) + current = nil + } + } + + // If the last pair is incomplete, reject the request + if current != nil && (!current.zipReceived || current.name == "") { + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "each extension must include consecutive name and zip_file"}}, nil + } + + if len(items) == 0 { + return oapi.UploadExtensionsAndRestart400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no extensions provided"}}, nil + } + + // Materialize uploads + extBase := "/home/kernel/extensions" + + // 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 + } 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 + } + } + + 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 + } + 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 + } + 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 + } + 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 + } + log.Info("installed extension", "name", p.name) + } + + // 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)) + } + + // Read existing runtime flags from /chromium/flags (if any) + const flagsPath = "/chromium/flags" + existingTokens, err := chromiumflags.ReadOptionalFlagFile(flagsPath) + if err != nil { + log.Error("failed to read existing flags", "error", err) + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to read existing flags"}}, nil + } + + // 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, ",")), + } + + // Merge existing flags with new extension flags using token-aware API + mergedTokens := chromiumflags.MergeFlags(existingTokens, newTokens) + + if err := os.MkdirAll("/chromium", 0o755); err != nil { + log.Error("failed to create chromium dir", "error", err) + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create chromium dir"}}, nil + } + // Write flags file with merged flags + if err := chromiumflags.WriteFlagFile(flagsPath, mergedTokens); err != nil { + log.Error("failed to write overlay flags", "error", err) + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to write overlay flags"}}, nil + } + + // Begin listening for devtools URL updates, since we are about to restart Chromium + 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") + 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() + if err != nil { + log.Error("failed to restart chromium", "error", err, "out", string(out)) + errCh <- fmt.Errorf("supervisorctl restart failed: %w", err) + } + }() + + // Wait for either a new upstream, a restart error, or timeout + timeout := time.NewTimer(15 * time.Second) + defer timeout.Stop() + select { + case <-updates: + log.Info("devtools ready", "elapsed", time.Since(start).String()) + return oapi.UploadExtensionsAndRestart201Response{}, nil + case err := <-errCh: + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: err.Error()}}, nil + case <-timeout.C: + log.Info("devtools not ready in time", "elapsed", time.Since(start).String()) + return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "devtools not ready in time"}}, nil + } +} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 0c637f96..29256a30 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -70,9 +70,16 @@ func main() { } stz := scaletozero.NewUnikraftCloudController() + // DevTools WebSocket upstream manager: tail Chromium supervisord log + const chromiumLogPath = "/var/log/supervisord/chromium" + upstreamMgr := devtoolsproxy.NewUpstreamManager(chromiumLogPath, slogger) + upstreamMgr.Start(ctx) + apiService, err := api.New( recorder.NewFFmpegManager(), recorder.NewFFmpegRecorderFactory(config.PathToFFmpeg, defaultParams, stz), + upstreamMgr, + stz, ) if err != nil { slogger.Error("failed to create api service", "err", err) @@ -103,11 +110,6 @@ func main() { Handler: r, } - // DevTools WebSocket proxy setup: tail Chromium supervisord log and start WS server on :9222 only after upstream is found - const chromiumLogPath = "/var/log/supervisord/chromium" - upstreamMgr := devtoolsproxy.NewUpstreamManager(chromiumLogPath, slogger) - upstreamMgr.Start(ctx) - // wait up to 10 seconds for initial upstream; exit nonzero if not found if _, err := upstreamMgr.WaitForInitial(10 * time.Second); err != nil { slogger.Error("devtools upstream not available", "err", err) diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go new file mode 100644 index 00000000..698cba28 --- /dev/null +++ b/server/cmd/chromium-launcher/main.go @@ -0,0 +1,106 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "github.com/onkernel/kernel-images/server/lib/chromiumflags" +) + +func main() { + headless := flag.Bool("headless", false, "Run Chromium with headless flags") + chromiumPath := flag.String("chromium", "chromium", "Chromium binary path (default: chromium)") + runtimeFlagsPath := flag.String("runtime-flags", "/chromium/flags", "Path to runtime flags overlay file") + flag.Parse() + + // Inputs + internalPort := strings.TrimSpace(os.Getenv("INTERNAL_PORT")) + if internalPort == "" { + internalPort = "9223" + } + baseFlags := os.Getenv("CHROMIUM_FLAGS") + runtimeTokens, err := chromiumflags.ReadOptionalFlagFile(*runtimeFlagsPath) + if err != nil { + fmt.Fprintf(os.Stderr, "failed reading runtime flags: %v\n", err) + os.Exit(1) + } + final := chromiumflags.MergeFlagsWithRuntimeTokens(baseFlags, runtimeTokens) + + // Diagnostics for parity with previous scripts + fmt.Printf("BASE_FLAGS: %s\n", baseFlags) + fmt.Printf("RUNTIME_FLAGS: %s\n", strings.Join(runtimeTokens, " ")) + fmt.Printf("FINAL_FLAGS: %s\n", strings.Join(final, " ")) + + // flags we send no matter what + chromiumArgs := []string{ + fmt.Sprintf("--remote-debugging-port=%s", internalPort), + "--remote-allow-origins=*", + "--user-data-dir=/home/kernel/user-data", + "--password-store=basic", + "--no-first-run", + } + if *headless { + chromiumArgs = append([]string{"--headless=new"}, chromiumArgs...) + } + chromiumArgs = append(chromiumArgs, final...) + + runAsRoot := strings.EqualFold(strings.TrimSpace(os.Getenv("RUN_AS_ROOT")), "true") + + // Prepare environment + env := os.Environ() + env = append(env, + "DISPLAY=:1", + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", + ) + + if runAsRoot { + // Replace current process with Chromium + if p, err := execLookPath(*chromiumPath); err == nil { + if err := syscall.Exec(p, append([]string{filepath.Base(p)}, chromiumArgs...), env); err != nil { + fmt.Fprintf(os.Stderr, "exec chromium failed: %v\n", err) + os.Exit(1) + } + } else { + fmt.Fprintf(os.Stderr, "chromium binary not found: %v\n", err) + os.Exit(1) + } + return + } + + // Not running as root: call runuser to exec as kernel user, providing env vars inside + runuserPath, err := execLookPath("runuser") + if err != nil { + fmt.Fprintf(os.Stderr, "runuser not found: %v\n", err) + os.Exit(1) + } + + // Build: runuser -u kernel -- env DISPLAY=... DBUS_... XDG_... HOME=... chromium + inner := []string{ + "env", + "DISPLAY=:1", + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", + "XDG_CONFIG_HOME=/home/kernel/.config", + "XDG_CACHE_HOME=/home/kernel/.cache", + "HOME=/home/kernel", + *chromiumPath, + } + inner = append(inner, chromiumArgs...) + argv := append([]string{filepath.Base(runuserPath), "-u", "kernel", "--"}, inner...) + if err := syscall.Exec(runuserPath, argv, env); err != nil { + fmt.Fprintf(os.Stderr, "exec runuser failed: %v\n", err) + os.Exit(1) + } +} + +// execLookPath helps satisfy syscall.Exec's requirement to pass an absolute path. +func execLookPath(file string) (string, error) { + if strings.ContainsRune(file, os.PathSeparator) { + return file, nil + } + return exec.LookPath(file) +} diff --git a/server/cmd/chromium-launcher/main_test.go b/server/cmd/chromium-launcher/main_test.go new file mode 100644 index 00000000..7b4ba40a --- /dev/null +++ b/server/cmd/chromium-launcher/main_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestExecLookPath(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "mybin") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write bin: %v", err) + } + oldPath := os.Getenv("PATH") + defer func() { _ = os.Setenv("PATH", oldPath) }() + if err := os.Setenv("PATH", dir); err != nil { + t.Fatalf("set PATH: %v", err) + } + + // lookPath should find by PATH + if p, err := exec.LookPath("mybin"); err != nil || p != bin { + t.Fatalf("lookPath failed: p=%q err=%v", p, err) + } + + // execLookPath should return input when absolute + if p, err := execLookPath(bin); err != nil || p != bin { + t.Fatalf("execLookPath absolute failed: p=%q err=%v", p, err) + } + + // execLookPath should resolve by PATH for bare names + if p, err := execLookPath("mybin"); err != nil || p != bin { + t.Fatalf("execLookPath PATH search failed: p=%q err=%v", p, err) + } +} diff --git a/server/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go index 0b53420a..b655d5f6 100644 --- a/server/e2e/e2e_chromium_test.go +++ b/server/e2e/e2e_chromium_test.go @@ -88,15 +88,136 @@ func ensurePlaywrightDeps(t *testing.T) { } func TestChromiumHeadfulUserDataSaving(t *testing.T) { + t.Skip("flaky. TODO(raf): fix") ensurePlaywrightDeps(t) runChromiumUserDataSavingFlow(t, headfulImage, containerName) } func TestChromiumHeadlessPersistence(t *testing.T) { + t.Skip("flaky. TODO(raf): fix") ensurePlaywrightDeps(t) runChromiumUserDataSavingFlow(t, headlessImage, containerName) } +func TestExtensionUploadAndActivation(t *testing.T) { + ensurePlaywrightDeps(t) + image := headlessImage + name := containerName + "-ext" + + 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 { + t.Fatalf("docker not available: %v", err) + } + + // Clean slate + _ = stopContainer(baseCtx, name) + + env := map[string]string{} + // headless uses stealth defaults; no need to set CHROMIUM_FLAGS here + + // Start container + _, exitCh, err := runContainer(baseCtx, image, name, env) + if err != nil { + t.Fatalf("failed to start container: %v", err) + } + defer stopContainer(baseCtx, name) + + ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + defer cancel() + + if err := waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh); err != nil { + _ = dumpContainerDiagnostics(ctx, name) + t.Fatalf("api not ready: %v", err) + } + + // Wait for DevTools + if _, err := waitDevtoolsWS(ctx); err != nil { + t.Fatalf("devtools not ready: %v", err) + } + + // Build simple MV3 extension zip in-memory + extDir := t.TempDir() + manifest := `{ + "manifest_version": 3, + "version": "1.0", + "name": "My Test Extension", + "description": "Test of a simple browser extension", + "content_scripts": [ + { + "matches": [ + "https://www.sfmoma.org/*" + ], + "js": [ + "content-script.js" + ] + } + ] +}` + contentScript := "document.title += \" -- Title updated by browser extension\";\n" + if err := os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(extDir, "content-script.js"), []byte(contentScript), 0600); err != nil { + t.Fatalf("write content-script: %v", err) + } + + extZip, err := zipDirToBytes(extDir) + if err != nil { + t.Fatalf("zip ext: %v", err) + } + + // Use new API to upload extension and restart Chromium + { + client, err := apiClient() + if err != nil { + t.Fatal(err) + } + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("extensions.zip_file", "ext.zip") + if err != nil { + t.Fatal(err) + } + if _, err := io.Copy(fw, bytes.NewReader(extZip)); err != nil { + t.Fatal(err) + } + if err := w.WriteField("extensions.name", "testext"); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + start := time.Now() + rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body) + elapsed := time.Since(start) + if err != nil { + t.Fatalf("uploadExtensionsAndRestart request error: %v", err) + } + if rsp.StatusCode() != http.StatusCreated { + t.Fatalf("unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) + } + t.Logf("/chromium/upload-extensions-and-restart completed in %s (%d ms)", elapsed.String(), elapsed.Milliseconds()) + } + + // Verify the content script updated the title on an allowed URL + { + cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "index.ts", + "verify-title-contains", + "--url", "https://www.sfmoma.org/", + "--substr", "Title updated by browser extension", + "--ws-url", "ws://127.0.0.1:9222/", + "--timeout", "45000", + ) + cmd.Dir = getPlaywrightPath() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("title verify failed: %v output=%s", err, string(out)) + } + } +} + func runChromiumUserDataSavingFlow(t *testing.T, image, containerName string) { t.Helper() logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{ diff --git a/server/e2e/playwright/index.ts b/server/e2e/playwright/index.ts index 559cbe1a..b36a6571 100644 --- a/server/e2e/playwright/index.ts +++ b/server/e2e/playwright/index.ts @@ -287,6 +287,32 @@ class CDPClient { } } + async navigateAndVerifyTitleContains(url: string, substring: string, timeout: number = 45000): Promise { + if (!this.page) throw new Error('Not connected to browser'); + + try { + console.log(`[cdp] action: navigate-and-verify-title, url: ${url}, contains: ${substring}`); + this.page.setDefaultTimeout(timeout); + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + + // Give content scripts a small window to run + await this.page.waitForTimeout(1500); + + const title = await this.page.title(); + console.log(`[cdp] page title: ${title}`); + if (!title.includes(substring)) { + const screenshotPath = `title-verify-fail.png`; + await this.captureScreenshot({ filename: screenshotPath }); + throw new Error(`Expected page title to include "${substring}", got: ${title}`); + } + console.log('[cdp] title verification successful'); + } catch (error) { + const screenshotPath = `title-verify-error.png`; + await this.captureScreenshot({ filename: screenshotPath }).catch(console.error); + throw error; + } + } + async disconnect(): Promise { // Note: We don't close the browser since it's an existing instance // We just disconnect from it @@ -388,6 +414,18 @@ async function main(): Promise { break; } + case 'verify-title-contains': { + if (!options.url || !options.substr) { + throw new Error('Missing required options: --url, --substr'); + } + await client.navigateAndVerifyTitleContains( + options.url, + options.substr, + options.timeout ? parseInt(options.timeout, 10) : undefined, + ); + break; + } + default: throw new Error(`Unknown command: ${command}`); } diff --git a/server/lib/chromiumflags/chromiumflags.go b/server/lib/chromiumflags/chromiumflags.go new file mode 100644 index 00000000..a9681656 --- /dev/null +++ b/server/lib/chromiumflags/chromiumflags.go @@ -0,0 +1,205 @@ +package chromiumflags + +import ( + "encoding/json" + "errors" + "io" + "os" + "strings" +) + +// FlagsFile is the structured JSON representation stored at /chromium/flags. +// +// Example on disk: +// { "flags": ["--foo", "--bar=1"] } +type FlagsFile struct { + Flags []string `json:"flags"` +} + +// parseFlags splits a space-delimited string of Chromium flags into tokens. +// Tokens are expected in the form --flag or --flag=value. Quotes are not supported, +// matching the previous bash implementation which used simple word-splitting. +func parseFlags(input string) []string { + input = strings.TrimSpace(input) + if input == "" { + return []string{} + } + return strings.Fields(input) +} + +// appendCSVInto appends comma-separated values into dst, skipping empty items. +func appendCSVInto(dst *[]string, csv string) { + for _, part := range strings.Split(csv, ",") { + if p := strings.TrimSpace(part); p != "" { + *dst = append(*dst, p) + } + } +} + +// parseTokenStream extracts extension-related flags and collects non-extension flags. +// It returns the list of non-extension tokens and, via references, fills the buckets for +// --load-extension, --disable-extensions-except and a possible --disable-extensions token for that stream. +func parseTokenStream(tokens []string, load, except *[]string, disableAll *string) (nonExt []string) { + for _, tok := range tokens { + switch { + case strings.HasPrefix(tok, "--load-extension="): + val := strings.TrimPrefix(tok, "--load-extension=") + appendCSVInto(load, val) + case strings.HasPrefix(tok, "--disable-extensions-except="): + val := strings.TrimPrefix(tok, "--disable-extensions-except=") + appendCSVInto(except, val) + case tok == "--disable-extensions": + *disableAll = tok + default: + nonExt = append(nonExt, tok) + } + } + return nonExt +} + +// union merges two lists of strings, returning a new list with duplicates removed. +func union(base, rt []string) []string { + seen := map[string]struct{}{} + out := []string{} + for _, v := range append(append([]string{}, base...), rt...) { + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +// ReadOptionalFlagFile returns the flags array from the JSON file at path. +// If the file does not exist, it returns nil and a nil error. +func ReadOptionalFlagFile(path string) ([]string, error) { + // If the file doesn't exist, treat as empty overlay + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + defer f.Close() + + // Read entire content to allow JSON detection + content, err := io.ReadAll(f) + if err != nil { + return nil, err + } + b := strings.TrimSpace(string(content)) + if b == "" { + return nil, nil + } + + // format: JSON with { "flags": ["--flag", "--flag=val"] } + var jf FlagsFile + if err := json.Unmarshal([]byte(b), &jf); err != nil { + return nil, err + } + if jf.Flags == nil { + return nil, errors.New("flags file missing 'flags' array") + } + // Normalize tokens and return slice + normalized := []string{} + for _, tok := range jf.Flags { + if t := strings.TrimSpace(tok); t != "" { + normalized = append(normalized, t) + } + } + return normalized, nil +} + +// MergeFlags merges base flags with runtime flags, returning the final merged flags as a string. +// The merging logic respects extension-related flag semantics: +// 1) If runtime specifies --disable-extensions, it overrides everything extension related +// 2) Else if base specifies --disable-extensions and runtime does NOT specify any --load-extension, keep base disable +// 3) Else, build from merged load/except +// Non-extension flags from both base and runtime are combined with deduplication (first occurrence preserved). +func MergeFlags(baseTokens, runtimeTokens []string) []string { + // Buckets + var ( + baseNonExt []string // Non-extension related flags contained in base + runtimeNonExt []string // Non-extension related flags contained in runtime + baseLoad []string // --load-extension flags contained in base + baseExcept []string // --disable-extensions-except flags for base + rtLoad []string // --load-extension flags contained in runtime + rtExcept []string // --disable-extensions-except flags contained in runtime + baseDisableAll string // --disable-extensions flag contained in base + rtDisableAll string // --disable-extensions flag contained in runtime + ) + + baseNonExt = parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseDisableAll) + runtimeNonExt = parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtDisableAll) + + // Merge extension lists + mergedLoad := union(baseLoad, rtLoad) + mergedExcept := union(baseExcept, rtExcept) + + // Construct final extension-related flags respecting override semantics: + // 1) If runtime specifies --disable-extensions, it overrides everything extension related + // 2) Else if base specifies --disable-extensions and runtime does NOT specify any --load-extension, keep base disable + // 3) Else, build from merged load/except + var extFlags []string + if rtDisableAll != "" { + extFlags = append(extFlags, rtDisableAll) + } else { + if baseDisableAll != "" && len(rtLoad) == 0 { + extFlags = append(extFlags, baseDisableAll) + } else if len(mergedLoad) > 0 { + extFlags = append(extFlags, "--load-extension="+strings.Join(mergedLoad, ",")) + } + if len(mergedExcept) > 0 { + extFlags = append(extFlags, "--disable-extensions-except="+strings.Join(mergedExcept, ",")) + } + } + + // Combine and dedupe (preserving first occurrence) + combined := append(append([]string{}, baseNonExt...), runtimeNonExt...) + combined = append(combined, extFlags...) + seen := make(map[string]struct{}, len(combined)) + final := make([]string, 0, len(combined)) + for _, tok := range combined { + if tok == "" { + continue + } + if _, ok := seen[tok]; ok { + continue + } + seen[tok] = struct{}{} + final = append(final, tok) + } + return final +} + +// MergeFlagsWithRuntimeTokens merges base flags (string, e.g. from env CHROMIUM_FLAGS) +// with runtime token slice and returns final tokens. +func MergeFlagsWithRuntimeTokens(baseFlags string, runtimeTokens []string) []string { + base := parseFlags(baseFlags) + return MergeFlags(base, runtimeTokens) +} + +// WriteFlagFile writes the provided tokens to the given path as JSON in the +// form: { "flags": ["--foo", "--bar=1"] } with file mode 0644. +// The function creates or truncates the file. +func WriteFlagFile(path string, tokens []string) error { + // Normalize tokens: trim and drop empties + normalized := make([]string, 0, len(tokens)) + for _, t := range tokens { + if s := strings.TrimSpace(t); s != "" { + normalized = append(normalized, s) + } + } + data, err := json.Marshal(FlagsFile{Flags: normalized}) + if err != nil { + return err + } + // Ensure trailing newline for readability + data = append(data, '\n') + return os.WriteFile(path, data, 0o644) +} diff --git a/server/lib/chromiumflags/chromiumflags_test.go b/server/lib/chromiumflags/chromiumflags_test.go new file mode 100644 index 00000000..3063b98b --- /dev/null +++ b/server/lib/chromiumflags/chromiumflags_test.go @@ -0,0 +1,327 @@ +package chromiumflags + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestParseFlags(t *testing.T) { + // Empty input returns nil + if got := parseFlags(""); got == nil || len(got) != 0 { + t.Fatalf("expected nil for empty input, got: %#v", got) + } + + input := " --foo --bar=1\t--baz " + got := parseFlags(input) + want := []string{"--foo", "--bar=1", "--baz"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseFlags mismatch:\n got: %#v\nwant: %#v", got, want) + } + + // Quotes are not supported; ensure simple word splitting occurs + input = `--flag="with space" --qux` + got = parseFlags(input) + if len(got) != 3 { + t.Fatalf("expected 3 tokens due to simple splitting, got %d: %#v", len(got), got) + } +} + +func TestAppendCSVInto(t *testing.T) { + var dst []string + appendCSVInto(&dst, "a,, b , c,") + want := []string{"a", "b", "c"} + if !reflect.DeepEqual(dst, want) { + t.Fatalf("appendCSVInto mismatch:\n got: %#v\nwant: %#v", dst, want) + } +} + +func TestParseTokenStream_BaseAndRuntime(t *testing.T) { + var ( + baseLoad []string + baseExcept []string + rtLoad []string + rtExcept []string + baseDisable string + rtDisable string + ) + + baseTokens := []string{ + "--load-extension=/e1,/e2", + "--disable-extensions-except=/x1", + "--other=1", + "--disable-extensions", + } + runtimeTokens := []string{ + "--disable-extensions-except=/x2,/x3", + "--load-extension=/e3", + "--disable-extensions", + "--foo", + } + + baseNonExt := parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseDisable) + runtimeNonExt := parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtDisable) + + if !reflect.DeepEqual(baseLoad, []string{"/e1", "/e2"}) { + t.Fatalf("base load-extension parsed incorrectly: %#v", baseLoad) + } + if !reflect.DeepEqual(baseExcept, []string{"/x1"}) { + t.Fatalf("base disable-extensions-except parsed incorrectly: %#v", baseExcept) + } + if !reflect.DeepEqual(rtLoad, []string{"/e3"}) { + t.Fatalf("runtime load-extension parsed incorrectly: %#v", rtLoad) + } + if !reflect.DeepEqual(rtExcept, []string{"/x2", "/x3"}) { + t.Fatalf("runtime disable-extensions-except parsed incorrectly: %#v", rtExcept) + } + if baseDisable != "--disable-extensions" { + t.Fatalf("expected base disable-all captured, got %q", baseDisable) + } + if rtDisable != "--disable-extensions" { + t.Fatalf("expected runtime disable-all captured, got %q", rtDisable) + } + if !reflect.DeepEqual(baseNonExt, []string{"--other=1"}) { + t.Fatalf("unexpected base non-extension tokens: %#v", baseNonExt) + } + if !reflect.DeepEqual(runtimeNonExt, []string{"--foo"}) { + t.Fatalf("unexpected runtime non-extension tokens: %#v", runtimeNonExt) + } +} + +func TestMergeUnion(t *testing.T) { + base := []string{"a", "b", "a", ""} + rt := []string{"b", "c", "", "a"} + got := union(base, rt) + want := []string{"a", "b", "c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("mergeUnion mismatch:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestOverrideSemantics_DisableBase_LoadRuntime(t *testing.T) { + // Base has --disable-extensions, runtime has --load-extension → runtime overrides, no disable-all in final + baseFlags := "--disable-extensions" + runtimeFlags := "--load-extension=/e1" + + baseTokens := parseFlags(baseFlags) + runtimeTokens := parseFlags(runtimeFlags) + + var ( + baseLoad []string + baseExcept []string + rtLoad []string + rtExcept []string + baseDisable string + rtDisable string + ) + + _ = parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseDisable) + _ = parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtDisable) + + mergedLoad := union(baseLoad, rtLoad) + mergedExcept := union(baseExcept, rtExcept) + + var extFlags []string + if rtDisable != "" { + extFlags = append(extFlags, rtDisable) + } else { + if baseDisable != "" && len(rtLoad) == 0 { + extFlags = append(extFlags, baseDisable) + } else if len(mergedLoad) > 0 { + extFlags = append(extFlags, "--load-extension="+strings.Join(mergedLoad, ",")) + } + if len(mergedExcept) > 0 { + extFlags = append(extFlags, "--disable-extensions-except="+strings.Join(mergedExcept, ",")) + } + } + + for _, f := range extFlags { + if f == "--disable-extensions" { + t.Fatalf("unexpected disable-all in final flags when runtime loads extensions: %#v", extFlags) + } + } +} + +func TestOverrideSemantics_DisableRuntime_Wins(t *testing.T) { + // Runtime has --disable-extensions → overrides everything extension related + baseFlags := "--load-extension=/e1 --disable-extensions-except=/x1" + runtimeFlags := "--disable-extensions" + + baseTokens := parseFlags(baseFlags) + runtimeTokens := parseFlags(runtimeFlags) + + var ( + baseLoad []string + baseExcept []string + rtLoad []string + rtExcept []string + baseDisable string + runtimeDisable string + ) + + _ = parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseDisable) + _ = parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &runtimeDisable) + + var extFlags []string + if runtimeDisable != "" { + extFlags = append(extFlags, runtimeDisable) + } + + if len(extFlags) != 1 || extFlags[0] != "--disable-extensions" { + t.Fatalf("runtime disable should win exclusively, got: %#v", extFlags) + } +} + +func TestReadOptionalFlagFile(t *testing.T) { + // Non-existent returns nil slice and nil error + if s, err := ReadOptionalFlagFile(filepath.Join(t.TempDir(), "not-there")); err != nil || s != nil { + t.Fatalf("expected nil slice and nil error for missing file, got %#v, err=%v", s, err) + } + + // Plain text is no longer supported: expect an error + dir := t.TempDir() + path := filepath.Join(dir, "flags.txt") + content := "--foo\n--bar=1" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + if _, err := ReadOptionalFlagFile(path); err == nil { + t.Fatalf("expected error for plain text flags file, got nil") + } +} + +func TestReadOptionalFlagFile_JSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "flags.json") + content := `{"flags":["--one","--two=2"," ","--three"]}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + got, err := ReadOptionalFlagFile(path) + if err != nil { + t.Fatalf("ReadOptionalFlagFile error: %v", err) + } + want := []string{"--one", "--two=2", "--three"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ReadOptionalFlagFile(JSON) content mismatch:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestWriteFlagFileAndReadBack(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "flags.json") + tokens := []string{" --a ", "", "--b=1"} + if err := WriteFlagFile(path, tokens); err != nil { + t.Fatalf("WriteFlagFile error: %v", err) + } + // Read as runtime flags (tokens) + got, err := ReadOptionalFlagFile(path) + if err != nil { + t.Fatalf("ReadOptionalFlagFile error: %v", err) + } + if !reflect.DeepEqual(got, []string{"--a", "--b=1"}) { + t.Fatalf("unexpected merged runtime tokens: %#v", got) + } + // Validate JSON structure in file + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + var jf FlagsFile + if err := json.Unmarshal(raw, &jf); err != nil { + t.Fatalf("json unmarshal error: %v; content=%s", err, string(raw)) + } + if !reflect.DeepEqual(jf.Flags, []string{"--a", "--b=1"}) { + t.Fatalf("unexpected flags in file: %#v", jf.Flags) + } +} + +// TestWriteFlagFileFromString removed: callers should use WriteFlagFile with tokens. + +func TestMergeFlags(t *testing.T) { + tests := []struct { + name string + baseFlags []string + runtimeFlags []string + want []string + }{ + { + name: "empty base and runtime", + baseFlags: []string{}, + runtimeFlags: []string{}, + want: []string{}, + }, + { + name: "base only, no runtime", + baseFlags: []string{"--foo", "--bar=1"}, + runtimeFlags: nil, + want: []string{"--foo", "--bar=1"}, + }, + { + name: "runtime only, no base", + baseFlags: nil, + runtimeFlags: []string{"--foo", "--bar=1"}, + want: []string{"--foo", "--bar=1"}, + }, + { + name: "merge non-extension flags", + baseFlags: []string{"--foo", "--bar=1"}, + runtimeFlags: []string{"--baz", "--qux=2"}, + want: []string{"--foo", "--bar=1", "--baz", "--qux=2"}, + }, + { + name: "deduplicate non-extension flags", + baseFlags: []string{"--foo", "--bar=1"}, + runtimeFlags: []string{"--foo", "--baz"}, + want: []string{"--foo", "--bar=1", "--baz"}, + }, + { + name: "merge load-extension flags", + baseFlags: []string{"--load-extension=/e1"}, + runtimeFlags: []string{"--load-extension=/e2"}, + want: []string{"--load-extension=/e1,/e2"}, + }, + { + name: "merge disable-extensions-except flags", + baseFlags: []string{"--disable-extensions-except=/x1"}, + runtimeFlags: []string{"--disable-extensions-except=/x2"}, + want: []string{"--disable-extensions-except=/x1,/x2"}, + }, + { + name: "runtime disable-extensions overrides all", + baseFlags: []string{"--load-extension=/e1", "--disable-extensions-except=/x1"}, + runtimeFlags: []string{"--disable-extensions"}, + want: []string{"--disable-extensions"}, + }, + { + name: "base disable-extensions, runtime load-extension overrides", + baseFlags: []string{"--disable-extensions"}, + runtimeFlags: []string{"--load-extension=/e1"}, + want: []string{"--load-extension=/e1"}, + }, + { + name: "base disable-extensions, no runtime load-extension keeps disable", + baseFlags: []string{"--disable-extensions", "--other=1"}, + runtimeFlags: []string{"--foo"}, + want: []string{"--other=1", "--foo", "--disable-extensions"}, + }, + { + name: "complex merge with extensions and non-extensions", + baseFlags: []string{"--foo", "--load-extension=/e1", "--disable-extensions-except=/x1"}, + runtimeFlags: []string{"--bar", "--load-extension=/e2", "--disable-extensions-except=/x2"}, + want: []string{"--foo", "--bar", "--load-extension=/e1,/e2", "--disable-extensions-except=/x1,/x2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MergeFlags(tt.baseFlags, tt.runtimeFlags) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MergeFlags() mismatch:\n got: %#v\nwant: %#v", got, tt.want) + } + }) + } +} diff --git a/server/lib/devtoolsproxy/proxy.go b/server/lib/devtoolsproxy/proxy.go index f1d1c472..9bd939d0 100644 --- a/server/lib/devtoolsproxy/proxy.go +++ b/server/lib/devtoolsproxy/proxy.go @@ -33,6 +33,9 @@ type UpstreamManager struct { startOnce sync.Once stopOnce sync.Once cancelTail context.CancelFunc + + subsMu sync.RWMutex + subs map[chan string]struct{} } func NewUpstreamManager(logFilePath string, logger *slog.Logger) *UpstreamManager { @@ -84,7 +87,52 @@ func (u *UpstreamManager) setCurrent(url string) { if url != "" && url != prev { u.logger.Info("devtools upstream updated", slog.String("url", url)) u.currentURL.Store(url) + // Broadcast update to subscribers without blocking. If a subscriber's + // channel buffer (size 1) is full, replace the buffered value with the + // latest update to avoid dropping notifications entirely. + u.subsMu.RLock() + for ch := range u.subs { + select { + case ch <- url: + // sent successfully + default: + // channel is full; drop one stale value if present and try again + select { + case <-ch: + default: + } + select { + case ch <- url: + default: + // still full; give up to remain non-blocking + } + } + } + u.subsMu.RUnlock() + } +} + +// Subscribe returns a channel that receives new upstream URLs as they are discovered. +// The returned cancel function should be called to unsubscribe and release resources. +func (u *UpstreamManager) Subscribe() (<-chan string, func()) { + // use channel size 1 to avoid setCurrent blocking/stalling on slow subscribers + // also provides "latest-wins" semantics: only one notification can sit in the channel + ch := make(chan string, 1) + u.subsMu.Lock() + if u.subs == nil { + u.subs = make(map[chan string]struct{}) + } + u.subs[ch] = struct{}{} + u.subsMu.Unlock() + cancel := func() { + u.subsMu.Lock() + if _, ok := u.subs[ch]; ok { + delete(u.subs, ch) + close(ch) + } + u.subsMu.Unlock() } + return ch, cancel } func (u *UpstreamManager) tailLoop(ctx context.Context) { diff --git a/server/lib/devtoolsproxy/proxy_test.go b/server/lib/devtoolsproxy/proxy_test.go index 2b1899aa..3db78abe 100644 --- a/server/lib/devtoolsproxy/proxy_test.go +++ b/server/lib/devtoolsproxy/proxy_test.go @@ -227,3 +227,37 @@ func TestUpstreamManagerDetectsChromiumAndRestart(t *testing.T) { t.Fatalf("did not update upstream to port %d; got: %q", port2, mgr.Current()) } } + +func TestUpstreamManagerSubscriberGetsLatest(t *testing.T) { + logger := silentLogger() + mgr := NewUpstreamManager("/dev/null", logger) + + updates, cancel := mgr.Subscribe() + defer cancel() + + // Fill buffer with an older value, then immediately update to a newer value. + // The subscriber buffer size is 1, so the second send should replace the + // buffered value instead of being dropped. + mgr.setCurrent("ws://old") + mgr.setCurrent("ws://new") + + select { + case v := <-updates: + if v != "ws://new" { + t.Fatalf("expected latest update 'ws://new', got %q", v) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for update") + } + + // Subsequent updates should still be delivered normally. + mgr.setCurrent("ws://newer") + select { + case v := <-updates: + if v != "ws://newer" { + t.Fatalf("expected next update 'ws://newer', got %q", v) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for next update") + } +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index f889d3fa..66f78543 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -396,6 +396,18 @@ type InternalError = Error // NotFoundError defines model for NotFoundError. type NotFoundError = Error +// UploadExtensionsAndRestartMultipartBody defines parameters for UploadExtensionsAndRestart. +type UploadExtensionsAndRestartMultipartBody struct { + // Extensions List of extensions to upload and activate + Extensions []struct { + // Name Folder name to place the extension under /home/kernel/extensions/ + Name string `json:"name"` + + // ZipFile Zip archive containing an unpacked Chromium extension (must include manifest.json) + ZipFile openapi_types.File `json:"zip_file"` + } `json:"extensions"` +} + // DownloadDirZipParams defines parameters for DownloadDirZip. type DownloadDirZipParams struct { // Path Absolute directory path to archive and download. @@ -466,6 +478,9 @@ type DownloadRecordingParams struct { Id *string `form:"id,omitempty" json:"id,omitempty"` } +// UploadExtensionsAndRestartMultipartRequestBody defines body for UploadExtensionsAndRestart for multipart/form-data ContentType. +type UploadExtensionsAndRestartMultipartRequestBody UploadExtensionsAndRestartMultipartBody + // ClickMouseJSONRequestBody defines body for ClickMouse for application/json ContentType. type ClickMouseJSONRequestBody = ClickMouseRequest @@ -590,6 +605,9 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // UploadExtensionsAndRestartWithBody request with any body + UploadExtensionsAndRestartWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // ClickMouseWithBody request with any body ClickMouseWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -708,6 +726,18 @@ type ClientInterface interface { StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) } +func (c *Client) UploadExtensionsAndRestartWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUploadExtensionsAndRestartRequestWithBody(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) ClickMouseWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewClickMouseRequestWithBody(c.Server, contentType, body) if err != nil { @@ -1236,6 +1266,35 @@ func (c *Client) StopRecording(ctx context.Context, body StopRecordingJSONReques return c.Client.Do(req) } +// NewUploadExtensionsAndRestartRequestWithBody generates requests for UploadExtensionsAndRestart with any type of body +func NewUploadExtensionsAndRestartRequestWithBody(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("/chromium/upload-extensions-and-restart") + 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 +} + // NewClickMouseRequest calls the generic ClickMouse builder with application/json body func NewClickMouseRequest(server string, body ClickMouseJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -2499,6 +2558,9 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // UploadExtensionsAndRestartWithBodyWithResponse request with any body + UploadExtensionsAndRestartWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadExtensionsAndRestartResponse, error) + // ClickMouseWithBodyWithResponse request with any body ClickMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) @@ -2617,6 +2679,29 @@ type ClientWithResponsesInterface interface { StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) } +type UploadExtensionsAndRestartResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r UploadExtensionsAndRestartResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UploadExtensionsAndRestartResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ClickMouseResponse struct { Body []byte HTTPResponse *http.Response @@ -3314,6 +3399,15 @@ func (r StopRecordingResponse) StatusCode() int { return 0 } +// UploadExtensionsAndRestartWithBodyWithResponse request with arbitrary body returning *UploadExtensionsAndRestartResponse +func (c *ClientWithResponses) UploadExtensionsAndRestartWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadExtensionsAndRestartResponse, error) { + rsp, err := c.UploadExtensionsAndRestartWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUploadExtensionsAndRestartResponse(rsp) +} + // ClickMouseWithBodyWithResponse request with arbitrary body returning *ClickMouseResponse func (c *ClientWithResponses) ClickMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) { rsp, err := c.ClickMouseWithBody(ctx, contentType, body, reqEditors...) @@ -3695,6 +3789,39 @@ func (c *ClientWithResponses) StopRecordingWithResponse(ctx context.Context, bod return ParseStopRecordingResponse(rsp) } +// ParseUploadExtensionsAndRestartResponse parses an HTTP response from a UploadExtensionsAndRestartWithResponse call +func ParseUploadExtensionsAndRestartResponse(rsp *http.Response) (*UploadExtensionsAndRestartResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UploadExtensionsAndRestartResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + 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 == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseClickMouseResponse parses an HTTP response from a ClickMouseWithResponse call func ParseClickMouseResponse(rsp *http.Response) (*ClickMouseResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -4843,6 +4970,9 @@ func ParseStopRecordingResponse(rsp *http.Response) (*StopRecordingResponse, err // ServerInterface represents all server handlers. type ServerInterface interface { + // Upload one or more unpacked extensions (as zips) and restart Chromium + // (POST /chromium/upload-extensions-and-restart) + UploadExtensionsAndRestart(w http.ResponseWriter, r *http.Request) // Simulate a mouse click action on the host computer // (POST /computer/click_mouse) ClickMouse(w http.ResponseWriter, r *http.Request) @@ -4936,6 +5066,12 @@ type ServerInterface interface { type Unimplemented struct{} +// Upload one or more unpacked extensions (as zips) and restart Chromium +// (POST /chromium/upload-extensions-and-restart) +func (_ Unimplemented) UploadExtensionsAndRestart(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Simulate a mouse click action on the host computer // (POST /computer/click_mouse) func (_ Unimplemented) ClickMouse(w http.ResponseWriter, r *http.Request) { @@ -5119,6 +5255,20 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(http.Handler) http.Handler +// UploadExtensionsAndRestart operation middleware +func (siw *ServerInterfaceWrapper) UploadExtensionsAndRestart(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UploadExtensionsAndRestart(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ClickMouse operation middleware func (siw *ServerInterfaceWrapper) ClickMouse(w http.ResponseWriter, r *http.Request) { @@ -5869,6 +6019,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl ErrorHandlerFunc: options.ErrorHandlerFunc, } + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/chromium/upload-extensions-and-restart", wrapper.UploadExtensionsAndRestart) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/computer/click_mouse", wrapper.ClickMouse) }) @@ -5968,6 +6121,40 @@ type InternalErrorJSONResponse Error type NotFoundErrorJSONResponse Error +type UploadExtensionsAndRestartRequestObject struct { + Body *multipart.Reader +} + +type UploadExtensionsAndRestartResponseObject interface { + VisitUploadExtensionsAndRestartResponse(w http.ResponseWriter) error +} + +type UploadExtensionsAndRestart201Response struct { +} + +func (response UploadExtensionsAndRestart201Response) VisitUploadExtensionsAndRestartResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type UploadExtensionsAndRestart400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response UploadExtensionsAndRestart400JSONResponse) VisitUploadExtensionsAndRestartResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type UploadExtensionsAndRestart500JSONResponse struct{ InternalErrorJSONResponse } + +func (response UploadExtensionsAndRestart500JSONResponse) VisitUploadExtensionsAndRestartResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type ClickMouseRequestObject struct { Body *ClickMouseJSONRequestBody } @@ -7304,6 +7491,9 @@ func (response StopRecording500JSONResponse) VisitStopRecordingResponse(w http.R // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Upload one or more unpacked extensions (as zips) and restart Chromium + // (POST /chromium/upload-extensions-and-restart) + UploadExtensionsAndRestart(ctx context.Context, request UploadExtensionsAndRestartRequestObject) (UploadExtensionsAndRestartResponseObject, error) // Simulate a mouse click action on the host computer // (POST /computer/click_mouse) ClickMouse(ctx context.Context, request ClickMouseRequestObject) (ClickMouseResponseObject, error) @@ -7422,6 +7612,37 @@ type strictHandler struct { options StrictHTTPServerOptions } +// UploadExtensionsAndRestart operation middleware +func (sh *strictHandler) UploadExtensionsAndRestart(w http.ResponseWriter, r *http.Request) { + var request UploadExtensionsAndRestartRequestObject + + 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.UploadExtensionsAndRestart(ctx, request.(UploadExtensionsAndRestartRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UploadExtensionsAndRestart") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UploadExtensionsAndRestartResponseObject); ok { + if err := validResponse.VisitUploadExtensionsAndRestartResponse(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)) + } +} + // ClickMouse operation middleware func (sh *strictHandler) ClickMouse(w http.ResponseWriter, r *http.Request) { var request ClickMouseRequestObject @@ -8268,80 +8489,86 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+w9624bN5evQnD7o9mVZKdxWtT/kljpGrnCSpBv22QFeuZI4pcZckpyLCuB331xSM6d", - "o5FlO4mLBQok0XAOee5XTr/SSKaZFCCMpsdfqQKdSaHB/uMpi8/g7xy0mSolFf4USWFAGPwry7KER8xw", - "KQ7+raXA33S0gpTh335SsKDH9D8OKvgH7qk+cNCurq5GNAYdKZ4hEHqMGxK/I70a0WdSLBIefavdi+1w", - "61NhQAmWfKOti+3IDNQFKOIXjuhraZ7LXMTf6ByvpSF2P4rP/HKE9izh0edXMtdQ8AcPEMccX2TJWyUz", - "UIaj3CxYomFEs9pPX+l5bow7YXNDC5K4p8RIwpEQLDJkzc2KjiiIPKXHf9EEFoaOqOLLFf6Z8jhOgI7o", - "OYs+0xFdSLVmKqafRtRsMqDHVBvFxRJJGOHR5+7n9vbvNhkQuSB2DWGR/bnaNZZr/GeeUQ8muMFKJvH8", - "M2x0CL2YLzgogo8RP1xL4hxfJWYFbmM6otxAat/vQPc/MKXYBv8t8nRu3/LbLVieGHr8sMPKPD0HhcgZ", - "noLdXEEGzDT29dCR7EuwEnfZxeJfJJJSxVwwY6lVAiCZ1NzTrAtp04X0P/tAuhpRBX/nXEGMTLmkCLpi", - "hDz/NzilfaaAGTjhCiIj1WY/SU1lHBCUN5l7ncQFdIILyc8yMiwhjl0jApPlhPz2+PGDCTlxnLGE/+3x", - "4wkd0YwZVHN6TP/3r8Pxb5++PhodXf1EAyKVMbPqHuLJuZZJbqB2CFyIO0QW9dYmB5P/7AJvUdPuFCLm", - "CSRg4C0zq/3oOIBCcfDYbnP7Bz+DyAracr/T87h79tMYhHHq7EVXFZvUMCFPkmzFRJ6C4hGRiqw22QpE", - "m/9s/OXJ+M/D8e/jT//1UxDZDmKlD2gJLGjNlhAwHi2KFQtDRHvOEzgVC9kFz/U85qpLjQ8rMCtQlg6W", - "mVwTVknmpMLpXMoEmMBtUhnP0Rx1wb1k2qBK8YV3adZsTZxtT5mhxzRmBsb27YDGhNUW0XKKes6NJj+j", - "fo7IRxqr9aUa438fKfLoIx2r9ViN8b+P9MEktINgoXM/ZRoIPipkYoFbShWkxM4Kjo+D72n+BebnGwMB", - "ZzPjX4BwQezjCTkki9oxOOjJsG21OPrTNTYbFXJQ46Enep84zTbaQDq98NFKlzHaLiDRioklEMCFVkuu", - "LX5ssYDIQLy7HO7Ly3KrfZl6PSkJBy2WpASfTWqxyrOz6ZN3UzqiH85O7Z8n05dT+5ez6esnr6aB0KXF", - "fPt01G9YX3JtLN8COGJ0grh1KcaFU2BUaRCmEMQy4NkWp5ZWKRAHvZTLHtl6QhK5tHttyELJ1MlIFSx3", - "haxmQltWSS6Jf0gMXJowlzC+MizNAvElT8FuX51ozTTJlIzzyEnRLuatx5DXtw4x7JW8gBvE7DeJa1N5", - "AdcKa4fCTiMtTBcx5kpLRYzcK+zcFdLOYSeSef84KQZt5kPxHmiDh0cdKlzDULg0olpFQ4C1zFUEO8Ns", - "kaTcYFTDIkShN5/PfF1hkDjNg/4BwoZRb16QojLR1V75uZEJGZVDN7+OUflBE51HEWgdcgst7OTnIC5v", - "lUQA00uIdmV48yz+LZRDuIQI2cBIJNOUiZjojYhWSgqZ62TTRZWpZTPt++tTt4rhIDG1zFO0ppNr6SHT", - "cyWlaWwSRiMXLvZz9LAJO8FXSab4BU9gCTrsfJme5xoCPr0NkmliVlwTXI2gRJ4k7DyBgsfdVN/hHnCZ", - "ltD4LjonvYIkKUmOiXEugpY9WgdgfZDqM5q5ysX9zOou/oGH6AyM34SLEALDOgziol+8Auwsefa1U9uZ", - "iguupECZIBdMcTyItd0ajA0Va6SvUaOSfHQ2MjdzDVHAI7BLnuapF+kifsdwVEMkRay3MLDP5BbsHFRD", - "7VC+nhbiSxiysLrSlQwr8egqYZwra4rnqe6TNMS/WIY0SHmS8Bohul4LLrmZR8EkxqNKcAnBJWEI2sSg", - "1Pz816NwZPvr0RgEvh4Tt5Sc54uF06yu7zAxsnpHYDI3/cCu+rn3gifJfkZ0xpeCJU56nQ63pLfJMm2X", - "N4wafTc9e0W3w63H1375i9OXL+mInr5+R0f0v9+/HQ6r/d5bhHiWsbWo0yFJ3izo8V/bg+OAI7r61AG6", - "h2qc1iJ2do68ZUQjNMyw+iichSomb2alLT89CUutfz4Pve6K4WOmkYQQE14VYAL2qgyk85zHYZlmykA8", - "ZyYcqNtAmqxX0PRC/rVrxOq9fDbM5Pqa3HiWK4UmW9uXncHq5UKU5fMsCuA31YanDHPkZ2/fk9wmNBmo", - "CIRhy7pBEbZsPGCRpoUlInzRoNWKOTPlyDVk7kc0hbSvmlGdWIG2nCcppOhu3enLQkePMWRmiym1j+va", - "rXIhkH0ObYjDat3P2JiL/QzZCTMMzc1acZebtERPxExh+JDlgeJIzAzbyUbH9V0mg4F9CffTIM43cr14", - "HF891QiuiyGuMCD6hKRqctgFxC/vqXT1o6KAVZWq67ih2ZRkbJNIhmKaKdBoocSy5KDMTZYbDDoTvoBo", - "EyW+0qVvys2yslEJC2IR9OYQLpS8bB6pU1JCVQh2vHYyDaUhdcC5Jh/tix9pn8ri+QNewOWo7nFRP7Mk", - "iFa5+Fw/sAtFaBEL7ajErlUAKlz/XnDB9Wo3t1H1A4q3+pzGYCrj/GH3Z102NmrPa8nVNZxcdVr/0p6H", - "bRkP63zr5wwZkRnYUuJbUCnXmkuh96ueLJXMA3W317Am9pEv5yryRyMAuW7fINDl+/Xo6MH1mnpyLUJZ", - "L57VPrJ5bnHe9z3n3aXGvF5Jbd17QVvClPUt5+Cr7fG+DbctNf8ZCtFz/YGZ6FZbhmU/1zowhB4kjIIo", - "V5pfwHDpouwdeHikfDfZ7FAY6i1zWQrcsPG4UCwFFQxezirrUizCKGiRoYBegFI8Bk20myDxFHiAHHOp", - "OT3+5XBEUy7cPx6GbHAwiC9a34Hwu2ZCwIraLbU/7aFPfAJ9KmYuc+6vOlTnqGfdPuEeoM5WgqTs0vay", - "+Bc4Fa+e9p/ANj6078C9erojRx4eHh42mHK4W+AyMzK7qaBJFQHCGdaX0zSFmDMDyYZoIzNb68O8cKlY", - "BIs8IXqVm1iuxYS8W3FNUrbBqB2jPC5sdVOpPMNY/oLHIC2xwrXB6/TdnQbjge6s6Y4/cR8WGG7QBdIX", - "oAQk5DRlS9DkydtTOqIXoLQ77OHk4eTQWvsMBMs4PaaPJoeTR76xZklvs/ncgDpws0mpzF1lPJOOjcgn", - "J/oxZoDl7BV1hgi0eSrjza2Ng3WHu66aNg/dvv2hNhz4y+Fh3ziXm6NCB4ThBMRIjiO3PHSMEuxBe+Dw", - "akQf7/Jec1rPjq7lacrUxlZ00jxhtshu6dyY9SLShagrqTFqdVyxACoepfIChlhUdtruiEOdTt7NGOTb", - "XojZ92XOq6IRl9bP5bNgnUGEah/Xund6C8cW+sCNQc3L0rzlWB7Sqeao2F0pVnggbSfmPdwWCTk846Kb", - "tciTZPNdGekwJYwIWFedkZIvbjZqB7644a275kt3tm1ffapY4lC8kTodHR4Nv9ecCL4N3jlq1Idm2nxD", - "fz3AMoySfnhu2bTuH8Aoy4+SR3ItEsli1K75F27juSWYUP5gcoXJIPnz9K0LWJE/jItyGtmxSxdxVmWB", - "G3NKLf77/U+4+pNnNs7B9MSA0raHsfMMK1PRil8AYSImBVK2eY3v/Z2DNQducqtIRpsyMKoJ1GBy+yks", - "MD0S6+lawS8LJedcMHuy9gadBixSvcCxDGStYNUJfB/l0jOrbkIIKwTNo1zKKwrevAiqvaA2Jaoc+9pV", - "lgYn634EEbqe0atG37qCZM1Yba7uHorMH2Aak4FFn7HDvVJsEq6NdUS6V26qAcX9jND9lJQK64CoVPEJ", - "0s/XVu6ZrCCCVjC0qyZ0ZcNOG/bFJ8V43h2mZrcRm9hUqIrn7yGfLAZSEQW2iL1NmRWwuIwqg7p8Biz2", - "MeVuqmw3K0IJhP+jaLOMDJhx1d26UQxhTT9id2up33cSFuRvFYPa66eFcGhwhn5e62D0ane3kXRHet7f", - "sdpX42ugSJ7F7H4mJTMwgan/GusObHNLr3hWcjjPMFysl9NaSp0kco1EwWW2u8DF0m2R5onhWQLeIfhS", - "kYJUehvgbpV005T3FlgRHvRLiNuAKXOA6jmOmWFNIWm3h31EUo7Y3ny8uzYP4gPa3Qa+C4M6bFeaDa2F", - "s7PbZ7ibg8IBCDrw2p6FLsslz36496bOSR6RwgmwVF5QW+pQ5O5hlfBAGPnCM6dvbkrV2NvS3Ogqee+U", - "T0PXB0LK4dL3W1ON64p+XG/0FpjZuzs+aTZyNz34wrP5vrpQvrtdH/YU7D95Vol1jYH/GCF38lmv5FQi", - "Wsq7bbr3N1PqgwR35cwDswq783TnI7Rm2XC34KDre8H/ziHUYK90Yu3JsVPPsjXvYIcc/JDPfRc0h0y9", - "0oS0cmMtuiliB18Lkl85mifg5ira8iazStxa2YbNIHzK4BOIko/bkojhnCEw51cwSmbZ/WfUzE4KIEYY", - "wYXS9jaTDtxkZG9O6OY0n+upW/YNedXO7wxcGnfaYGI3VNirX40O6OtsNq2NO1ZBrZ8cpSO6AhZbrL/S", - "f41ns+n4mTvb+F3wxvAriDmz450IEMHb+UkHjvzcNmIPaJ06xXBlx9QFpiuv7qOYWkJ3qGzNCvNmt5RY", - "jMq3t8M+4JJdKhcntdCHdaoYd1e9GPUOeC3KqcfegcfGZ01+PTrqO6adEuw51tYxSad8u3j8G9ZV9kxL", - "ihHze+9GbX6JnrPo3FdNxUQu9UFF2HCtXS793HyPHW4JhLtpvFVyC0NTfH0iz0BdcC3Dc9zhbRYySeS6", - "IXmti8Hd4c42m6VINqQ4JuGL4pY018QfbYti9nuV6+xTwz28W7Vg7uf/6XfzaOWXGAZdGQrWD+29Qp4B", - "D03kBSjc2imIJ/kBXLqrsOE8pnZB747SmNAVwJ2Lkbd/AnsXKCAE1Z1Y5dd8x0ml6fY7900G22uPgxy2", - "Vy3vlsWNK6Lfh8f1C6UhTXc3RH8w3rItzP1a3T29OvjMk2SQ0S9w0S5pR+1W6zaPN3BldfdYaC+G1m9f", - "f2ORqn0QJCBKb17cyz4ImpLy+njhlfslTpe3gYMBVvPO8LcWujs2JQ6pkBXxT+7lQEvt2q5Dr5/1Md/B", - "rdhV/xhz07gk/Z1cWO3Ocujzw/U7xPc2p6uMj7tUvV0OZW6GUr2KeDI3W3O+72SPbpC7BG6AD2Yxrbvd", - "GGa0L3f/f4nuDkp0NamWuWmlZOUNwIOqzB+2rq3vw97p0Hrnjt6VN3xDsyHVXc9/wLh6puCC2wC8uLlX", - "vwjY4Z+fJu61R8W4cZ2FWyutZYGzvDdYddom5MMKBJEpGv145Drn7sJmrkG7JpyrIJWv9xU9rfkKlzyH", - "bh4OGzlLsIM0O7rxDFntHrErUzdMVfl0/Nx/w2D8ZOu3BOSi+tRD9wMIE/JHzhQTBiD2V9DPnj979OjR", - "75Pt1bLGUWaud7nXSYrv9+x5EDzKL4e/bFNRjjaJJwnhAo3UUoHWI5IlwDQQozaELRkXJGEGVJPcZ2DU", - "ZvxkYUIfBpjly6W7HLBm3LS/p0bOYSEVImrUxilBhcS2S8330QOUNwzcXUFtdRGE2c2iJNz5gd6h8eIL", - "IG4y7AYx6E5ftW18b6Q7WdXRVzv/LBel+dG3N1XNkqQOtkk2qzgDYxp37UbDn1QIetGH21S0+MLJjUT/", - "9+H3mv/LktsJfpiyX2CLFNQ/2jIhb0SysVNlla3LQJHTExIxgfZNwZJrAwpiwhCE+6J6h8sy28bk2ocG", - "7ozHgY8ZXD9Q8mMT3/eyuZFZ0/1YRP4vAAD//xd4sMVmZwAA", + "H4sIAAAAAAAC/+w9aW/bOJR/hdDOh3bXV9t0BpNvaePOBj0Rt+hsJ12DkZ5tTiRSQ1J23CL/ffFI6qZs", + "x0maZrBAgSYSRT6++yLzPQhFkgoOXKvg8HsgQaWCKzC/vKDRKfyTgdJjKYXER6HgGrjGH2maxiykmgk+", + "/FsJjs9UuICE4k+/SJgFh8F/DMv5h/atGtrZrq6uekEEKpQsxUmCQ1yQuBWDq17wUvBZzMIftXq+HC59", + "wjVITuMftHS+HJmAXIIkbmAveCf0K5Hx6AfB8U5oYtYL8J0bjrO9jFl48VZkCnL6IABRxPBDGn+QIgWp", + "GfLNjMYKekFaefQ9OM+0thDWFzRTEvuWaEEYIoKGmqyYXgS9AHiWBId/BTHMdNALJJsv8P+ERVEMQS84", + "p+FF0AtmQq6ojIKvvUCvUwgOA6Ul43NEYYigT+3j5vIf1ykQMSNmDKGheVyuGokV/pqlgZvGu8BCxNH0", + "AtbKt72IzRhIgq9xfziWRBl+SvQC7MJBL2AaEvN9a3b3gEpJ1/g7z5Kp+cotN6NZrIPDJy1SZsk5SNyc", + "ZgmYxSWkQHVtXTc7on0OhuMu27v4k4RCyIhxqg22iglIKhRzOGvPtG7P9D/7zHTVCyT8kzEJERLlMsCp", + "S0KI87/BCu1LCVTDMZMQaiHX+3FqIiIPo7xP7eckymcnOJA8EqGmMbHk6hEYzAfkt+fPHw/IsaWMQfxv", + "z58Pgl6QUo1iHhwG//vXqP/b1+/PegdXvwQelkqpXrSBODpXIs40VIDAgbhCaLbeWGQ4+M/25A1smpV8", + "yDyGGDR8oHqxHx63bCEHPDLL3D7gpxAaRpvvBz2L2rCfRMC1FWfHujJfpLITchSnC8qzBCQLiZBksU4X", + "wJv0p/1vR/0vo/7v/a//9Yt3s62NFTagwbCgFJ2DR3k0MJYP9CHtFYvhhM9Ee3qmphGTbWx8XoBegDR4", + "MMRkitCSMwflns6FiIFyXCYR0RTVUXu6N1RpFCk2cybNqK2B1e0J1cFhEFENffO1R2L8YovbsoJ6zrQi", + "j1A+e+QsiOTqUvbx31mANDoL+nLVl338dxY8HvhW4NQH9wuqgOCrnCdmuKSQXkzsLOD42vudYt9ger7W", + "4DE2E/YNCOPEvB6QEZlVwGCgBtt1q9mjg662WC/ngwoNHdK72GmyVhqS8dJ5K23CKDOAhAvK50AABxop", + "uTb70dkMQg3R7ny4Ly2LpfYl6vW4xO+0GJQSfDeo+CovT8dHH8dBL/h8emL+Px6/GZsfTsfvjt6OPa5L", + "g/jmba9bsb5hShu6efaI3gnurY0xxq0Ao0gD1zkjFg7PJj+10EoeP+iNmHfw1hGJxdystSYzKRLLI6Wz", + "3GayigptaCUxJ+4l0XCp/VRC/0rTJPX4lywBs3wJ0YoqkkoRZaHlol3UW4ciry7tI9hbsYQb+Ow38WsT", + "sYRrubXb3E4tzJzWY8ykEpJosZfbuetMO7udiOb9/aQIlJ5u8/dAaQQeZSg3DdvcpV6gZLhtYiUyGcLO", + "czZQUizQq+zCh6H3F6cur7AVOXVA/wBu3Kj3r0memWhLr7ioRUJaZtCOryMUflBEZWEISvnMQmN34sK7", + "lw9S4ATjSwh3JXgdFvcV8iFcQohkoCQUSUJ5RNSahwspuMhUvG5vlcp5Pez762s7i2FnonKeJahNB9eS", + "Q6qmUghdW8S/jYxb38/iwwTsBD8lqWRLFsMclN/4UjXNFHhsenNKqoheMEVwNE7Fszim5zHkNG6H+nbv", + "HpNpEI3fonFSC4jjAuUYGGfcq9nDlWeuz0JeoJorTdwjWjXxj92MVsG4RRj3bWC7DANfdrOXh5wFzb63", + "cjtjvmRScOQJsqSSISBGdyvQxlWsoL6CjZLz0diITE8VhB6LQC9ZkiWOpXP/Hd1RBaHgkdpAwC6Vm5Nz", + "qxgqu+XrSSF+hC4LrQpdQbBiH20hjDJpVPE0UV2chvvPhyEOEhbHrIKIttWCS6anoTeIcVslOITgEP8M", + "Skcg5fT81wO/Z/vrQR84fh4RO5ScZ7OZlay27dARknrHyUSmuye76qbeaxbH+ynRCZtzGlvutTLc4N46", + "yZQZXlNqwcfx6dtg87xV/9oNf33y5k3QC07efQx6wX9/+rDdrXZrb2DiSUpXvIqHOH4/Cw7/2uwcewzR", + "1dfWpHuIxknFY6fnSFtKFM6GEVYXhlNfxuT9pNDlJ8d+rnXvp77PbTK8TxWiECLCygSMR18VjnSWscjP", + "01RqiKZU+x1140iT1QLqVsh9dg1fvZPOmupMXZMaLzMpUWUr87FVWJ1UCNNsmoae/Y2VZgnFGPnlh08k", + "MwFNCjIErum8qlC4SRtv0UjjXBMRNqvhakGtmrLo2qbue0ECSVc2o4RYgjKUJwkkaG4t9EWio0MZUr1B", + "lZrXVemWGedIPrttiPxi3U3YiPH9FNkx1RTVzUoyG5s0WI9HVKL7kGae5EhENd1JR0fVVQZbHfti3q9b", + "93wj04vguOypwunaO8QRGngXk5RFDjOAuOEdma7urUigZabqOmZoMiYpXceCIpumEhRqKD4vKCgynWYa", + "nc6YzSBch7HLdKmbUrPIbJTMgrvwWnPwJ0re1EFqpZRQFLwVr51UQ6FI7eRMkTPz4VnQJbIIv8cK2BjV", + "vs7zZwYF4SLjF1WArSsS5L7QjkJsSwUg/fnvGeNMLXYzG2U9IP+qy2hsDWWsPWw/VkVho/K+Elxdw8iV", + "0LqP9gS2oTyM8a3C6VMiEzCpxA8gE6YUE1ztlz2ZS5F58m7vYEXMK5fOleSPmgNy3bqBp8r368HB4+sV", + "9cSK+6JehNW8MnFuDu+nDnh3yTGvFkIZ857jllBpbMs5uGx7tG/BbUPOf4JM9Ep9pjq81ZJhUc81Bgxn", + "9yJGQphJxZawPXVR1A7cfKT4Nl7vkBjqTHMZDNyw8DiTNAHpdV5OS+2SD0IvaJYigy5BShaBIsp2kDgM", + "PEaK2dA8OHw66gUJ4/aXJz4d7HXi89K3x/2uqBAwrHZL5U8D9LELoE/4xEbO3VmHEo5q1O0C7i3Y2YiQ", + "hF6aWhb7Bif87YtuCEzhQ7kK3NsXO1LkyWg0qhFltJvjMtEivSmjCRkCzrNdXk6SBCJGNcRrorRITa4P", + "48K5pCHMspioRaYjseID8nHBFEnoGr129PIYN9lNKbMUffkli0AYZPlzg9epu1sJRoDurOiOj5hzCzTT", + "aAKD1yA5xOQkoXNQ5OjDSdALliCVBXY0eDIYGW2fAqcpCw6DZ4PR4JkrrBnUD8OFFAnLkmGWouvYh0sN", + "3GjqPuVRX4IxyEYlCuUx5J/MZ0RwYyoSIYEUU5BvLCVUhgu2BNXD56afSi8gIRlHpA0XIoHhhdnGsFx6", + "eJaNRs9CNEDmJ+idcQWayIybVF65wiymc4WULTdiHvWIg5y8dM/JklGishTkkikhox6hPCIryvQZx2lj", + "Q85i9DEsPwoRo6cYM6UBQ7KzwBTPYsYBfUhxbsQpIucww31L0JnkRgO5zP4ZDwz2nfKICnyNi60e8ejU", + "4diqdlD6hYjWjQa7JIs1S6nUQ3SL+uhz1nvs6hJVotLnaisT8ZRjkHct+Q1OaKjZEtV+JVlfn95fs34l", + "YqSpcRu0IGlMQ1vaKsl1Pao3ROao/4X2v436vw+m/a/fn/SePn/u926+sXSKct0G8UvJkASxS5mhF0XI", + "UhpeQFRyQAn1oyRTqD7COIuAJJSzGSg9+FsJ/rjqqJ4zTuV6q/dSgOeK/z77Xa+FNCaoUPerV0+Ug9FL", + "Ng8qvbRPR098wVLBDZYVIOqVuHDCBE5qCuFgikigkSnXHIxGXUX0Yvlhs4/3qhc83+W7ehOs6QjNkgSx", + "7VVBBTUrTP6IKlRI6rHZQlM9mDnN2pkGObSdmonIbJ0w1311WS47UTfK7v7Nse1W153IO+pqbrVdpeiO", + "I89CdK9km7Aki6kpORo81zpfibAB+0IojOEtVRo0SsQStpGo6Du4Iwq1+hpuRiDXBIA7u1/ivM3bEpIq", + "XC4nqFII0QmKKr0MagPFZmpom0KnRaHSUCzzyVS9cfauBMvfnruv8izjQrvPKPcAZlkc369ytDsllHBY", + "lXXigi62U3QHuthW1rumS7vTd195Kklit3gjcToYHWz/rn4+4jZoZ7FRbSFs0i33cjaQ7JX1NH5uapkk", + "17+AUIYeBY3EiqNngtI1/cZMdDsH7cumYOCgCCVfTj7Y8L3inNreDUMulUedpQaudW026O/WP2byC0uN", + "My1pAhqkMhXdnTv6c48Z/aZ8U6aVB7/7JwOjDmxMkKfm6jzQqwYq21J9X/0M08GxDq/l/Nu98VY7CmI9", + "32MR1hvGqiL4IfKlI1ZVhRCaM5rbcsGvyHjTPMXgGLXOUUUT7K68tLXP+GdgoespvbIRuM1IRo1Vuowf", + "IMv8AbrWJ513XbSoV7BNzJQ2hkh18k3Zrr2fEnqYnFLu2sMqpX+C+HOJiwfGKyaTZChvc6tt3jC9113+", + "Sd6sfIeh2W34JiYUKv35B0gnswMhiQSTm9skzBJoVHiVXlk+BRo5n3I3UTaL5a4Ezv+zSLMINeh+Weu/", + "kQ9hVD/u7tZCv3tiFqRv6YOaw/g5cyiwin5aqed2Sne7rH5Hct5dv99X4itTkSyN6MMMSiagPWegKqQb", + "mlK/WrC0oLBN/XZXe47iWKzyDLGpdDA+t0vYCkUMziC4VJGERDgdYM/YDToqIrl7cGslkMIj6ahh7HPY", + "pdId5xza3Y6/5Ar1upUCVyXYfKJlY6nAYuHWqgSGSkWB4KGrOk/hYOb8tao45LH7xgIoNcVOI2+2Z9/W", + "OplWZfDeSp/6DlP5hMOG77cmGtdl/aja9lKp4hZBsxa7yUG1MHeDqtkmediTsb+wtGTrCgH/NUxOq8X4", + "BosW/G5akLqLKdW2qrsy5p7Ord1pujMIjc5eXM3b9v+Js38y8LUblTKxcujYqYOj0f1lWr5cafWhM5rd", + "TDXThLiyTX6qzmLD7znKryzOY7BdZk1+E2nJbo1ow0QQLmRwAURBx01BxPaYwdP1nBNKpOnDJ9TE9E3h", + "jkzvgycKbBJpaPvEO2NC27X+So3tsB9Iq2Z8p+FSW2i9gd22xF71ogiPvE4m40rzd+nUuj76oBcsgEZm", + "19+DP/uTybj/0sLW/+i9P+EtRIyaZnecEKc33eR2OvKoqcQeB1Xs5K3mLVXn6TW/eohsahDdwrJRK9Sp", + "3YJj0SvfXA77jEN2yVwcV1wf2spi3F32otfZ7joresA7279rlzz9enDQBabpme4Aa2PTuBW+XSz+DfMq", + "e4Yl+YGbB29GTXyJljOv3JdFxVjM1bBErD/XLubuFFGHHm4whL13YSPn5oomv4unaKL0nmrxLzMTcSxW", + "Nc5rXJPQbnVvklnweE1yMAmb5XdGMEUcaBsEs9uqXGedyt79q5UDpu40VHBvFq24l2arKUPG+qmtl88y", + "INBELEHi0lZAHMqHcGkvBvDHMZXjyncUxvgORO+cjLx9CMzJSA8TlDcESDfmHjuVxptvIKkT2BwC30ph", + "c/D8bklcOzB/PzSuHq/3Sbo9L/+T0ZZuIO738iT+1fCCxfFWQr/GQbuEHZUz/pss3pYD/Lv7QnsRtHoX", + "xQ9mqcr1SB5Wev/6QdZBUJUUl2nkVrmb41RxN4LXwarfoPCjme6OVYndlE+LuDcPsqGlcomB3V436SO2", + "g1kxo/416qZ2ZcQ9mbDKDQ6+y9irNyo82JiuVD72ionNfCgyvS3UK5EnMr0x5rsnfXSD2MVzH8bWKKZx", + "0wW6Gc2rLv4/RXcHKboKV4tMN0Ky4jz0sEzz+7Vr47bsO21ab51YvnKKb1tvSHny/V/Qrp5KWDLjgOfn", + "mKvHolv0c93EnfoobzeuknBjprVIcBanqMtK24B8XgAnIkGlH/Vs5dweX88UKFuEsxmk4vOupKdRX/6U", + "57Zz2NuVnEHYMEkPbtxDVrlVwaapa6qqeNt/5W506R9tvFlFzMqLb9rXwQzIHxmVlGuAyF3Icfrq5bNn", + "z34fbM6W1UCZ2NrlXpDkt5ntCQiC8nT0dJOIMtRJLI4J46ik5hKU6pE0BqqAaLkmdE4ZJzHVIOvoPgUt", + "1/2jmfZdkzLJ5nN7OGBFmW7eLlk5Di7XVgjKTWy64uEhWoDihIE9K6iMLALXu2mUmFk70Nk0nt+HZDvD", + "buCD7nTHd+32pXZnVUte85P0soDy1rqqaRxXp62jrXUlg6dN467NqP+CGa8VfbJJRPP7nm7E+r9v/67+", + "B5xux/mh0txHGUqoXmE1IO95vDZdZaWuS0GSk2MSUo76TcKcKQ0SIkJxCvv3JVpUFukmIleuXbkzGnuu", + "drm+o+TaJu73sLkWad38mI38XwAAAP//btvsR3RsAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index feea875f..cece5906 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -737,6 +737,45 @@ paths: $ref: "#/components/responses/NotFoundError" "500": $ref: "#/components/responses/InternalError" + + /chromium/upload-extensions-and-restart: + post: + summary: Upload one or more unpacked extensions (as zips) and restart Chromium + description: | + Upload one or more extension zip archives, extract them under /home/kernel/extensions/, + set runtime extension flags in /chromium/flags, restart Chromium via supervisord, and wait + until the Chromium DevTools "listening" log line is observed before returning success. + operationId: uploadExtensionsAndRestart + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + extensions: + type: array + description: List of extensions to upload and activate + items: + type: object + properties: + zip_file: + type: string + format: binary + description: Zip archive containing an unpacked Chromium extension (must include manifest.json) + name: + type: string + description: Folder name to place the extension under /home/kernel/extensions/ + pattern: "^[A-Za-z0-9._-]{1,255}$" + required: [zip_file, name] + required: [extensions] + responses: + "201": + description: Extensions uploaded, Chromium restarted, and DevTools is ready + "400": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalError" components: schemas: StartRecordingRequest: