diff --git a/pkg/docker/elevate.go b/pkg/docker/elevate.go new file mode 100644 index 000000000..933e9b591 --- /dev/null +++ b/pkg/docker/elevate.go @@ -0,0 +1,105 @@ +package docker + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/devsy-org/devsy/pkg/log" +) + +// elevationAuthTimeout is generous enough for interactive credential entry, +// unlike the short per-command probe timeouts. +const elevationAuthTimeout = 2 * time.Minute + +// Supported privilege-elevation helpers. +const ( + elevationPkexec = "pkexec" + elevationSudo = "sudo" + elevationDoas = "doas" +) + +// Elevator runs docker commands through a privilege-elevation helper (pkexec, +// sudo, doas). It authenticates once, up front, so an operation's many commands +// share a single prompt via the warmed OS credential cache. +type Elevator struct { + prefix []string // elevation command and leading args; always non-empty + + once sync.Once + err error +} + +// ElevatorFromName maps a helper name to an Elevator; "" and "none" return +// (nil, nil), unknown names error. +func ElevatorFromName(name string) (*Elevator, error) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "", "none": + return nil, nil + case elevationPkexec: + return &Elevator{prefix: []string{elevationPkexec}}, nil + case elevationSudo: + return &Elevator{prefix: []string{elevationSudo}}, nil + case elevationDoas: + return &Elevator{prefix: []string{elevationDoas}}, nil + default: + return nil, fmt.Errorf( + "unknown privilege elevation %q (want pkexec, sudo, doas, or none)", + name, + ) + } +} + +// wrap builds the elevated invocation of dockerCommand. env (KEY=VAL entries, +// e.g. DOCKER_HOST) is forwarded through env(1) because sudo/pkexec/doas reset +// the child environment and would otherwise drop provider configuration. +func (e *Elevator) wrap(dockerCommand string, env, args []string) (string, []string) { + full := make([]string, 0, len(e.prefix)+len(env)+len(args)+1) + full = append(full, e.prefix[1:]...) + if len(env) > 0 { + full = append(full, "env") + full = append(full, env...) + } + full = append(full, dockerCommand) + full = append(full, args...) + return e.prefix[0], full +} + +// ensureAuthenticated warms the credential cache once. It elevates the +// client-only "--version" (no daemon needed) on its own timeout, so a short +// caller deadline cannot kill the prompt. +func (e *Elevator) ensureAuthenticated(dockerCommand string, env []string) error { + e.once.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), elevationAuthTimeout) + defer cancel() + + name, args := e.wrap(dockerCommand, env, []string{"--version"}) + //nolint:gosec // command and args come from trusted provider config + cmd := exec.CommandContext(ctx, name, args...) + if env != nil { + cmd.Env = append(os.Environ(), env...) + } + cmd.Stdin = os.Stdin // attach terminal for the credential prompt + cmd.Stdout = io.Discard + cmd.Stderr = os.Stderr + + log.Debugf("authenticating privilege elevation via %s", e.prefix[0]) + if err := cmd.Run(); err != nil { + e.err = fmt.Errorf("privilege elevation via %s failed: %w", e.prefix[0], err) + } + }) + return e.err +} + +// EnsureElevated authenticates the configured elevator once; concurrent callers +// block on the single prompt. No-op and safe when no elevator is set. +func (r *DockerHelper) EnsureElevated() error { + if r.Elevator == nil { + return nil + } + return r.Elevator.ensureAuthenticated(r.DockerCommand, r.Environment) +} diff --git a/pkg/docker/elevate_test.go b/pkg/docker/elevate_test.go new file mode 100644 index 000000000..d8ab0a6fc --- /dev/null +++ b/pkg/docker/elevate_test.go @@ -0,0 +1,106 @@ +package docker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + dockerCmd = string(RuntimeDocker) + dockerHost = "DOCKER_HOST=tcp://host:2375" +) + +func TestElevatorFromName(t *testing.T) { + tests := []struct { + name string + wantNil bool + wantPrefix []string + wantErr bool + }{ + {name: "", wantNil: true}, + {name: "none", wantNil: true}, + {name: " None ", wantNil: true}, + {name: elevationPkexec, wantPrefix: []string{elevationPkexec}}, + {name: "SUDO", wantPrefix: []string{elevationSudo}}, + {name: elevationDoas, wantPrefix: []string{elevationDoas}}, + {name: "gksu", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e, err := ElevatorFromName(tt.name) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + if tt.wantNil { + assert.Nil(t, e) + return + } + require.NotNil(t, e) + assert.Equal(t, tt.wantPrefix, e.prefix) + }) + } +} + +func TestElevatorWrap(t *testing.T) { + e, err := ElevatorFromName(elevationPkexec) + require.NoError(t, err) + + name, args := e.wrap(dockerCmd, nil, []string{"ps", "-q"}) + assert.Equal(t, elevationPkexec, name) + assert.Equal(t, []string{dockerCmd, "ps", "-q"}, args) + + // No docker args. + name, args = e.wrap("/usr/bin/docker", nil, nil) + assert.Equal(t, elevationPkexec, name) + assert.Equal(t, []string{"/usr/bin/docker"}, args) + + // Environment is forwarded through env(1). + name, args = e.wrap(dockerCmd, []string{dockerHost}, []string{"ps"}) + assert.Equal(t, elevationPkexec, name) + assert.Equal(t, []string{"env", dockerHost, dockerCmd, "ps"}, args) +} + +func TestEnsureElevatedNoOpWithoutElevator(t *testing.T) { + r := &DockerHelper{DockerCommand: dockerCmd} + assert.NoError(t, r.EnsureElevated()) +} + +func TestBuildCmdWithoutElevator(t *testing.T) { + r := &DockerHelper{DockerCommand: dockerCmd} + cmd := r.buildCmd(t.Context(), "ps", "-q") + assert.Equal(t, []string{dockerCmd, "ps", "-q"}, cmd.Args) +} + +func TestBuildCmdWithElevator(t *testing.T) { + e, err := ElevatorFromName(elevationSudo) + require.NoError(t, err) + // Mark authentication as already done so buildCmd does not attempt an + // interactive prompt during the test. + e.once.Do(func() {}) + + r := &DockerHelper{DockerCommand: dockerCmd, Elevator: e} + cmd := r.buildCmd(t.Context(), "ps", "-q") + assert.Equal(t, []string{elevationSudo, dockerCmd, "ps", "-q"}, cmd.Args) +} + +func TestBuildCmdWithElevatorForwardsEnv(t *testing.T) { + e, err := ElevatorFromName(elevationSudo) + require.NoError(t, err) + e.once.Do(func() {}) + + r := &DockerHelper{ + DockerCommand: dockerCmd, + Environment: []string{dockerHost}, + Elevator: e, + } + cmd := r.buildCmd(t.Context(), "ps") + assert.Equal(t, + []string{elevationSudo, "env", dockerHost, dockerCmd, "ps"}, + cmd.Args, + ) +} diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index e2c574242..c6a7a7988 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -68,6 +68,9 @@ type DockerHelper struct { Environment []string Builder DockerBuilder Runtime ContainerRuntime + // Elevator, when set, runs docker commands through a privilege-elevation + // helper (pkexec/sudo/doas). Nil disables elevation. + Elevator *Elevator } func (r *DockerHelper) GPUSupportEnabled() (bool, error) { @@ -523,7 +526,13 @@ func failedBootSentinel(status string, graceElapsed bool) error { } func (r *DockerHelper) buildCmd(ctx context.Context, args ...string) *exec.Cmd { - cmd := exec.CommandContext(ctx, r.DockerCommand, args...) + name, cmdArgs := r.DockerCommand, args + if r.Elevator != nil { + _ = r.EnsureElevated() // defensive; normally already done in NewDockerDriver + name, cmdArgs = r.Elevator.wrap(r.DockerCommand, r.Environment, args) + } + //nolint:gosec // command and args come from trusted provider config + cmd := exec.CommandContext(ctx, name, cmdArgs...) if r.Environment != nil { cmd.Env = append(os.Environ(), r.Environment...) } diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go index e300b7043..1e5c5b007 100644 --- a/pkg/driver/docker/docker.go +++ b/pkg/driver/docker/docker.go @@ -59,15 +59,29 @@ func NewDockerDriver( rt = docker.DetectRuntime(dockerCommand) } + elevator, err := docker.ElevatorFromName(workspaceInfo.Agent.Docker.Elevation) + if err != nil { + return nil, fmt.Errorf("invalid elevation config: %w", err) + } + log.Debugf("using docker command: command=%s, runtime=%s", dockerCommand, rt.Name()) + dockerHelper := &docker.DockerHelper{ + DockerCommand: dockerCommand, + Environment: makeEnvironment(workspaceInfo.Agent.Docker.Env), + ContainerID: workspaceInfo.Workspace.Source.Container, + Builder: builder, + Runtime: rt, + Elevator: elevator, + } + + // Authenticate before any command runs, keeping the prompt out of the short + // per-command probe timeouts that would otherwise kill it. + if err := dockerHelper.EnsureElevated(); err != nil { + return nil, err + } + return &dockerDriver{ - Docker: &docker.DockerHelper{ - DockerCommand: dockerCommand, - Environment: makeEnvironment(workspaceInfo.Agent.Docker.Env), - ContainerID: workspaceInfo.Workspace.Source.Container, - Builder: builder, - Runtime: rt, - }, + Docker: dockerHelper, IDLabels: workspaceInfo.CLIOptions.IDLabels, UpdateRemoteUserUIDDefault: workspaceInfo.CLIOptions.UpdateRemoteUserUIDDefault, }, nil diff --git a/pkg/options/resolve.go b/pkg/options/resolve.go index 7aa58aa3c..d3d7a833d 100644 --- a/pkg/options/resolve.go +++ b/pkg/options/resolve.go @@ -282,6 +282,10 @@ func resolveAgentDockerConfig( options map[string]string, ) { agentConfig.Docker.Path = resolver.ResolveDefaultValue(agentConfig.Docker.Path, options) + agentConfig.Docker.Elevation = resolver.ResolveDefaultValue( + agentConfig.Docker.Elevation, + options, + ) agentConfig.Docker.Builder = resolver.ResolveDefaultValue(agentConfig.Docker.Builder, options) agentConfig.Docker.Install = types.StrBool( resolver.ResolveDefaultValue(string(agentConfig.Docker.Install), options), diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 4e6783dbd..ad0896f1c 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -202,6 +202,11 @@ type ProviderDockerDriverConfig struct { // Runtime identifies the container runtime explicitly (docker, podman, nerdctl). // When empty, the runtime is auto-detected from the binary at Path. Runtime string `json:"runtime,omitempty"` + + // Elevation optionally runs docker commands through a privilege-elevation + // helper for rootful daemons whose socket is not accessible to the current + // user. One of "" / "none" (disabled), "pkexec", "sudo", or "doas". + Elevation string `json:"elevation,omitempty"` } type ProviderKubernetesDriverConfig struct { diff --git a/providers/docker/provider.yaml b/providers/docker/provider.yaml index b2056e0b1..fd8b70bcd 100644 --- a/providers/docker/provider.yaml +++ b/providers/docker/provider.yaml @@ -8,6 +8,7 @@ optionGroups: - options: - DOCKER_PATH - DOCKER_HOST + - DOCKER_ELEVATION - INACTIVITY_TIMEOUT - DOCKER_BUILDER name: "Advanced Options" @@ -17,6 +18,9 @@ options: DOCKER_PATH: description: The path where to find the docker binary. default: docker + DOCKER_ELEVATION: + description: "Optionally run docker commands through a privilege-elevation helper for rootful daemons whose socket the current user cannot access. One of: pkexec, sudo, doas, or none. Note: pkexec targets local desktop sessions with a running polkit agent; use sudo or doas on headless/SSH hosts." + default: none DOCKER_HOST: global: true description: The docker host to use. @@ -28,6 +32,7 @@ agent: local: true docker: path: ${DOCKER_PATH} + elevation: ${DOCKER_ELEVATION} builder: ${DOCKER_BUILDER} install: false env: diff --git a/providers/podman/provider.yaml b/providers/podman/provider.yaml index be4f1f185..0a2d45ded 100644 --- a/providers/podman/provider.yaml +++ b/providers/podman/provider.yaml @@ -8,6 +8,7 @@ optionGroups: - options: - PODMAN_PATH - PODMAN_HOST + - PODMAN_ELEVATION - INACTIVITY_TIMEOUT name: "Advanced Options" options: @@ -16,6 +17,9 @@ options: PODMAN_PATH: description: The path where to find the podman binary. default: podman + PODMAN_ELEVATION: + description: "Optionally run podman commands through a privilege-elevation helper for a rootful podman socket the current user cannot access. One of: pkexec, sudo, doas, or none. Leave as none for rootless podman: elevating would target the separate rootful podman instead. Note: pkexec targets local desktop sessions with a running polkit agent; use sudo or doas on headless/SSH hosts." + default: none PODMAN_HOST: global: true description: The podman host to use. @@ -24,6 +28,7 @@ agent: local: true docker: path: ${PODMAN_PATH} + elevation: ${PODMAN_ELEVATION} install: false runtime: podman env: