diff --git a/doc/plugin_agent_workloadattestor_docker.md b/doc/plugin_agent_workloadattestor_docker.md index 2a89de2fbc..a2f4a39d43 100644 --- a/doc/plugin_agent_workloadattestor_docker.md +++ b/doc/plugin_agent_workloadattestor_docker.md @@ -1,12 +1,14 @@ # Agent plugin: WorkloadAttestor "docker" -The `docker` plugin generates selectors based on docker labels for workloads calling the agent. +The `docker` plugin generates selectors based on container labels for workloads calling the agent. It does so by retrieving the workload's container ID from its cgroup membership on Unix systems or Job Object names on Windows, -then querying the docker daemon for the container's labels. +then querying the container runtime API (Docker by default, or Podman when detected) for the container's labels. | Configuration | Description | Default | |--------------------------------|------------------------------------------------------------------------------------------------|----------------------------------| | docker_socket_path | The location of the docker daemon socket (Unix) | "unix:///var/run/docker.sock" | +| podman_socket_path | The location of the rootful Podman socket (Unix) | "unix:///run/podman/podman.sock" | +| podman_socket_path_template | The socket template for rootless Podman (Unix). Must contain one `%d` UID placeholder | "unix:///run/user/%d/podman/podman.sock" | | docker_version | The API version of the docker daemon. If not specified | | | container_id_cgroup_matchers | A list of patterns used to discover container IDs from cgroup entries (Unix) | | | docker_host | The location of the Docker Engine API endpoint (Windows only) | "npipe:////./pipe/docker_engine" | @@ -22,6 +24,28 @@ A sample configuration: } ``` +## Podman support (Unix) + +The plugin supports Podman workloads, including rootless Podman in multi-user hosts. + +At attestation time, the plugin inspects the workload cgroup path: + +- If a Podman cgroup path is detected and includes a user slice (`/user-.slice/`), SPIRE treats the workload as rootless Podman and calls the Podman API using `podman_socket_path_template` with `` substituted into `%d`. +- If a Podman cgroup path is detected but no user slice UID is present, SPIRE uses `podman_socket_path` (rootful Podman). +- If no Podman cgroup path is detected, SPIRE uses `docker_socket_path` (Docker). + +This per-workload socket selection avoids routing rootless Podman workloads through a single global daemon socket. + +Example rootless customization: + +```hcl +WorkloadAttestor "docker" { + plugin_data { + podman_socket_path_template = "unix:///custom/user/%d/podman.sock" + } +} +``` + ## Sigstore experimental feature This feature extends the `docker` workload attestor with the ability to validate container image signatures and attestations using the [Sigstore](https://www.sigstore.dev/) ecosystem. diff --git a/pkg/agent/plugin/workloadattestor/docker/docker.go b/pkg/agent/plugin/workloadattestor/docker/docker.go index 78c86c2ba5..063474c72b 100644 --- a/pkg/agent/plugin/workloadattestor/docker/docker.go +++ b/pkg/agent/plugin/workloadattestor/docker/docker.go @@ -48,6 +48,11 @@ type Docker interface { ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) } +type podmanDocker interface { + Docker + Close() error +} + type Plugin struct { workloadattestorv1.UnsafeWorkloadAttestorServer configv1.UnsafeConfigServer @@ -55,18 +60,27 @@ type Plugin struct { log hclog.Logger retryer *retryer - mtx sync.RWMutex - docker Docker - c *containerHelper - sigstoreVerifier sigstore.Verifier + mtx sync.RWMutex + docker Docker + c *containerHelper + sigstoreVerifier sigstore.Verifier + podmanClientFactory func(socketPath string) (podmanDocker, error) } func New() *Plugin { return &Plugin{ - retryer: newRetryer(), + retryer: newRetryer(), + podmanClientFactory: defaultPodmanClientFactory, } } +func defaultPodmanClientFactory(socketPath string) (podmanDocker, error) { + return dockerclient.NewClientWithOpts( + dockerclient.WithHost(socketPath), + dockerclient.WithAPIVersionNegotiation(), + ) +} + type dockerPluginConfig struct { OSConfig `hcl:",squash"` @@ -132,7 +146,7 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque p.mtx.RLock() defer p.mtx.RUnlock() - containerID, err := p.c.getContainerID(req.Pid, p.log) + containerID, podmanSocket, err := p.c.getContainerIDAndSocket(req.Pid, p.log) switch { case err != nil: return nil, err @@ -141,9 +155,23 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque return &workloadattestorv1.AttestResponse{}, nil } + client := p.docker + if podmanSocket != "" { + podmanClient, err := p.podmanClientFactory(podmanSocket) + if err != nil { + return nil, fmt.Errorf("unable to create Podman client for socket %q: %w", podmanSocket, err) + } + defer func() { + if closeErr := podmanClient.Close(); closeErr != nil { + p.log.Warn("Failed to close Podman client", telemetry.Error, closeErr) + } + }() + client = podmanClient + } + var container container.InspectResponse err = p.retryer.Retry(ctx, func() error { - container, err = p.docker.ContainerInspect(ctx, containerID) + container, err = client.ContainerInspect(ctx, containerID) return err }) if err != nil { @@ -156,7 +184,7 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque var inspectErr error imageName := container.Config.Image if imageName != "" || p.sigstoreVerifier != nil { - imageJSON, _, inspectErr = p.docker.ImageInspectWithRaw(ctx, imageName) + imageJSON, _, inspectErr = client.ImageInspectWithRaw(ctx, imageName) } // Add image_config_digest selector diff --git a/pkg/agent/plugin/workloadattestor/docker/docker_posix.go b/pkg/agent/plugin/workloadattestor/docker/docker_posix.go index 335f087e77..8f2c0551c1 100644 --- a/pkg/agent/plugin/workloadattestor/docker/docker_posix.go +++ b/pkg/agent/plugin/workloadattestor/docker/docker_posix.go @@ -8,6 +8,8 @@ import ( "io" "os" "path/filepath" + "regexp" + "strconv" "github.com/hashicorp/go-hclog" "github.com/spiffe/spire/pkg/agent/common/cgroups" @@ -16,6 +18,16 @@ import ( "github.com/spiffe/spire/pkg/common/pluginconf" ) +const ( + defaultPodmanSocketPath = "unix:///run/podman/podman.sock" + defaultPodmanSocketPathTemplate = "unix:///run/user/%d/podman/podman.sock" +) + +var ( + rePodmanCgroup = regexp.MustCompile(`(?:libpod-|/libpod/)`) + reUserSliceUID = regexp.MustCompile(`/user-(\d+)\.slice/`) +) + type OSConfig struct { // DockerSocketPath is the location of the docker daemon socket, this config can be used only on unix environments (default: "unix:///var/run/docker.sock"). DockerSocketPath string `hcl:"docker_socket_path" json:"docker_socket_path"` @@ -33,6 +45,15 @@ type OSConfig struct { // about mountinfo and cgroup information used to locate the container. VerboseContainerLocatorLogs bool `hcl:"verbose_container_locator_logs"` + // PodmanSocketPath is the socket path for rootful Podman (no user namespace). + // Defaults to "unix:///run/podman/podman.sock". + PodmanSocketPath string `hcl:"podman_socket_path" json:"podman_socket_path"` + + // PodmanSocketPathTemplate is the socket path template for rootless Podman. + // The placeholder %d is replaced with the container owner's host UID extracted + // from the cgroup path. Defaults to "unix:///run/user/%d/podman/podman.sock". + PodmanSocketPathTemplate string `hcl:"podman_socket_path_template" json:"podman_socket_path_template"` + // Used by tests to use a fake /proc directory instead of the real one rootDir string } @@ -63,10 +84,25 @@ func (p *Plugin) createHelper(c *dockerPluginConfig, status *pluginconf.Status) rootDir = "/" } + podmanSocketPath := c.PodmanSocketPath + if podmanSocketPath == "" { + podmanSocketPath = defaultPodmanSocketPath + } + podmanSocketPathTemplate := c.PodmanSocketPathTemplate + if podmanSocketPathTemplate == "" { + podmanSocketPathTemplate = defaultPodmanSocketPathTemplate + } + if err := validatePodmanSocketPathTemplate(podmanSocketPathTemplate); err != nil { + status.ReportErrorf("invalid podman_socket_path_template: %v", err) + return nil + } + return &containerHelper{ rootDir: rootDir, containerIDFinder: containerIDFinder, verboseContainerLocatorLogs: c.VerboseContainerLocatorLogs, + podmanSocketPath: podmanSocketPath, + podmanSocketPathTemplate: podmanSocketPathTemplate, } } @@ -80,19 +116,77 @@ type containerHelper struct { rootDir string containerIDFinder cgroup.ContainerIDFinder verboseContainerLocatorLogs bool + podmanSocketPath string + podmanSocketPathTemplate string } -func (h *containerHelper) getContainerID(pID int32, log hclog.Logger) (string, error) { +func (h *containerHelper) getContainerIDAndSocket(pID int32, log hclog.Logger) (string, string, error) { if h.containerIDFinder != nil { cgroupList, err := cgroups.GetCgroups(pID, dirFS(h.rootDir)) if err != nil { - return "", err + return "", "", err } - return getContainerIDFromCGroups(h.containerIDFinder, cgroupList) + containerID, err := getContainerIDFromCGroups(h.containerIDFinder, cgroupList) + if err != nil || containerID == "" { + return "", "", err + } + return containerID, h.detectPodmanSocket(cgroupList, log), nil } extractor := containerinfo.Extractor{RootDir: h.rootDir, VerboseLogging: h.verboseContainerLocatorLogs} - return extractor.GetContainerID(pID, log) + containerID, err := extractor.GetContainerID(pID, log) + if err != nil || containerID == "" { + return "", "", err + } + + cgroupList, err := cgroups.GetCgroups(pID, dirFS(h.rootDir)) + if err != nil { + log.Warn("Failed to read cgroups for Podman detection, falling back to Docker client", "pid", pID, "err", err) + return containerID, "", nil + } + return containerID, h.detectPodmanSocket(cgroupList, log), nil +} + +func (h *containerHelper) detectPodmanSocket(cgroupList []cgroups.Cgroup, log hclog.Logger) string { + for _, cg := range cgroupList { + if !rePodmanCgroup.MatchString(cg.GroupPath) { + continue + } + if m := reUserSliceUID.FindStringSubmatch(cg.GroupPath); m != nil { + if uid, err := strconv.ParseUint(m[1], 10, 32); err == nil { + return fmt.Sprintf(h.podmanSocketPathTemplate, uid) + } + log.Warn("Failed to parse rootless Podman UID from cgroup path, falling back to rootful Podman socket", "uid", m[1], "cgroup_path", cg.GroupPath) + } + return h.podmanSocketPath + } + return "" +} + +func validatePodmanSocketPathTemplate(template string) error { + var placeholders int + for i := 0; i < len(template); i++ { + if template[i] != '%' { + continue + } + if i+1 >= len(template) { + return errors.New("trailing % at end of template") + } + switch template[i+1] { + case '%': + i++ + case 'd': + placeholders++ + i++ + default: + return errors.New("template only supports escaped %% or the %d UID placeholder") + } + } + + if placeholders != 1 { + return errors.New("template must contain exactly one %d UID placeholder") + } + return nil } func getDockerHost(c *dockerPluginConfig) string { diff --git a/pkg/agent/plugin/workloadattestor/docker/docker_posix_test.go b/pkg/agent/plugin/workloadattestor/docker/docker_posix_test.go index b203293727..b7e411828d 100644 --- a/pkg/agent/plugin/workloadattestor/docker/docker_posix_test.go +++ b/pkg/agent/plugin/workloadattestor/docker/docker_posix_test.go @@ -3,6 +3,7 @@ package docker import ( + "errors" "os" "path/filepath" "testing" @@ -15,6 +16,11 @@ import ( const ( testCgroupEntries = "10:devices:/docker/6469646e742065787065637420616e796f6e6520746f20726561642074686973" + + testRootlessPodmanCgroupEntries = "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-6469646e742065787065637420616e796f6e6520746f20726561642074686973.scope" + testRootfulPodmanCgroupEntries = "0::/machine.slice/libpod-6469646e742065787065637420616e796f6e6520746f20726561642074686973.scope" + testCgroupfsRootlessPodmanCgroupEntries = "0::/user.slice/user-2000.slice/user@2000.service/user.slice/libpod/6469646e742065787065637420616e796f6e6520746f20726561642074686973" + testInvalidUIDPodmanCgroupEntries = "0::/user.slice/user-4294967296.slice/user@4294967296.service/user.slice/libpod-6469646e742065787065637420616e796f6e6520746f20726561642074686973.scope" ) func TestContainerExtraction(t *testing.T) { @@ -169,6 +175,8 @@ func verifyConfigDefault(t *testing.T, c *containerHelper) { // The unit tests configure the plugin to use the new container info // extraction code so the legacy finder should be set to nil. require.Nil(t, c.containerIDFinder) + require.Equal(t, defaultPodmanSocketPath, c.podmanSocketPath) + require.Equal(t, defaultPodmanSocketPathTemplate, c.podmanSocketPathTemplate) } func withDefaultDataOpt(tb testing.TB) testPluginOpt { @@ -197,3 +205,169 @@ func withConfig(t *testing.T, trustDomain string, cfg string) testPluginOpt { require.NoError(t, err) } } + +func withPodmanClientFactory(factory func(string) (podmanDocker, error)) testPluginOpt { + return func(p *Plugin) { + p.podmanClientFactory = factory + } +} + +func TestPodmanContainerExtraction(t *testing.T) { + tests := []struct { + desc string + cgroups string + expectedSocketPath string + }{ + { + desc: "rootless podman systemd cgroups v2", + cgroups: testRootlessPodmanCgroupEntries, + expectedSocketPath: "unix:///run/user/1000/podman/podman.sock", + }, + { + desc: "rootful podman systemd cgroups v2", + cgroups: testRootfulPodmanCgroupEntries, + expectedSocketPath: defaultPodmanSocketPath, + }, + { + desc: "rootless podman cgroupfs (no systemd)", + cgroups: testCgroupfsRootlessPodmanCgroupEntries, + expectedSocketPath: "unix:///run/user/2000/podman/podman.sock", + }, + { + desc: "rootless podman with invalid uid falls back to rootful socket", + cgroups: testInvalidUIDPodmanCgroupEntries, + expectedSocketPath: defaultPodmanSocketPath, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + rootDirOpt := prepareRootDirOpt(t, tt.cgroups) + + var gotSocketPath string + p := newTestPlugin(t, + rootDirOpt, + withDocker(dockerError{}), + withPodmanClientFactory(func(socketPath string) (podmanDocker, error) { + gotSocketPath = socketPath + return noOpCloseableDocker{Docker: fakeContainer{Image: "my-podman-image"}}, nil + }), + ) + + selectorValues, err := doAttest(t, p) + require.NoError(t, err) + require.Equal(t, tt.expectedSocketPath, gotSocketPath, "wrong Podman socket path") + require.Contains(t, selectorValues, "image_id:my-podman-image") + }) + } +} + +func TestPodmanCustomSocketConfig(t *testing.T) { + t.Run("custom rootful socket", func(t *testing.T) { + p := newTestPlugin(t, withConfig(t, "example.org", ` +podman_socket_path = "unix:///custom/podman.sock" +`)) + require.Equal(t, "unix:///custom/podman.sock", p.c.podmanSocketPath) + require.Equal(t, defaultPodmanSocketPathTemplate, p.c.podmanSocketPathTemplate) + }) + + t.Run("custom rootless socket template", func(t *testing.T) { + p := newTestPlugin(t, withConfig(t, "example.org", ` +podman_socket_path_template = "unix:///var/run/user/%d/podman.sock" +`)) + require.Equal(t, defaultPodmanSocketPath, p.c.podmanSocketPath) + require.Equal(t, "unix:///var/run/user/%d/podman.sock", p.c.podmanSocketPathTemplate) + }) + + t.Run("rootless podman uses custom template", func(t *testing.T) { + rootDirOpt := prepareRootDirOpt(t, testRootlessPodmanCgroupEntries) + + var gotSocketPath string + p := newTestPlugin(t, + withConfig(t, "example.org", ` +podman_socket_path_template = "unix:///custom/user/%d/podman.sock" +`), + rootDirOpt, + withPodmanClientFactory(func(socketPath string) (podmanDocker, error) { + gotSocketPath = socketPath + return noOpCloseableDocker{Docker: fakeContainer{Image: "img"}}, nil + }), + ) + + _, err := doAttest(t, p) + require.NoError(t, err) + require.Equal(t, "unix:///custom/user/1000/podman.sock", gotSocketPath) + }) + + t.Run("invalid template is rejected", func(t *testing.T) { + for _, invalidTemplate := range []string{ + "unix:///run/user/%s/podman/podman.sock", + "unix:///run/user/%d/podman%", + "unix:///run/user/podman%", + } { + t.Run(invalidTemplate, func(t *testing.T) { + p := New() + err := doConfigure(t, p, "example.org", ` +podman_socket_path_template = "`+invalidTemplate+`" +`) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid podman_socket_path_template") + }) + } + }) +} + +type noOpCloseableDocker struct { + Docker +} + +func (f noOpCloseableDocker) Close() error { + return nil +} + +type closeableFakeContainer struct { + fakeContainer + closed bool +} + +func (f *closeableFakeContainer) Close() error { + f.closed = true + return nil +} + +func TestPodmanClientFactoryError(t *testing.T) { + rootDirOpt := prepareRootDirOpt(t, testRootlessPodmanCgroupEntries) + + p := newTestPlugin(t, + rootDirOpt, + withDocker(dockerError{}), + withPodmanClientFactory(func(string) (podmanDocker, error) { + return nil, errors.New("connection refused") + }), + ) + + _, err := doAttest(t, p) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to create Podman client") +} + +func TestPodmanClientIsClosed(t *testing.T) { + rootDirOpt := prepareRootDirOpt(t, testRootlessPodmanCgroupEntries) + var client *closeableFakeContainer + + p := newTestPlugin(t, + rootDirOpt, + withDocker(dockerError{}), + withPodmanClientFactory(func(string) (podmanDocker, error) { + client = &closeableFakeContainer{ + fakeContainer: fakeContainer{Image: "img"}, + } + return client, nil + }), + ) + + _, err := doAttest(t, p) + require.NoError(t, err) + require.NotNil(t, client) + require.True(t, client.closed) +} diff --git a/pkg/agent/plugin/workloadattestor/docker/docker_windows.go b/pkg/agent/plugin/workloadattestor/docker/docker_windows.go index ba98477d85..7291554ac4 100644 --- a/pkg/agent/plugin/workloadattestor/docker/docker_windows.go +++ b/pkg/agent/plugin/workloadattestor/docker/docker_windows.go @@ -25,12 +25,12 @@ type containerHelper struct { ph process.Helper } -func (h *containerHelper) getContainerID(pID int32, log hclog.Logger) (string, error) { +func (h *containerHelper) getContainerIDAndSocket(pID int32, log hclog.Logger) (string, string, error) { containerID, err := h.ph.GetContainerIDByProcess(pID, log) if err != nil { - return "", status.Errorf(codes.Internal, "failed to get container ID: %v", err) + return "", "", status.Errorf(codes.Internal, "failed to get container ID: %v", err) } - return containerID, nil + return containerID, "", nil } func getDockerHost(c *dockerPluginConfig) string {