diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e87734d697..306bb64b3ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,7 +140,7 @@ jobs: docker run --privileged --rm tonistiigi/binfmt --install linux/arm64 docker run --privileged --rm tonistiigi/binfmt --install linux/arm/v7 - name: "Run integration tests" - run: docker run -t --rm --privileged test-integration ./hack/test-integration.sh -test.only-flaky=false + run: docker run -t --rm --privileged test-integration ./hack/test-integration.sh -test.only-flaky=false -test.allow-modify-users=true - name: "Run integration tests (flaky)" run: docker run -t --rm --privileged test-integration ./hack/test-integration.sh -test.only-flaky=true diff --git a/.golangci.yml b/.golangci.yml index d0e92d9ab31..7fda716e51a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -114,7 +114,7 @@ linters: arguments: [7] - name: function-length # 155 occurrences (at default 0, 75). Really long functions should really be broken up in most cases. - arguments: [0, 400] + arguments: [0, 450] - name: cyclomatic # 204 occurrences (at default 10) arguments: [100] diff --git a/cmd/nerdctl/builder/builder_build.go b/cmd/nerdctl/builder/builder_build.go index 582147589dc..337d18e8c72 100644 --- a/cmd/nerdctl/builder/builder_build.go +++ b/cmd/nerdctl/builder/builder_build.go @@ -25,6 +25,8 @@ import ( "github.com/spf13/cobra" + "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/api/types" @@ -209,6 +211,13 @@ func processBuildCommandFlag(cmd *cobra.Command, args []string) (types.BuilderBu return types.BuilderBuildOptions{}, err } + usernsRemap, err := cmd.Flags().GetString("userns-remap") + if err != nil { + return types.BuilderBuildOptions{}, err + } else if usernsRemap != "" { + log.L.Warn("WARNING! userns remap is not supported with nerdctl build. dropping the config.") + } + return types.BuilderBuildOptions{ GOptions: globalOptions, BuildKitHost: buildKitHost, diff --git a/cmd/nerdctl/container/container_create.go b/cmd/nerdctl/container/container_create.go index 67db1a4f94a..46ec70e60d8 100644 --- a/cmd/nerdctl/container/container_create.go +++ b/cmd/nerdctl/container/container_create.go @@ -454,6 +454,30 @@ func createOptions(cmd *cobra.Command) (types.ContainerCreateOptions, error) { } // #endregion + // #region for UserNS + opt.UserNS, err = cmd.Flags().GetString("userns-remap") + if err != nil { + return opt, err + } + + userns, err := cmd.Flags().GetString("userns") + if err != nil { + return opt, err + } + + if userns == "host" { + opt.UserNS = "" + } else if userns != "" { + return opt, fmt.Errorf("invalid user mode") + } + + if opt.Privileged && opt.UserNS != "" { + //userns-remap is not supported with privileged flag. + // Ref: https://docs.docker.com/engine/security/userns-remap/ + return opt, fmt.Errorf("privileged flag cannot be used with userns-remap") + } + // #endregion + return opt, nil } diff --git a/cmd/nerdctl/container/container_create_linux_test.go b/cmd/nerdctl/container/container_create_linux_test.go index 3ea83d8ac96..f017a1a8e9e 100644 --- a/cmd/nerdctl/container/container_create_linux_test.go +++ b/cmd/nerdctl/container/container_create_linux_test.go @@ -19,9 +19,12 @@ package container import ( "errors" "fmt" + "io" "os" "path/filepath" + "strconv" "strings" + "syscall" "testing" "github.com/opencontainers/go-digest" @@ -325,3 +328,197 @@ func TestCreateFromOCIArchive(t *testing.T) { base.Cmd("create", "--rm", "--name", containerName, fmt.Sprintf("oci-archive://%s", tarPath)).AssertOK() base.Cmd("start", "--attach", containerName).AssertOutContains("test-nerdctl-create-from-oci-archive") } + +func TestUsernsMappingCreateCmd(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: require.All( + nerdtest.AllowModifyUserns, + nerdtest.RemapIDs, + require.Not(nerdtest.Docker)), + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + data.Labels().Set("validUserns", "nerdctltestuser") + data.Labels().Set("expectedHostUID", "123456789") + data.Labels().Set("invalidUserns", "invaliduser") + }, + SubTests: []*test.Case{ + { + Description: "Test container create with valid Userns", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + helpers.Ensure("create", "--tty", "--userns-remap", data.Labels().Get("validUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage) + return helpers.Command("start", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) + assert.NilError(t, err, "Failed to get container host UID") + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + }, + } + }, + }, + { + Description: "Test container create failure with valid Userns and privileged flag", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("create", "--tty", "--privileged", "--userns-remap", data.Labels().Get("validUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "Test container create with invalid Userns", + NoParallel: true, // Changes system config so running in non parallel mode + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("create", "--tty", "--userns-remap", data.Labels().Get("invalidUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + }, + } + testCase.Run(t) +} + +func getContainerHostUID(helpers test.Helpers, containerName string) (string, error) { + result := helpers.Capture("inspect", "--format", "{{.State.Pid}}", containerName) + pidStr := strings.TrimSpace(result) + pid, err := strconv.Atoi(pidStr) + if err != nil { + return "", fmt.Errorf("invalid PID: %v", err) + } + + stat, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) + if err != nil { + return "", fmt.Errorf("failed to stat process: %v", err) + } + + uid := int(stat.Sys().(*syscall.Stat_t).Uid) + return strconv.Itoa(uid), nil +} + +func appendUsernsConfig(userns string, hostUID string, helpers test.Helpers) error { + if err := addUser(userns, hostUID, helpers); err != nil { + return fmt.Errorf("failed to add user %s: %w", userns, err) + } + + entry := fmt.Sprintf("%s:%s:65536\n", userns, hostUID) + + tempDir := helpers.T().TempDir() + files := []string{"subuid", "subgid"} + for _, file := range files { + + fileBak := filepath.Join(tempDir, file) + defer os.Remove(fileBak) + d, err := os.Create(fileBak) + if err != nil { + return fmt.Errorf("failed to create %s: %w", fileBak, err) + } + + s, err := os.Open(filepath.Join("/etc", file)) + if err != nil { + return fmt.Errorf("failed to open %s: %w", file, err) + } + defer s.Close() + + _, err = io.Copy(d, s) + if err != nil { + return fmt.Errorf("failed to copy %s to %s: %w", file, fileBak, err) + } + + f, err := os.OpenFile(fmt.Sprintf("/etc/%s", file), os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open %s: %w", file, err) + } + defer f.Close() + + if _, err := f.WriteString(entry); err != nil { + return fmt.Errorf("failed to write to %s: %w", file, err) + } + } + return nil +} + +func addUser(username string, hostID string, helpers test.Helpers) error { + helpers.Custom("groupadd", "-g", hostID, username).Run(&test.Expected{ + ExitCode: 0}) + helpers.Custom("useradd", "-u", hostID, "-g", hostID, "-s", "/bin/false", username).Run(&test.Expected{ + ExitCode: 0}) + return nil +} + +func removeUsernsConfig(t *testing.T, userns string, helpers test.Helpers) { + if err := delUser(userns, helpers); err != nil { + t.Logf("failed to del user %s, Error: %s", userns, err) + } + + if err := delGroup(userns, helpers); err != nil { + t.Logf("failed to del group %s, Error: %s", userns, err) + } + + tempDir := helpers.T().TempDir() + files := []string{"subuid", "subgid"} + for _, file := range files { + fileBak := filepath.Join(tempDir, file) + s, err := os.Open(fileBak) + if err != nil { + t.Logf("failed to open %s, Error: %s", fileBak, err) + continue + } + defer s.Close() + + d, err := os.Open(filepath.Join("/etc/%s", file)) + if err != nil { + t.Logf("failed to open %s, Error: %s", file, err) + continue + + } + defer d.Close() + + _, err = io.Copy(d, s) + if err != nil { + t.Logf("failed to restore. Copy %s to %s failed, Error %s", fileBak, file, err) + continue + } + + } +} + +func delUser(username string, helpers test.Helpers) error { + helpers.Custom("userdel", username).RunAnyhow() + return nil +} + +func delGroup(groupname string, helpers test.Helpers) error { + helpers.Custom("groupdel", groupname).RunAnyhow() + return nil +} diff --git a/cmd/nerdctl/container/container_run.go b/cmd/nerdctl/container/container_run.go index 84bc42f05e6..be629b7eb2f 100644 --- a/cmd/nerdctl/container/container_run.go +++ b/cmd/nerdctl/container/container_run.go @@ -288,7 +288,6 @@ func setCreateFlags(cmd *cobra.Command) { // #endregion cmd.Flags().String("ipfs-address", "", "multiaddr of IPFS API (default uses $IPFS_PATH env variable if defined or local directory ~/.ipfs)") - cmd.Flags().String("isolation", "default", "Specify isolation technology for container. On Linux the only valid value is default. Windows options are host, process and hyperv with process isolation as the default") cmd.RegisterFlagCompletionFunc("isolation", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if runtime.GOOS == "windows" { @@ -296,6 +295,7 @@ func setCreateFlags(cmd *cobra.Command) { } return []string{"default"}, cobra.ShellCompDirectiveNoFileComp }) + cmd.Flags().String("userns", "", "Specify host to disable userns-remap") } diff --git a/cmd/nerdctl/container/container_run_user_linux_test.go b/cmd/nerdctl/container/container_run_user_linux_test.go index 968057f1404..5e71bfef83e 100644 --- a/cmd/nerdctl/container/container_run_user_linux_test.go +++ b/cmd/nerdctl/container/container_run_user_linux_test.go @@ -17,9 +17,16 @@ package container import ( + "fmt" "testing" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestRunUserGID(t *testing.T) { @@ -181,3 +188,236 @@ func TestRunAddGroup_CVE_2023_25173(t *testing.T) { base.Cmd(cmd...).AssertOutContains(testCase.expected + "\n") } } + +func TestUsernsMappingRunCmd(t *testing.T) { + nerdtest.Setup() + testCase := &test.Case{ + Require: require.All( + nerdtest.AllowModifyUserns, + nerdtest.RemapIDs, + require.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + data.Labels().Set("validUserns", "nerdctltestuser") + data.Labels().Set("expectedHostUID", "123456789") + data.Labels().Set("validUid", "123456789") + data.Labels().Set("net-container", "net-container") + data.Labels().Set("invalidUserns", "invaliduser") + }, + SubTests: []*test.Case{ + { + Description: "Test container run with valid Userns format userns username", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--tty", "-d", "--userns-remap", data.Labels().Get("validUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) + if err != nil { + t.Fatalf("Failed to get container host UID: %v", err) + } + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + }, + } + }, + }, + { + Description: "Test container run with valid Userns --userns uid", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--tty", "-d", "--userns-remap", data.Labels().Get("validUid"), "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) + if err != nil { + t.Fatalf("Failed to get container host UID: %v", err) + } + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + }, + } + }, + }, + { + Description: "Test container run failure with valid Userns and privileged flag", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--tty", "--privileged", "--userns-remap", data.Labels().Get("validUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "Test container run with valid Userns format --userns :", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--tty", "-d", "--userns-remap", fmt.Sprintf("%s:%s", data.Labels().Get("validUserns"), data.Labels().Get("validUserns")), "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) + if err != nil { + t.Fatalf("Failed to get container host UID: %v", err) + } + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + }, + } + }, + }, + { + Description: "Test container run with valid Userns --userns uid:gid", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--tty", "-d", "--userns-remap", fmt.Sprintf("%s:%s", data.Labels().Get("validUid"), data.Labels().Get("validUid")), "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) + if err != nil { + t.Fatalf("Failed to get container host UID: %v", err) + } + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + }, + } + }, + }, + { + Description: "Test container network share with valid Userns", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + helpers.Ensure("run", "--tty", "-d", "--userns-remap", data.Labels().Get("validUserns"), "--name", data.Labels().Get("net-container"), testutil.NginxAlpineImage) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("rm", "-f", data.Labels().Get("net-container")) + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--tty", "-d", "--userns-remap", data.Labels().Get("validUserns"), "--net", fmt.Sprintf("container:%s", data.Labels().Get("net-container")), "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + } + }, + }, + { + Description: "Test container run with valid Userns with override --userns=host", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--tty", "-d", "--userns-remap", data.Labels().Get("validUserns"), "--userns", "host", "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) + if err != nil { + t.Fatalf("Failed to get container host UID: %v", err) + } + assert.Assert(t, actualHostUID == "0", info) + }, + } + }, + }, + { + Description: "Test container run with valid Userns with invalid overrid --userns=hostinvalid", + NoParallel: true, // Changes system config so running in non parallel mode + Setup: func(data test.Data, helpers test.Helpers) { + err := appendUsernsConfig(data.Labels().Get("validUserns"), data.Labels().Get("expectedHostUID"), helpers) + assert.NilError(t, err, "Failed to append Userns config") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + removeUsernsConfig(t, data.Labels().Get("validUserns"), helpers) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--tty", "-d", "--userns-remap", data.Labels().Get("validUserns"), "--userns", "hostinvalid", "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "Test container run with invalid Userns", + Setup: func(data test.Data, helpers test.Helpers) { + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--tty", "-d", "--userns-remap", data.Labels().Get("invalidUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + }, + } + testCase.Run(t) +} diff --git a/cmd/nerdctl/main.go b/cmd/nerdctl/main.go index 1efadbe99d3..cfc29a59395 100644 --- a/cmd/nerdctl/main.go +++ b/cmd/nerdctl/main.go @@ -186,6 +186,7 @@ func initRootCmdFlags(rootCmd *cobra.Command, tomlPath string) (*pflag.FlagSet, helpers.AddPersistentStringFlag(rootCmd, "host-gateway-ip", nil, nil, nil, aliasToBeInherited, cfg.HostGatewayIP, "NERDCTL_HOST_GATEWAY_IP", "IP address that the special 'host-gateway' string in --add-host resolves to. Defaults to the IP address of the host. It has no effect without setting --add-host") helpers.AddPersistentStringFlag(rootCmd, "bridge-ip", nil, nil, nil, aliasToBeInherited, cfg.BridgeIP, "NERDCTL_BRIDGE_IP", "IP address for the default nerdctl bridge network") rootCmd.PersistentFlags().Bool("kube-hide-dupe", cfg.KubeHideDupe, "Deduplicate images for Kubernetes with namespace k8s.io") + rootCmd.PersistentFlags().String("userns-remap", cfg.UsernsRemap, "Support idmapping for creating and running containers. This options is only supported on linux. If `host` is passed, no idmapping is done. if a user name is passed, it does idmapping based on the uidmap and gidmap ranges specified in /etc/subuid and /etc/subgid respectively") return aliasToBeInherited, nil } diff --git a/docs/command-reference.md b/docs/command-reference.md index dc9cdc786fc..0c33b5e9d0c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -235,6 +235,9 @@ User flags: - :nerd_face: `--umask`: Set the umask inside the container. Defaults to 0022. Corresponds to Podman CLI. - :whale: `--group-add`: Add additional groups to join +- :nerd_face: `--userns-remap=:`: Support idmapping of containers. This options is only supported on rootful linux for container create and run if a user name and optionally group name is passed, it does idmapping based on the uidmap and gidmap ranges specified in /etc/subuid and /etc/subgid respectively. Note: `--userns-remap` is not supported for building containers. Nerdctl Build doesn't support userns-remap feature. (format: [:]) +- :whale: `--userns`: Set it to `host` to disable user namespacing set in nerdctl.toml or in cli. + Security flags: @@ -422,7 +425,7 @@ IPFS flags: Unimplemented `docker run` flags: `--device-cgroup-rule`, `--disable-content-trust`, `--expose`, `--health-*`, `--isolation`, `--no-healthcheck`, - `--link*`, `--publish-all`, `--storage-opt`, `--userns`, `--volume-driver` + `--link*`, `--publish-all`, `--storage-opt`, `--volume-driver` ### :whale: :blue_square: nerdctl exec diff --git a/docs/config.md b/docs/config.md index b4c6a20a381..620b4d2353b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -26,6 +26,7 @@ snapshotter = "stargz" cgroup_manager = "cgroupfs" hosts_dir = ["/etc/containerd/certs.d", "/etc/docker/certs.d"] experimental = true +userns_remap = "" ``` ## Properties @@ -47,6 +48,7 @@ experimental = true | `host_gateway_ip` | `--host-gateway-ip` | `NERDCTL_HOST_GATEWAY_IP` | IP address that the special 'host-gateway' string in --add-host resolves to. Defaults to the IP address of the host. It has no effect without setting --add-host | Since 1.3.0 | | `bridge_ip` | `--bridge-ip` | `NERDCTL_BRIDGE_IP` | IP address for the default nerdctl bridge network, e.g., 10.1.100.1/24 | Since 2.0.1 | | `kube_hide_dupe` | `--kube-hide-dupe` | | Deduplicate images for Kubernetes with namespace k8s.io, no more redundant ones are displayed | Since 2.0.3 | +| `userns_remap` | `--userns-remap` | | Support idmapping of containers. This options is only supported on rootful linux. If `host` is passed, no idmapping is done. if a user name is passed, it does idmapping based on the uidmap and gidmap ranges specified in /etc/subuid and /etc/subgid respectively. | Since 2.1.0 | The properties are parsed in the following precedence: 1. CLI flag diff --git a/go.mod b/go.mod index 1c9bb9cfb7d..077ac75a68c 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.23.0 require ( github.com/Masterminds/semver/v3 v3.3.1 github.com/Microsoft/go-winio v0.6.2 - github.com/Microsoft/hcsshim v0.12.9 + github.com/Microsoft/hcsshim v0.13.0-rc.3 github.com/compose-spec/compose-go/v2 v2.6.1 github.com/containerd/accelerated-container-image v1.3.0 github.com/containerd/cgroups/v3 v3.0.5 github.com/containerd/console v1.0.4 github.com/containerd/containerd/api v1.8.0 - github.com/containerd/containerd/v2 v2.0.5 + github.com/containerd/containerd/v2 v2.1.0-beta.1 github.com/containerd/continuity v0.4.5 github.com/containerd/errdefs v1.0.0 github.com/containerd/fifo v1.1.0 @@ -45,6 +45,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/moby/sys/mount v0.3.4 github.com/moby/sys/signal v0.7.1 + github.com/moby/sys/user v0.3.0 github.com/moby/sys/userns v0.1.0 github.com/moby/term v0.5.2 github.com/muesli/cancelreader v0.2.2 @@ -71,8 +72,6 @@ require ( ) require ( - github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect - github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20231105174938-2b5cbb29f3e2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/cilium/ebpf v0.16.0 // indirect @@ -92,7 +91,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -105,7 +104,6 @@ require ( github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/symlink v0.3.0 // indirect - github.com/moby/sys/user v0.3.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect @@ -113,7 +111,7 @@ require ( github.com/multiformats/go-multibase v0.2.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.0.7 // indirect - github.com/opencontainers/selinux v1.11.1 // indirect + github.com/opencontainers/selinux v1.12.0 // indirect github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect github.com/philhofer/fwd v1.1.3-0.20240612014219-fbbf4953d986 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -129,14 +127,15 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect - go.opentelemetry.io/otel v1.31.0 // indirect - go.opentelemetry.io/otel/metric v1.31.0 // indirect - go.opentelemetry.io/otel/trace v1.31.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 // indirect - google.golang.org/grpc v1.69.4 // indirect - google.golang.org/protobuf v1.36.2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect lukechampine.com/blake3 v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index 0e9b9ee537b..b8e761015cb 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20231105174938-2b5cbb29f3e2 h1:dIScnXFlF784X79oi7MzVT6GWqr/W1uUt0pB5CsDs9M= -github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20231105174938-2b5cbb29f3e2/go.mod h1:gCLVsLfv1egrcZu+GoJATN5ts75F2s62ih/457eWzOw= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -12,8 +10,8 @@ github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7r github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.12.9 h1:2zJy5KA+l0loz1HzEGqyNnjd3fyZA31ZBCGKacp6lLg= -github.com/Microsoft/hcsshim v0.12.9/go.mod h1:fJ0gkFAna6ukt0bLdKB8djt4XIJhF/vEPuoIWYVvZ8Y= +github.com/Microsoft/hcsshim v0.13.0-rc.3 h1:c2Glm+kfftlSccp+rNIJ6mp1UppJYTq7q9SObIu3GZs= +github.com/Microsoft/hcsshim v0.13.0-rc.3/go.mod h1:rc/I5c+x7rZHik6V5qj31JTATiLKh2BV7CsZpbNlt88= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -31,8 +29,8 @@ github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/containerd/api v1.8.0 h1:hVTNJKR8fMc/2Tiw60ZRijntNMd1U+JVMyTRdsD2bS0= github.com/containerd/containerd/api v1.8.0/go.mod h1:dFv4lt6S20wTu/hMcP4350RL87qPWLVa/OHOwmmdnYc= -github.com/containerd/containerd/v2 v2.0.5 h1:2vg/TjUXnaohAxiHnthQg8K06L9I4gdYEMcOLiMc8BQ= -github.com/containerd/containerd/v2 v2.0.5/go.mod h1:Qqo0UN43i2fX1FLkrSTCg6zcHNfjN7gEnx3NPRZI+N0= +github.com/containerd/containerd/v2 v2.1.0-beta.1 h1:ZKnSH4BzhpHpWd8rMKAIdQUt9pmmm9loDx9K6WYebq0= +github.com/containerd/containerd/v2 v2.1.0-beta.1/go.mod h1:a6m8gn911bu2uvYpd+D3C8vg5XVjb+ozE90IhzNqUmk= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -152,8 +150,9 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -240,8 +239,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/selinux v1.11.1 h1:nHFvthhM0qY8/m+vfhJylliSshm8G1jJ2jDMcgULaH8= -github.com/opencontainers/selinux v1.11.1/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= +github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplUkdTrmPb8= +github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= @@ -256,8 +255,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rootless-containers/bypass4netns v0.4.2 h1:JUZcpX7VLRfDkLxBPC6fyNalJGv9MjnjECOilZIvKRc= github.com/rootless-containers/bypass4netns v0.4.2/go.mod h1:iOY28IeFVqFHnK0qkBCQ3eKzKQgSW5DtlXFQJyJMAQk= github.com/rootless-containers/rootlesskit/v2 v2.3.4 h1:EHiqqiq+ntTfdnQtIgDR3etiuqKkRCPr1qpoizJxW/E= @@ -313,18 +312,20 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= @@ -453,15 +454,15 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 h1:3UsHvIr4Wc2aW4brOaSCmcxh9ksica6fHEr8P1XhkYw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -471,10 +472,11 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= -google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hack/test-integration.sh b/hack/test-integration.sh index 7834216b463..cf35382574d 100755 --- a/hack/test-integration.sh +++ b/hack/test-integration.sh @@ -47,7 +47,7 @@ for arg in "$@"; do done if [ "$needsudo" == "true" ] || [ "$needsudo" == "yes" ] || [ "$needsudo" == "1" ]; then - gotestsum "${args[@]}" -- -timeout="$timeout" -p 1 -exec sudo -args -test.allow-kill-daemon "$@" + gotestsum "${args[@]}" -- -timeout="$timeout" -p 1 -exec sudo -args -test.allow-kill-daemon "$@" else gotestsum "${args[@]}" -- -timeout="$timeout" -p 1 -args -test.allow-kill-daemon "$@" fi diff --git a/mod/tigron/test/command.go b/mod/tigron/test/command.go index 1516cf8d4dc..d985c45d956 100644 --- a/mod/tigron/test/command.go +++ b/mod/tigron/test/command.go @@ -301,6 +301,10 @@ func (gc *GenericCommand) Run(expect *Expected) { } } +func (gc *GenericCommand) RunAnyhow() { + gc.Run(&Expected{ExitCode: internal.ExitCodeNoCheck}) +} + func (gc *GenericCommand) Stderr() string { return gc.rawStdErr } diff --git a/mod/tigron/test/interfaces.go b/mod/tigron/test/interfaces.go index 12df876747a..654ba5a6aae 100644 --- a/mod/tigron/test/interfaces.go +++ b/mod/tigron/test/interfaces.go @@ -128,6 +128,8 @@ type TestableCommand interface { // An empty `&Expected{}` is (of course) equivalent to &Expected{Exit: 0}, meaning the command // is verified to be successful. Run(expect *Expected) + // RunAnyhow runs the command but skips comparing the expectation. + RunAnyhow() // Background allows starting a command in the background. Background() // Signal sends a signal to a backgrounded command. diff --git a/pkg/api/types/container_types.go b/pkg/api/types/container_types.go index a1b2d272c17..bf8a3eaf0f0 100644 --- a/pkg/api/types/container_types.go +++ b/pkg/api/types/container_types.go @@ -281,6 +281,9 @@ type ContainerCreateOptions struct { // ImagePullOpt specifies image pull options which holds the ImageVerifyOptions for verifying the image. ImagePullOpt ImagePullOptions + + // UserNS name for user namespace mapping of container + UserNS string } // ContainerStopOptions specifies options for `nerdctl (container) stop`. diff --git a/pkg/cmd/container/create.go b/pkg/cmd/container/create.go index 61e7686aebd..d64e57506d0 100644 --- a/pkg/cmd/container/create.go +++ b/pkg/cmd/container/create.go @@ -194,6 +194,35 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa } opts = append(opts, rootfsOpts...) cOpts = append(cOpts, rootfsCOpts...) + if options.UserNS != "" { + if !options.Rootfs { + if runtime.GOOS != "linux" { + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), errors.New("UserNS is only supported on Rootful Linux") + + } else if rootlessutil.IsRootless() { + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), errors.New("UserNS is only supported in Rootful Linux") + } + userNameSpaceOpts, userNameSpaceCOpts, err := getUserNamespaceOpts(ctx, client, &options, *ensuredImage, id) + if err != nil { + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err + } + opts = append(opts, userNameSpaceOpts...) + cOpts = append(cOpts, userNameSpaceCOpts...) + + userNsOpts, err := getContainerUserNamespaceNetOpts(ctx, client, netManager) + if err != nil { + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err + } + opts = append(opts, userNsOpts...) + } else { + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), errors.New("UserNS is not supported with rootfs images") + } + } else { + if !options.Rootfs { + // UserNS not set and its a normal image + cOpts = append(cOpts, containerd.WithNewSnapshot(id, ensuredImage.Image)) + } + } if options.Workdir != "" { opts = append(opts, oci.WithProcessCwd(options.Workdir)) @@ -381,7 +410,6 @@ func generateRootfsOpts(args []string, id string, ensured *imgutil.EnsuredImage, cOpts = append(cOpts, containerd.WithImage(ensured.Image), containerd.WithSnapshotter(ensured.Snapshotter), - containerd.WithNewSnapshot(id, ensured.Image), containerd.WithImageStopSignal(ensured.Image, "SIGTERM"), ) diff --git a/pkg/cmd/container/create_userns_opts_darwin.go b/pkg/cmd/container/create_userns_opts_darwin.go new file mode 100644 index 00000000000..98f0e24dc93 --- /dev/null +++ b/pkg/cmd/container/create_userns_opts_darwin.go @@ -0,0 +1,46 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "context" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/imgutil" +) + +func getUserNamespaceOpts( + ctx context.Context, + client *containerd.Client, + options *types.ContainerCreateOptions, + ensuredImage imgutil.EnsuredImage, + id string, +) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { + return []oci.SpecOpts{}, []containerd.NewContainerOpts{}, nil +} + +func getContainerUserNamespaceNetOpts( + ctx context.Context, + client *containerd.Client, + netManager containerutil.NetworkOptionsManager, +) ([]oci.SpecOpts, error) { + return []oci.SpecOpts{}, nil +} diff --git a/pkg/cmd/container/create_userns_opts_freebsd.go b/pkg/cmd/container/create_userns_opts_freebsd.go new file mode 100644 index 00000000000..98f0e24dc93 --- /dev/null +++ b/pkg/cmd/container/create_userns_opts_freebsd.go @@ -0,0 +1,46 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "context" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/imgutil" +) + +func getUserNamespaceOpts( + ctx context.Context, + client *containerd.Client, + options *types.ContainerCreateOptions, + ensuredImage imgutil.EnsuredImage, + id string, +) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { + return []oci.SpecOpts{}, []containerd.NewContainerOpts{}, nil +} + +func getContainerUserNamespaceNetOpts( + ctx context.Context, + client *containerd.Client, + netManager containerutil.NetworkOptionsManager, +) ([]oci.SpecOpts, error) { + return []oci.SpecOpts{}, nil +} diff --git a/pkg/cmd/container/create_userns_opts_linux.go b/pkg/cmd/container/create_userns_opts_linux.go new file mode 100644 index 00000000000..13f9275801c --- /dev/null +++ b/pkg/cmd/container/create_userns_opts_linux.go @@ -0,0 +1,516 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/moby/sys/user" + "github.com/opencontainers/runtime-spec/specs-go" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/netutil/nettype" +) + +// IDMap contains a single entry for user namespace range remapping. An array +// of IDMap entries represents the structure that will be provided to the Linux +// kernel for creating a user namespace. +type IDMap struct { + ContainerID int `json:"container_id"` + HostID int `json:"host_id"` + Size int `json:"size"` +} + +// IdentityMapping contains a mappings of UIDs and GIDs. +// The zero value represents an empty mapping. +type IdentityMapping struct { + UIDMaps []IDMap `json:"UIDMaps"` + GIDMaps []IDMap `json:"GIDMaps"` +} + +const ( + capabRemapIDs = "remap-ids" +) + +func getUserNamespaceOpts( + ctx context.Context, + client *containerd.Client, + options *types.ContainerCreateOptions, + ensuredImage imgutil.EnsuredImage, + id string, +) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { + if isDefaultUserns(options) { + return nil, createDefaultSnapshotOpts(id, ensuredImage), nil + } + + supportsRemap, err := snapshotterSupportsRemapLabels(ctx, client, ensuredImage.Snapshotter) + if err != nil { + return nil, nil, err + } else if !supportsRemap { + return nil, nil, errors.New("snapshotter does not support remap-ids capability") + } + + idMapping, err := loadAndValidateIDMapping(options.UserNS) + if err != nil { + return nil, nil, err + } + + uidMaps, gidMaps := convertMappings(idMapping) + specOpts := getUserNamespaceSpecOpts(uidMaps, gidMaps) + snapshotOpts, err := createSnapshotOpts(id, ensuredImage, uidMaps, gidMaps) + if err != nil { + return nil, nil, err + } + + return specOpts, snapshotOpts, nil +} + +// getContainerUserNamespaceNetOpts retrieves the user namespace path for the specified container. +func getContainerUserNamespaceNetOpts( + ctx context.Context, + client *containerd.Client, + netManager containerutil.NetworkOptionsManager, +) ([]oci.SpecOpts, error) { + netOpts, err := netManager.InternalNetworkingOptionLabels(ctx) + if err != nil { + return nil, err + } + netType, err := nettype.Detect(netOpts.NetworkSlice) + if err != nil { + return nil, err + } else if netType == nettype.Container { + containerName, err := getContainerNameFromNetworkSlice(netOpts) + if err != nil { + return nil, err + } + + container, err := findContainer(ctx, client, containerName) + if err != nil { + return nil, err + } + + if err := validateContainerStatus(ctx, container); err != nil { + return nil, err + } + + userNsPath, err := getUserNamespacePath(ctx, container) + if err != nil { + return nil, err + } + + var userNameSpaceSpecOpts []oci.SpecOpts + userNameSpaceSpecOpts = append(userNameSpaceSpecOpts, oci.WithLinuxNamespace(specs.LinuxNamespace{ + Type: specs.UserNamespace, + Path: userNsPath, + })) + return userNameSpaceSpecOpts, nil + } else if netType == nettype.Namespace { + netNsPath, err := getNamespacePathFromNetworkSlice(netOpts) + if err != nil { + return nil, err + } + userNsPath, err := getUserNamespacePathFromNetNsPath(netNsPath) + if err != nil { + return nil, err + } + var userNameSpaceSpecOpts []oci.SpecOpts + userNameSpaceSpecOpts = append(userNameSpaceSpecOpts, oci.WithLinuxNamespace(specs.LinuxNamespace{ + Type: specs.UserNamespace, + Path: userNsPath, + })) + return userNameSpaceSpecOpts, nil + + } + return []oci.SpecOpts{}, nil +} + +func getNamespacePathFromNetworkSlice(netOpts types.NetworkOptions) (string, error) { + if len(netOpts.NetworkSlice) > 1 { + return "", fmt.Errorf("only one network namespace is supported") + } + netItems := strings.Split(netOpts.NetworkSlice[0], ":") + if len(netItems) < 2 { + return "", fmt.Errorf("namespace networking argument format must be 'ns:', got: %q", netOpts.NetworkSlice[0]) + } + return netItems[1], nil +} + +func getUserNamespacePathFromNetNsPath(netNsPath string) (string, error) { + var path string + var maxSymlinkDepth = 255 + depth := 0 + for { + var err error + path, err = os.Readlink(netNsPath) + if err != nil { + break + } else if depth > maxSymlinkDepth { + return "", fmt.Errorf("EvalSymlinks: too many links") + } + + depth++ + _, err = os.Readlink(path) + if err != nil { + break + } else if depth > maxSymlinkDepth { + return "", fmt.Errorf("EvalSymlinks: too many links") + } + + netNsPath = path + depth++ + } + matched, err := regexp.MatchString(`^/proc/\d+/ns/net$`, netNsPath) + if err != nil { + return "", err + } else if !matched { + return "", fmt.Errorf("path is not of the form /proc//ns/net, unable to resolve user namespace") + } + userNsPath := filepath.Join(filepath.Dir(netNsPath), "user") + + return userNsPath, nil +} + +func convertIDMapToLinuxIDMapping(idMaps []IDMap) []specs.LinuxIDMapping { + // Create a slice to hold the resulting LinuxIDMapping structs + linuxIDMappings := make([]specs.LinuxIDMapping, len(idMaps)) + + // Iterate through the IDMap slice and convert each one + for i, idMap := range idMaps { + linuxIDMappings[i] = specs.LinuxIDMapping{ + ContainerID: uint32(idMap.ContainerID), + HostID: uint32(idMap.HostID), + Size: uint32(idMap.Size), + } + } + + // Return the converted slice + return linuxIDMappings +} + +// findContainer searches for a container by name and returns it if found. +func findContainer( + ctx context.Context, + client *containerd.Client, + containerName string, +) (containerd.Container, error) { + var container containerd.Container + + walker := &containerwalker.ContainerWalker{ + Client: client, + OnFound: func(_ context.Context, found containerwalker.Found) error { + if found.MatchCount > 1 { + return fmt.Errorf("multiple containers found with prefix: %s", containerName) + } + container = found.Container + return nil + }, + } + + if n, err := walker.Walk(ctx, containerName); err != nil { + return container, err + } else if n == 0 { + return container, fmt.Errorf("container not found: %s", containerName) + } + + return container, nil +} + +// validateContainerStatus checks if the container is running. +func validateContainerStatus(ctx context.Context, container containerd.Container) error { + task, err := container.Task(ctx, nil) + if err != nil { + return err + } + + status, err := task.Status(ctx) + if err != nil { + return err + } + + if status.Status != containerd.Running { + return fmt.Errorf("container %s is not running", container.ID()) + } + + return nil +} + +// getUserNamespacePath returns the path to the container's user namespace. +func getUserNamespacePath(ctx context.Context, container containerd.Container) (string, error) { + task, err := container.Task(ctx, nil) + if err != nil { + return "", err + } + + return fmt.Sprintf("/proc/%d/ns/user", task.Pid()), nil +} + +// Determines if the default UserNS should be used. +func isDefaultUserns(options *types.ContainerCreateOptions) bool { + return options.UserNS == "" || options.UserNS == "host" +} + +// Creates default snapshot options. +func createDefaultSnapshotOpts(id string, image imgutil.EnsuredImage) []containerd.NewContainerOpts { + return []containerd.NewContainerOpts{ + containerd.WithNewSnapshot(id, image.Image), + } +} + +// parseGroup parses a string identifier (name or ID) and returns the corresponding group +func parseGroup(identifier string) (user.Group, bool, error) { + id, err := strconv.Atoi(identifier) + if err == nil { + grp, err := user.LookupGid(id) + if err != nil { + return user.Group{}, true, fmt.Errorf("could not get group for gid %d: %w", id, err) + } + return grp, true, nil + } + + grp, err := user.LookupGroup(identifier) + if err != nil { + return user.Group{}, false, fmt.Errorf("could not get group for groupname %s: %w", identifier, err) + } + return grp, false, nil +} + +// parseIdentifier parses a string identifier (name or ID) and returns the corresponding user +func parseUser(identifier string) (user.User, bool, error) { + id, err := strconv.Atoi(identifier) + if err == nil { + usr, err := user.LookupUid(id) + if err != nil { + return user.User{}, true, fmt.Errorf("could not get user for uid %d: %w", id, err) + } + return usr, true, nil + } + + usr, err := user.LookupUser(identifier) + if err != nil { + return user.User{}, false, fmt.Errorf("could not get user for username %s: %w", identifier, err) + } + return usr, false, nil +} + +func getUserAndGroup(spec string) (user.User, user.Group, error) { + parts := strings.Split(spec, ":") + if len(parts) > 2 { + return user.User{}, user.Group{}, fmt.Errorf("invalid identity mapping format: %s", spec) + } else if len(parts) == 2 && (parts[0] == "" || parts[1] == "") { + return user.User{}, user.Group{}, fmt.Errorf("invalid identity mapping format: %s", spec) + } + + userPart := parts[0] + usr, _, err := parseUser(userPart) + if err != nil { + return user.User{}, user.Group{}, err + } + + var groupPart string + if len(parts) == 1 { + groupPart = userPart + } else { + groupPart = parts[1] + } + + group, _, err := parseGroup(groupPart) + if err != nil { + return user.User{}, user.Group{}, err + } + + return usr, group, nil +} + +// LoadIdentityMapping takes a requested identity mapping specification and +// using the data from /etc/sub{uid,gid} ranges, creates the +// uid and gid remapping ranges for that user/group pair. +// The specification can be in the following formats: +// (format: [:]) +func LoadIdentityMapping(spec string) (IdentityMapping, error) { + usr, groupUsr, err := getUserAndGroup(spec) + if err != nil { + return IdentityMapping{}, err + } + subuidRanges, err := lookupUserSubRangesFile("/etc/subuid", usr) + if err != nil { + return IdentityMapping{}, err + } + + subgidRanges, err := lookupGroupSubRangesFile("/etc/subgid", groupUsr) + if err != nil { + return IdentityMapping{}, err + } + + return IdentityMapping{ + UIDMaps: subuidRanges, + GIDMaps: subgidRanges, + }, nil +} + +func lookupUserSubRangesFile(path string, usr user.User) ([]IDMap, error) { + uidstr := strconv.Itoa(usr.Uid) + rangeList, err := user.ParseSubIDFileFilter(path, func(sid user.SubID) bool { + return sid.Name == usr.Name || sid.Name == uidstr + }) + if err != nil { + return nil, err + } + if len(rangeList) == 0 { + return nil, fmt.Errorf("no subuid ranges found for user %q", usr.Name) + } + + idMap := []IDMap{} + + containerID := 0 + for _, idrange := range rangeList { + idMap = append(idMap, IDMap{ + ContainerID: containerID, + HostID: int(idrange.SubID), + Size: int(idrange.Count), + }) + containerID = containerID + int(idrange.Count) + } + return idMap, nil +} + +func lookupGroupSubRangesFile(path string, grp user.Group) ([]IDMap, error) { + gidstr := strconv.Itoa(grp.Gid) + rangeList, err := user.ParseSubIDFileFilter(path, func(sid user.SubID) bool { + return sid.Name == grp.Name || sid.Name == gidstr + }) + if err != nil { + return nil, err + } + if len(rangeList) == 0 { + return nil, fmt.Errorf("no subuid ranges found for user %q", grp.Name) + } + + idMap := []IDMap{} + containerID := 0 + for _, idrange := range rangeList { + idMap = append(idMap, IDMap{ + ContainerID: containerID, + HostID: int(idrange.SubID), + Size: int(idrange.Count), + }) + containerID = containerID + int(idrange.Count) + } + return idMap, nil +} + +// Loads and validates the ID mapping from the given UserNS. +func loadAndValidateIDMapping(userNS string) (IdentityMapping, error) { + idMapping, err := LoadIdentityMapping(userNS) + if err != nil { + return IdentityMapping{}, err + } + if !validIDMapping(idMapping) { + return IdentityMapping{}, errors.New("no valid UID/GID mappings found") + } + return idMapping, nil +} + +// Validates that both UID and GID mappings are available. +func validIDMapping(mapping IdentityMapping) bool { + return len(mapping.UIDMaps) > 0 && len(mapping.GIDMaps) > 0 +} + +// Converts IDMapping into LinuxIDMapping structures. +func convertMappings(mapping IdentityMapping) ([]specs.LinuxIDMapping, []specs.LinuxIDMapping) { + return convertIDMapToLinuxIDMapping(mapping.UIDMaps), + convertIDMapToLinuxIDMapping(mapping.GIDMaps) +} + +// Builds OCI spec options for the user namespace. +func getUserNamespaceSpecOpts( + uidMaps, gidMaps []specs.LinuxIDMapping, +) []oci.SpecOpts { + return []oci.SpecOpts{oci.WithUserNamespace(uidMaps, gidMaps)} +} + +// Creates snapshot options based on ID mappings and snapshotter capabilities. +func createSnapshotOpts( + id string, + image imgutil.EnsuredImage, + uidMaps, gidMaps []specs.LinuxIDMapping, +) ([]containerd.NewContainerOpts, error) { + if !isValidMapping(uidMaps, gidMaps) { + return nil, errors.New("snapshotter uidmap gidmap config invalid") + } + return []containerd.NewContainerOpts{containerd.WithNewSnapshot(id, image.Image, WithUserNSRemapperLabels(uidMaps, gidMaps))}, nil +} + +func WithUserNSRemapperLabels(uidmaps, gidmaps []specs.LinuxIDMapping) snapshots.Opt { + idMap := ContainerdIDMap{ + UidMap: uidmaps, + GidMap: gidmaps, + } + uidmapLabel, gidmapLabel := idMap.Marshal() + return snapshots.WithLabels(map[string]string{ + snapshots.LabelSnapshotUIDMapping: uidmapLabel, + snapshots.LabelSnapshotGIDMapping: gidmapLabel, + }) +} + +func isValidMapping(uidMaps, gidMaps []specs.LinuxIDMapping) bool { + return len(uidMaps) > 0 && len(gidMaps) > 0 +} + +func getContainerNameFromNetworkSlice(netOpts types.NetworkOptions) (string, error) { + netItems := strings.Split(netOpts.NetworkSlice[0], ":") + if len(netItems) < 2 || netItems[1] == "" { + return "", fmt.Errorf("container networking argument format must be 'container:', got: %q", netOpts.NetworkSlice[0]) + } + containerName := netItems[1] + return containerName, nil +} + +func snapshotterSupportsRemapLabels( + ctx context.Context, + client *containerd.Client, + snapshotterName string, +) (bool, error) { + caps, err := client.GetSnapshotterCapabilities(ctx, snapshotterName) + if err != nil { + return false, err + } + return hasCapability(caps, capabRemapIDs), nil +} + +// Checks if the given capability exists in the list. +func hasCapability(caps []string, capability string) bool { + for _, cap := range caps { + if cap == capability { + return true + } + } + return false +} diff --git a/pkg/cmd/container/create_userns_opts_linux_test.go b/pkg/cmd/container/create_userns_opts_linux_test.go new file mode 100644 index 00000000000..ca8d6216b61 --- /dev/null +++ b/pkg/cmd/container/create_userns_opts_linux_test.go @@ -0,0 +1,140 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "testing" + + "github.com/opencontainers/runtime-spec/specs-go" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil" +) + +// TestCreateSnapshotOpts tests the createSnapshotOpts function. +func TestCreateSnapshotOpts(t *testing.T) { + tests := []struct { + name string + id string + image imgutil.EnsuredImage + uidMaps []specs.LinuxIDMapping + gidMaps []specs.LinuxIDMapping + expectError bool + }{ + { + name: "Single remapping", + id: "container1", + image: imgutil.EnsuredImage{}, + uidMaps: []specs.LinuxIDMapping{ + {HostID: 1000, Size: 1}, + }, + gidMaps: []specs.LinuxIDMapping{ + {HostID: 1000, Size: 1}, + }, + expectError: false, + }, + { + name: "Multi remapping with support", + id: "container2", + image: imgutil.EnsuredImage{}, + uidMaps: []specs.LinuxIDMapping{ + {HostID: 1000, Size: 1}, + {HostID: 2000, Size: 1}, + }, + gidMaps: []specs.LinuxIDMapping{ + {HostID: 3000, Size: 1}, + }, + expectError: false, + }, + { + name: "Empty UID/GID maps", + id: "container4", + image: imgutil.EnsuredImage{}, + uidMaps: []specs.LinuxIDMapping{}, + gidMaps: []specs.LinuxIDMapping{}, + expectError: true, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + _, err := createSnapshotOpts(testCase.id, testCase.image, testCase.uidMaps, testCase.gidMaps) + + if testCase.expectError { + assert.Assert(t, err != nil) + } else { + assert.NilError(t, err) + } + }) + } +} + +// TestGetContainerNameFromNetworkSlice tests the getContainerNameFromNetworkSlice function. +func TestGetContainerNameFromNetworkSlice(t *testing.T) { + tests := []struct { + name string + netOpts types.NetworkOptions + expected string + expectError bool + }{ + { + name: "Valid input with container name", + netOpts: types.NetworkOptions{ + NetworkSlice: []string{"container:mycontainer"}, + }, + expected: "mycontainer", + expectError: false, + }, + { + name: "Invalid input with no colon separator", + netOpts: types.NetworkOptions{ + NetworkSlice: []string{"container-mycontainer"}, + }, + expected: "", + expectError: true, + }, + { + name: "Empty NetworkSlice", + netOpts: types.NetworkOptions{ + NetworkSlice: []string{""}, + }, + expected: "", + expectError: true, + }, + { + name: "Missing container name", + netOpts: types.NetworkOptions{ + NetworkSlice: []string{"container:"}, + }, + expected: "", + expectError: true, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + containerName, err := getContainerNameFromNetworkSlice(testCase.netOpts) + if testCase.expectError { + assert.Assert(t, err != nil) + } else { + assert.NilError(t, err) + assert.Equal(t, testCase.expected, containerName) + } + }) + } +} diff --git a/pkg/cmd/container/create_userns_opts_windows.go b/pkg/cmd/container/create_userns_opts_windows.go new file mode 100644 index 00000000000..98f0e24dc93 --- /dev/null +++ b/pkg/cmd/container/create_userns_opts_windows.go @@ -0,0 +1,46 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "context" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/imgutil" +) + +func getUserNamespaceOpts( + ctx context.Context, + client *containerd.Client, + options *types.ContainerCreateOptions, + ensuredImage imgutil.EnsuredImage, + id string, +) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { + return []oci.SpecOpts{}, []containerd.NewContainerOpts{}, nil +} + +func getContainerUserNamespaceNetOpts( + ctx context.Context, + client *containerd.Client, + netManager containerutil.NetworkOptionsManager, +) ([]oci.SpecOpts, error) { + return []oci.SpecOpts{}, nil +} diff --git a/pkg/cmd/container/idmap.go b/pkg/cmd/container/idmap.go new file mode 100644 index 00000000000..23d366e4082 --- /dev/null +++ b/pkg/cmd/container/idmap.go @@ -0,0 +1,170 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "errors" + "fmt" + "strings" + + "github.com/opencontainers/runtime-spec/specs-go" +) + +const invalidID = 1<<32 - 1 + +var invalidUser = User{Uid: invalidID, Gid: invalidID} + +// User is a Uid and Gid pair of a user +// +//nolint:revive +type User struct { + Uid uint32 + Gid uint32 +} + +// IDMap contains the mappings of Uids and Gids. +// +//nolint:revive +type ContainerdIDMap struct { + UidMap []specs.LinuxIDMapping `json:"UidMap"` + GidMap []specs.LinuxIDMapping `json:"GidMap"` +} + +// RootPair returns the ID pair for the root user +func (i *ContainerdIDMap) RootPair() (User, error) { + uid, err := toHost(0, i.UidMap) + if err != nil { + return invalidUser, err + } + gid, err := toHost(0, i.GidMap) + if err != nil { + return invalidUser, err + } + return User{Uid: uid, Gid: gid}, nil +} + +// ToHost returns the host user ID pair for the container ID pair. +func (i *ContainerdIDMap) ToHost(pair User) (User, error) { + var ( + target User + err error + ) + target.Uid, err = toHost(pair.Uid, i.UidMap) + if err != nil { + return invalidUser, err + } + target.Gid, err = toHost(pair.Gid, i.GidMap) + if err != nil { + return invalidUser, err + } + return target, nil +} + +// Marshal serializes the IDMap object into two strings: +// one uidmap list and another one for gidmap list +func (i *ContainerdIDMap) Marshal() (string, string) { + marshal := func(mappings []specs.LinuxIDMapping) string { + var arr []string + for _, m := range mappings { + arr = append(arr, serializeLinuxIDMapping(m)) + } + return strings.Join(arr, ",") + } + return marshal(i.UidMap), marshal(i.GidMap) +} + +// Unmarshal deserialize the passed uidmap and gidmap strings +// into a IDMap object. Error is returned in case of failure +func (i *ContainerdIDMap) Unmarshal(uidMap, gidMap string) error { + unmarshal := func(str string, fn func(m specs.LinuxIDMapping)) error { + if len(str) == 0 { + return nil + } + for _, mapping := range strings.Split(str, ",") { + m, err := deserializeLinuxIDMapping(mapping) + if err != nil { + return err + } + fn(m) + } + return nil + } + if err := unmarshal(uidMap, func(m specs.LinuxIDMapping) { + i.UidMap = append(i.UidMap, m) + }); err != nil { + return err + } + return unmarshal(gidMap, func(m specs.LinuxIDMapping) { + i.GidMap = append(i.GidMap, m) + }) +} + +// toHost takes an id mapping and a remapped ID, and translates the +// ID to the mapped host ID. If no map is provided, then the translation +// assumes a 1-to-1 mapping and returns the passed in id # +func toHost(contID uint32, idMap []specs.LinuxIDMapping) (uint32, error) { + if idMap == nil { + return contID, nil + } + for _, m := range idMap { + high, err := safeSum(m.ContainerID, m.Size) + if err != nil { + break + } + if contID >= m.ContainerID && contID < high { + hostID, err := safeSum(m.HostID, contID-m.ContainerID) + if err != nil || hostID == invalidID { + break + } + return hostID, nil + } + } + return invalidID, fmt.Errorf("container ID %d cannot be mapped to a host ID", contID) +} + +// safeSum returns the sum of x and y. or an error if the result overflows +func safeSum(x, y uint32) (uint32, error) { + z := x + y + if z < x || z < y { + return invalidID, errors.New("ID overflow") + } + return z, nil +} + +// serializeLinuxIDMapping marshals a LinuxIDMapping object to string +func serializeLinuxIDMapping(m specs.LinuxIDMapping) string { + return fmt.Sprintf("%d:%d:%d", m.ContainerID, m.HostID, m.Size) +} + +// deserializeLinuxIDMapping unmarshals a string to a LinuxIDMapping object +func deserializeLinuxIDMapping(str string) (specs.LinuxIDMapping, error) { + var ( + hostID, ctrID, length int64 + ) + _, err := fmt.Sscanf(str, "%d:%d:%d", &ctrID, &hostID, &length) + if err != nil { + return specs.LinuxIDMapping{}, fmt.Errorf("input value %s unparsable: %w", str, err) + } + if ctrID < 0 || ctrID >= invalidID || hostID < 0 || hostID >= invalidID || length < 0 || length >= invalidID { + return specs.LinuxIDMapping{}, fmt.Errorf("invalid mapping \"%s\"", str) + } + return specs.LinuxIDMapping{ + ContainerID: uint32(ctrID), + HostID: uint32(hostID), + Size: uint32(length), + }, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 1666ab61a0e..461d4ff97a2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -41,6 +41,7 @@ type Config struct { HostGatewayIP string `toml:"host_gateway_ip"` BridgeIP string `toml:"bridge_ip, omitempty"` KubeHideDupe bool `toml:"kube_hide_dupe"` + UsernsRemap string `toml:"userns_remap, omitempty"` } // New creates a default Config object statically, @@ -61,5 +62,6 @@ func New() *Config { Experimental: true, HostGatewayIP: ncdefaults.HostGatewayIP(), KubeHideDupe: false, + UsernsRemap: "", } } diff --git a/pkg/testutil/nerdtest/requirements.go b/pkg/testutil/nerdtest/requirements.go index 90291a28aef..bfe226e05aa 100644 --- a/pkg/testutil/nerdtest/requirements.go +++ b/pkg/testutil/nerdtest/requirements.go @@ -17,6 +17,7 @@ package nerdtest import ( + "context" "encoding/json" "fmt" "os" @@ -25,12 +26,16 @@ import ( "gotest.tools/v3/assert" + "github.com/containerd/containerd/v2/defaults" + "github.com/containerd/containerd/v2/pkg/namespaces" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" "github.com/containerd/nerdctl/v2/pkg/buildkitutil" + "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" ) @@ -360,3 +365,40 @@ var Private = &test.Requirement{ } }, } + +var AllowModifyUserns = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + if testutil.GetAllowModifyUsers() { + return true, "allow modify userns is enabled" + } + return false, "allow modify userns is disabled" + }, +} + +var RemapIDs = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + // Create a cobra command for ProcessRootCmdFlags to get globalOptions + ctx := context.Background() + snapshotterName := defaults.DefaultSnapshotter + namespace := namespaces.Default + address := defaults.DefaultAddress + + client, ctx, cancel, err := clientutil.NewClient(ctx, namespace, address) + if err != nil { + return false, fmt.Sprintf("failed to create client: %v", err) + } + defer cancel() + + caps, err := client.GetSnapshotterCapabilities(ctx, snapshotterName) + if err != nil { + return false, fmt.Sprintf("failed to get snapshotter capabilities: %v", err) + } + + for _, cap := range caps { + if cap == "remap-ids" { + return true, "snapshotter supports ID remapping" + } + } + return false, "snapshotter does not support ID remapping" + }, +} diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index f12c704e8bb..ab91e371afa 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -490,11 +490,12 @@ const ( ) var ( - flagTestTarget Target - flagTestKillDaemon bool - flagTestIPv6 bool - flagTestKube bool - flagTestFlaky bool + flagTestTarget Target + flagTestKillDaemon bool + flagTestIPv6 bool + flagTestKube bool + flagTestFlaky bool + flagTestModifyUsers bool ) var ( @@ -504,6 +505,7 @@ var ( func M(m *testing.M) { flag.StringVar(&flagTestTarget, "test.target", Nerdctl, "target to test") flag.BoolVar(&flagTestKillDaemon, "test.allow-kill-daemon", false, "enable tests that kill the daemon") + flag.BoolVar(&flagTestModifyUsers, "test.allow-modify-users", false, "enable tests that creates/deletes user accounts on the host") flag.BoolVar(&flagTestIPv6, "test.only-ipv6", false, "enable tests on IPv6") flag.BoolVar(&flagTestKube, "test.only-kubernetes", false, "enable tests on Kubernetes") flag.BoolVar(&flagTestFlaky, "test.only-flaky", false, "enable testing of flaky tests only (if false, flaky tests are ignored)") @@ -580,6 +582,10 @@ func GetDaemonIsKillable() bool { return flagTestKillDaemon } +func GetAllowModifyUsers() bool { + return flagTestModifyUsers +} + func IsDocker() bool { return GetTarget() == Docker }