If a container was started with
- a numeric uid
- both a user and group (username:groupname)
- uid and gid (uid:gid)
The copy action failed, because the "username:groupname"
was looked up using getent.
This patch;
- splits `user` and `group` before looking up
- if numeric `uid` or `gid` is used and lookup fails,
the `uid` / `gid` is used as-is
The code also looked up the user and group on the host
instead of in the container when using getent; this patch
fixes the lookup to only use the container's /etc/passwd
and /etc/group instead.
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
| ... | ... |
@@ -3,26 +3,130 @@ |
| 3 | 3 |
package daemon // import "github.com/docker/docker/daemon" |
| 4 | 4 |
|
| 5 | 5 |
import ( |
| 6 |
+ "fmt" |
|
| 7 |
+ "math" |
|
| 8 |
+ "strconv" |
|
| 9 |
+ "strings" |
|
| 10 |
+ |
|
| 6 | 11 |
"github.com/docker/docker/container" |
| 7 |
- "github.com/docker/docker/internal/usergroup" |
|
| 12 |
+ "github.com/docker/docker/errdefs" |
|
| 8 | 13 |
"github.com/docker/docker/pkg/archive" |
| 9 | 14 |
"github.com/docker/docker/pkg/idtools" |
| 15 |
+ "github.com/moby/sys/user" |
|
| 10 | 16 |
) |
| 11 | 17 |
|
| 12 |
-func (daemon *Daemon) tarCopyOptions(container *container.Container, noOverwriteDirNonDir bool) (*archive.TarOptions, error) {
|
|
| 13 |
- if container.Config.User == "" {
|
|
| 18 |
+func (daemon *Daemon) tarCopyOptions(ctr *container.Container, noOverwriteDirNonDir bool) (*archive.TarOptions, error) {
|
|
| 19 |
+ if ctr.Config.User == "" {
|
|
| 14 | 20 |
return daemon.defaultTarCopyOptions(noOverwriteDirNonDir), nil |
| 15 | 21 |
} |
| 16 | 22 |
|
| 17 |
- user, err := usergroup.LookupUser(container.Config.User) |
|
| 23 |
+ uid, gid, err := getUIDGID(ctr.Config.User) |
|
| 18 | 24 |
if err != nil {
|
| 19 |
- return nil, err |
|
| 25 |
+ return nil, errdefs.InvalidParameter(err) |
|
| 20 | 26 |
} |
| 21 | 27 |
|
| 22 |
- identity := idtools.Identity{UID: user.Uid, GID: user.Gid}
|
|
| 23 |
- |
|
| 24 | 28 |
return &archive.TarOptions{
|
| 25 | 29 |
NoOverwriteDirNonDir: noOverwriteDirNonDir, |
| 26 |
- ChownOpts: &identity, |
|
| 30 |
+ ChownOpts: &idtools.Identity{UID: uid, GID: gid},
|
|
| 27 | 31 |
}, nil |
| 28 | 32 |
} |
| 33 |
+ |
|
| 34 |
+// getUIDGID resolves the UID and GID of a given container's Config.User, |
|
| 35 |
+// which can contain a user name (or ID) and, optionally, group (or ID). |
|
| 36 |
+// |
|
| 37 |
+// usergrp is a username or uid, and optional group, in the format `user[:group]`. |
|
| 38 |
+// Both `user` and `group` can be provided as an `uid` / `gid`, so the following |
|
| 39 |
+// formats are supported: |
|
| 40 |
+// |
|
| 41 |
+// - username - valid username from /etc/passwd |
|
| 42 |
+// - username:groupname - valid username; valid groupname from /etc/passwd, /etc/group |
|
| 43 |
+// - uid - 32-bit unsigned int valid Linux UID value |
|
| 44 |
+// - uid:gid - uid value; 32-bit unsigned int Linux GID value |
|
| 45 |
+// - username:gid - valid username from getent(1), gid value; 32-bit unsigned int Linux GID value |
|
| 46 |
+// - uid:groupname - 32-bit unsigned int valid Linux UID value, valid groupname from /etc/group |
|
| 47 |
+// |
|
| 48 |
+// If only a username (or uid) is provided, an attempt is made to look up the gid |
|
| 49 |
+// for that username using /etc/passwd |
|
| 50 |
+func getUIDGID(ctrUser string) (uid int, gid int, _ error) {
|
|
| 51 |
+ userNameOrID, groupNameOrID, _ := strings.Cut(ctrUser, ":") |
|
| 52 |
+ |
|
| 53 |
+ // Align with behavior of docker run, which treats an empty username |
|
| 54 |
+ // or groupname as default (0 (root)). |
|
| 55 |
+ // |
|
| 56 |
+ // docker run --rm --user ":33" alpine id |
|
| 57 |
+ // uid=0(root) gid=33 groups=33 |
|
| 58 |
+ // |
|
| 59 |
+ // docker run --rm --user "33:" alpine id |
|
| 60 |
+ // uid=33 gid=0(root) groups=0(root) |
|
| 61 |
+ if userNameOrID != "" {
|
|
| 62 |
+ var err error |
|
| 63 |
+ uid, gid, err = lookupUser(userNameOrID) |
|
| 64 |
+ if err != nil {
|
|
| 65 |
+ return 0, 0, err |
|
| 66 |
+ } |
|
| 67 |
+ } |
|
| 68 |
+ if groupNameOrID != "" {
|
|
| 69 |
+ var err error |
|
| 70 |
+ gid, err = lookupGID(groupNameOrID) |
|
| 71 |
+ if err != nil {
|
|
| 72 |
+ return 0, 0, err |
|
| 73 |
+ } |
|
| 74 |
+ } |
|
| 75 |
+ return uid, gid, nil |
|
| 76 |
+} |
|
| 77 |
+ |
|
| 78 |
+// getIDOrName checks whether nameOrID is a ID (integer) or a Name. |
|
| 79 |
+// It assumes nameOrID is a name when failing to parse as integer, |
|
| 80 |
+// in which case a non-empty name is returned. |
|
| 81 |
+func getIDOrName(nameOrID string) (id int, name string) {
|
|
| 82 |
+ if uid, err := strconv.ParseUint(nameOrID, 10, 32); err == nil && uid <= math.MaxInt32 {
|
|
| 83 |
+ // uid provided |
|
| 84 |
+ return int(uid), "" |
|
| 85 |
+ } |
|
| 86 |
+ // not an id, assume name |
|
| 87 |
+ return 0, nameOrID |
|
| 88 |
+} |
|
| 89 |
+ |
|
| 90 |
+func lookupUser(nameOrID string) (uid, gid int, _ error) {
|
|
| 91 |
+ userID, userName := getIDOrName(nameOrID) |
|
| 92 |
+ if userName != "" {
|
|
| 93 |
+ u, err := user.LookupUser(userName) |
|
| 94 |
+ if err != nil {
|
|
| 95 |
+ return 0, 0, fmt.Errorf("failed to look up user %q in container: %w", userName, err)
|
|
| 96 |
+ } |
|
| 97 |
+ return u.Uid, u.Gid, nil |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ u, err := user.LookupUid(userID) |
|
| 101 |
+ if err != nil {
|
|
| 102 |
+ // Match behavior of "docker run": when using a UID for the |
|
| 103 |
+ // user, resolving the user and its primary group is best-effort. |
|
| 104 |
+ // If a user with the given UID is found, we use its primary |
|
| 105 |
+ // group, otherwise use it as-is and use the default (0) as |
|
| 106 |
+ // GID. |
|
| 107 |
+ // |
|
| 108 |
+ // docker run --rm --user 12345 ubuntu id |
|
| 109 |
+ // uid=12345 gid=0(root) groups=0(root) |
|
| 110 |
+ // |
|
| 111 |
+ // docker run --rm ubuntu cat /etc/passwd | grep www-data |
|
| 112 |
+ // www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin |
|
| 113 |
+ // |
|
| 114 |
+ // docker run --rm --user 33 ubuntu id |
|
| 115 |
+ // uid=33(www-data) gid=33(www-data) groups=33(www-data) |
|
| 116 |
+ return userID, 0, nil |
|
| 117 |
+ } |
|
| 118 |
+ return u.Uid, u.Gid, nil |
|
| 119 |
+} |
|
| 120 |
+ |
|
| 121 |
+func lookupGID(nameOrID string) (int, error) {
|
|
| 122 |
+ groupID, groupName := getIDOrName(nameOrID) |
|
| 123 |
+ if groupName == "" {
|
|
| 124 |
+ // GID is passed, no need to look up |
|
| 125 |
+ return groupID, nil |
|
| 126 |
+ } |
|
| 127 |
+ group, err := user.LookupGroup(groupName) |
|
| 128 |
+ if err != nil {
|
|
| 129 |
+ return 0, fmt.Errorf("failed to look up group %q in container: %w", nameOrID, err)
|
|
| 130 |
+ } |
|
| 131 |
+ return group.Gid, nil |
|
| 132 |
+} |
| ... | ... |
@@ -3,10 +3,12 @@ package container // import "github.com/docker/docker/integration/container" |
| 3 | 3 |
import ( |
| 4 | 4 |
"archive/tar" |
| 5 | 5 |
"bytes" |
| 6 |
+ "context" |
|
| 6 | 7 |
"encoding/json" |
| 7 | 8 |
"io" |
| 8 | 9 |
"os" |
| 9 | 10 |
"path/filepath" |
| 11 |
+ "strings" |
|
| 10 | 12 |
"testing" |
| 11 | 13 |
|
| 12 | 14 |
"github.com/docker/docker/api/types" |
| ... | ... |
@@ -89,6 +91,124 @@ func TestCopyEmptyFile(t *testing.T) {
|
| 89 | 89 |
defer rdr.Close() |
| 90 | 90 |
} |
| 91 | 91 |
|
| 92 |
+func TestCopyToContainerCopyUIDGID(t *testing.T) {
|
|
| 93 |
+ skip.If(t, testEnv.DaemonInfo.OSType == "windows") |
|
| 94 |
+ ctx := setupTest(t) |
|
| 95 |
+ |
|
| 96 |
+ apiClient := testEnv.APIClient() |
|
| 97 |
+ imageID := makeTestImage(ctx, t) |
|
| 98 |
+ |
|
| 99 |
+ tests := []struct {
|
|
| 100 |
+ doc string |
|
| 101 |
+ user string |
|
| 102 |
+ expected string |
|
| 103 |
+ }{
|
|
| 104 |
+ {
|
|
| 105 |
+ doc: "image default", |
|
| 106 |
+ expected: "2375:2376", |
|
| 107 |
+ }, |
|
| 108 |
+ {
|
|
| 109 |
+ // Align with behavior of docker run, which treats a UID with |
|
| 110 |
+ // empty groupname as default (0 (root)). |
|
| 111 |
+ // |
|
| 112 |
+ // docker run --rm --user "7777:" alpine id |
|
| 113 |
+ // uid=7777 gid=0(root) groups=0(root) |
|
| 114 |
+ doc: "trailing colon", |
|
| 115 |
+ user: "7777:", |
|
| 116 |
+ expected: "7777:0", |
|
| 117 |
+ }, |
|
| 118 |
+ {
|
|
| 119 |
+ // Align with behavior of docker run, which treats a GID with |
|
| 120 |
+ // empty username as default (0 (root)). |
|
| 121 |
+ // |
|
| 122 |
+ // docker run --rm --user ":7777" alpine id |
|
| 123 |
+ // uid=0(root) gid=7777 groups=7777 |
|
| 124 |
+ doc: "leading colon", |
|
| 125 |
+ user: ":7777", |
|
| 126 |
+ expected: "0:7777", |
|
| 127 |
+ }, |
|
| 128 |
+ {
|
|
| 129 |
+ doc: "known UID", |
|
| 130 |
+ user: "2375", |
|
| 131 |
+ expected: "2375:2376", |
|
| 132 |
+ }, |
|
| 133 |
+ {
|
|
| 134 |
+ doc: "unknown UID", |
|
| 135 |
+ user: "7777", |
|
| 136 |
+ expected: "7777:0", |
|
| 137 |
+ }, |
|
| 138 |
+ {
|
|
| 139 |
+ doc: "UID and GID", |
|
| 140 |
+ user: "2375:2376", |
|
| 141 |
+ expected: "2375:2376", |
|
| 142 |
+ }, |
|
| 143 |
+ {
|
|
| 144 |
+ doc: "username and groupname", |
|
| 145 |
+ user: "testuser:testgroup", |
|
| 146 |
+ expected: "2375:2376", |
|
| 147 |
+ }, |
|
| 148 |
+ {
|
|
| 149 |
+ doc: "username", |
|
| 150 |
+ user: "testuser", |
|
| 151 |
+ expected: "2375:2376", |
|
| 152 |
+ }, |
|
| 153 |
+ {
|
|
| 154 |
+ doc: "username and GID", |
|
| 155 |
+ user: "testuser:7777", |
|
| 156 |
+ expected: "2375:7777", |
|
| 157 |
+ }, |
|
| 158 |
+ {
|
|
| 159 |
+ doc: "UID and groupname", |
|
| 160 |
+ user: "7777:testgroup", |
|
| 161 |
+ expected: "7777:2376", |
|
| 162 |
+ }, |
|
| 163 |
+ } |
|
| 164 |
+ |
|
| 165 |
+ for _, tc := range tests {
|
|
| 166 |
+ t.Run(tc.doc, func(t *testing.T) {
|
|
| 167 |
+ cID := container.Run(ctx, t, apiClient, container.WithImage(imageID), container.WithUser(tc.user)) |
|
| 168 |
+ defer container.Remove(ctx, t, apiClient, cID, containertypes.RemoveOptions{Force: true})
|
|
| 169 |
+ |
|
| 170 |
+ // tar with empty file |
|
| 171 |
+ dstDir, preparedArchive := makeEmptyArchive(t) |
|
| 172 |
+ err := apiClient.CopyToContainer(ctx, cID, dstDir, preparedArchive, containertypes.CopyToContainerOptions{
|
|
| 173 |
+ CopyUIDGID: true, |
|
| 174 |
+ }) |
|
| 175 |
+ assert.NilError(t, err) |
|
| 176 |
+ |
|
| 177 |
+ res, err := container.Exec(ctx, apiClient, cID, []string{"stat", "-c", "%u:%g", "/empty-file.txt"})
|
|
| 178 |
+ assert.NilError(t, err) |
|
| 179 |
+ assert.Equal(t, res.ExitCode, 0) |
|
| 180 |
+ assert.Equal(t, strings.TrimSpace(res.Stdout()), tc.expected) |
|
| 181 |
+ }) |
|
| 182 |
+ } |
|
| 183 |
+} |
|
| 184 |
+ |
|
| 185 |
+func makeTestImage(ctx context.Context, t *testing.T) (imageID string) {
|
|
| 186 |
+ t.Helper() |
|
| 187 |
+ apiClient := testEnv.APIClient() |
|
| 188 |
+ tmpDir := t.TempDir() |
|
| 189 |
+ buildCtx := fakecontext.New(t, tmpDir, fakecontext.WithDockerfile(` |
|
| 190 |
+ FROM busybox |
|
| 191 |
+ RUN addgroup -g 2376 testgroup && adduser -D -u 2375 -G testgroup testuser |
|
| 192 |
+ USER testuser:testgroup |
|
| 193 |
+ `)) |
|
| 194 |
+ defer buildCtx.Close() |
|
| 195 |
+ |
|
| 196 |
+ resp, err := apiClient.ImageBuild(ctx, buildCtx.AsTarReader(t), types.ImageBuildOptions{})
|
|
| 197 |
+ assert.NilError(t, err) |
|
| 198 |
+ defer resp.Body.Close() |
|
| 199 |
+ |
|
| 200 |
+ err = jsonmessage.DisplayJSONMessagesStream(resp.Body, io.Discard, 0, false, func(msg jsonmessage.JSONMessage) {
|
|
| 201 |
+ var r types.BuildResult |
|
| 202 |
+ assert.NilError(t, json.Unmarshal(*msg.Aux, &r)) |
|
| 203 |
+ imageID = r.ID |
|
| 204 |
+ }) |
|
| 205 |
+ assert.NilError(t, err) |
|
| 206 |
+ assert.Assert(t, imageID != "") |
|
| 207 |
+ return imageID |
|
| 208 |
+} |
|
| 209 |
+ |
|
| 92 | 210 |
func makeEmptyArchive(t *testing.T) (string, io.ReadCloser) {
|
| 93 | 211 |
tmpDir := t.TempDir() |
| 94 | 212 |
srcPath := filepath.Join(tmpDir, "empty-file.txt") |