Skip to content

Commit 7dc2486

Browse files
committed
libct: switch to numeric UID/GID/groups
This addresses the following TODO in the code (added back in 2015 by commit 845fc65): > // TODO: fix libcontainer's API to better support uid/gid in a typesafe way. Historically, libcontainer internally uses strings for user, group, and additional (aka supplementary) groups. Yet, runc receives those credentials as part of runtime-spec's process, which uses integers for all of them (see [1], [2]). What happens next is: 1. runc start/run/exec converts those credentials to strings (a User string containing "UID:GID", and a []string for additional GIDs) and passes those onto runc init. 2. runc init converts them back to int, in the most complicated way possible (parsing container's /etc/passwd and /etc/group). All this conversion and, especially, parsing is totally unnecessary, but is performed on every container exec (and start). The only benefit of all this is, a libcontainer user could use user and group names instead of numeric IDs (but runc itself is not using this feature, and we don't know if there are any other users of this). Let's remove this back and forth translation, hopefully increasing runc exec performance. The only remaining need to parse /etc/passwd is to set HOME environment variable for a specified UID, in case $HOME is not explicitly set in process.Env. This can now be done right in prepareEnv, which simplifies the code flow a lot. Alas, we can not use standard os/user.LookupId, as it could cache host's /etc/passwd or the current user (even with the osusergo tag). PS Note that the structures being changed (initConfig and Process) are never saved to disk as JSON by runc, so there is no compatibility issue for runc users. Still, this is a breaking change in libcontainer, but we never promised that libcontainer API will be stable (and there's a special package that can handle it -- github.com/moby/sys/user). Reflect this in CHANGELOG. For 3998. [1]: https://github.com/opencontainers/runtime-spec/blob/v1.0.2/config.md#posix-platform-user [2]: https://github.com/opencontainers/runtime-spec/blob/v1.0.2/specs-go/config.go#L86 Signed-off-by: Kir Kolyshkin <[email protected]>
1 parent b55167e commit 7dc2486

File tree

11 files changed

+112
-107
lines changed

11 files changed

+112
-107
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
### libcontainer API
1010
* `configs.CommandHook` struct has changed, Command is now a pointer.
1111
Also, `configs.NewCommandHook` now accepts a `*Command`. (#4325)
12+
* The `Process` struct has `User` string field replaced with numeric
13+
`UID` and `GID` fields, and `AdditionalGroups` changed its type from
14+
`[]string` to `[]int`. Essentially, resolution of user and group
15+
names to IDs is no longer performed by libcontainer, so if a libcontainer
16+
user previously relied on this feature, now they have to convert names to
17+
IDs before calling libcontainer; it is recommended to use Go package
18+
github.com/moby/sys/user for that. (#3999)
1219

1320
## [1.2.0] - 2024-10-22
1421

libcontainer/container_linux.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,8 @@ func (c *Container) newInitConfig(process *Process) *initConfig {
686686
Config: c.config,
687687
Args: process.Args,
688688
Env: process.Env,
689-
User: process.User,
689+
UID: process.UID,
690+
GID: process.GID,
690691
AdditionalGroups: process.AdditionalGroups,
691692
Cwd: process.Cwd,
692693
Capabilities: process.Capabilities,

libcontainer/env.go

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,23 @@ import (
66
"os"
77
"slices"
88
"strings"
9+
10+
"github.com/moby/sys/user"
11+
"github.com/sirupsen/logrus"
912
)
1013

1114
// prepareEnv processes a list of environment variables, preparing it
1215
// for direct consumption by unix.Exec. In particular, it:
1316
// - validates each variable is in the NAME=VALUE format and
1417
// contains no \0 (nil) bytes;
1518
// - removes any duplicates (keeping only the last value for each key)
16-
// - sets PATH for the current process, if found in the list.
19+
// - sets PATH for the current process, if found in the list;
20+
// - adds HOME to returned environment, if not found in the list.
1721
//
18-
// It returns the deduplicated environment, a flag telling whether HOME
19-
// is present in the input, and an error.
20-
func prepareEnv(env []string) ([]string, bool, error) {
22+
// Returns the prepared environment.
23+
func prepareEnv(env []string, uid int) ([]string, error) {
2124
if env == nil {
22-
return nil, false, nil
25+
return nil, nil
2326
}
2427
// Deduplication code based on dedupEnv from Go 1.22 os/exec.
2528

@@ -31,29 +34,55 @@ func prepareEnv(env []string) ([]string, bool, error) {
3134
kv := env[n-1]
3235
i := strings.IndexByte(kv, '=')
3336
if i == -1 {
34-
return nil, false, errors.New("invalid environment variable: missing '='")
37+
return nil, errors.New("invalid environment variable: missing '='")
3538
}
3639
if i == 0 {
37-
return nil, false, errors.New("invalid environment variable: name cannot be empty")
40+
return nil, errors.New("invalid environment variable: name cannot be empty")
3841
}
3942
key := kv[:i]
4043
if saw[key] { // Duplicate.
4144
continue
4245
}
4346
saw[key] = true
4447
if strings.IndexByte(kv, 0) >= 0 {
45-
return nil, false, fmt.Errorf("invalid environment variable %q: contains nul byte (\\x00)", key)
48+
return nil, fmt.Errorf("invalid environment variable %q: contains nul byte (\\x00)", key)
4649
}
4750
if key == "PATH" {
4851
// Needs to be set as it is used for binary lookup.
4952
if err := os.Setenv("PATH", kv[i+1:]); err != nil {
50-
return nil, false, err
53+
return nil, err
5154
}
5255
}
5356
out = append(out, kv)
5457
}
5558
// Restore the original order.
5659
slices.Reverse(out)
5760

58-
return out, saw["HOME"], nil
61+
// If HOME is not found in env, get it from container's /etc/passwd and add.
62+
if !saw["HOME"] {
63+
home, err := getUserHome(uid)
64+
if err != nil {
65+
// For backward compatibility, don't return an error, but merely log it.
66+
logrus.WithError(err).Debugf("HOME not set in process.env, and getting UID %d homedir failed", uid)
67+
}
68+
69+
out = append(out, "HOME="+home)
70+
}
71+
72+
return out, nil
73+
}
74+
75+
func getUserHome(uid int) (string, error) {
76+
const defaultHome = "/" // Default value, return this with any error.
77+
78+
u, err := user.LookupUid(uid)
79+
if err != nil {
80+
// ErrNoPasswdEntries is kinda expected as any UID can be specified.
81+
if errors.Is(err, user.ErrNoPasswdEntries) {
82+
err = nil
83+
}
84+
return defaultHome, err
85+
}
86+
87+
return u.Home, nil
5988
}

libcontainer/env_test.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
11
package libcontainer
22

33
import (
4+
"os/user"
45
"slices"
6+
"strconv"
57
"testing"
68
)
79

8-
func TestPrepareEnvDedup(t *testing.T) {
10+
func TestPrepareEnv(t *testing.T) {
11+
u, err := user.Current()
12+
if err != nil {
13+
t.Fatal(err)
14+
}
15+
home := "HOME=" + u.HomeDir
16+
uid, err := strconv.Atoi(u.Uid)
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
921
tests := []struct {
1022
env, wantEnv []string
1123
}{
1224
{
1325
env: []string{},
14-
wantEnv: []string{},
26+
wantEnv: []string{home},
1527
},
1628
{
17-
env: []string{"HOME=/root", "FOO=bar"},
18-
wantEnv: []string{"HOME=/root", "FOO=bar"},
29+
env: []string{"HOME=/whoo", "FOO=bar"},
30+
wantEnv: []string{"HOME=/whoo", "FOO=bar"},
1931
},
2032
{
2133
env: []string{"A=a", "A=b", "A=c"},
22-
wantEnv: []string{"A=c"},
34+
wantEnv: []string{"A=c", home},
2335
},
2436
{
2537
env: []string{"TERM=vt100", "HOME=/home/one", "HOME=/home/two", "TERM=xterm", "HOME=/home/three", "FOO=bar"},
@@ -28,7 +40,7 @@ func TestPrepareEnvDedup(t *testing.T) {
2840
}
2941

3042
for _, tc := range tests {
31-
env, _, err := prepareEnv(tc.env)
43+
env, err := prepareEnv(tc.env, uid)
3244
if err != nil {
3345
t.Error(err)
3446
continue

libcontainer/init_linux.go

Lines changed: 20 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414
"syscall"
1515

1616
"github.com/containerd/console"
17-
"github.com/moby/sys/user"
1817
"github.com/opencontainers/runtime-spec/specs-go"
1918
"github.com/sirupsen/logrus"
2019
"github.com/vishvananda/netlink"
@@ -57,8 +56,9 @@ type initConfig struct {
5756
ProcessLabel string `json:"process_label"`
5857
AppArmorProfile string `json:"apparmor_profile"`
5958
NoNewPrivileges bool `json:"no_new_privileges"`
60-
User string `json:"user"`
61-
AdditionalGroups []string `json:"additional_groups"`
59+
UID int `json:"uid"`
60+
GID int `json:"gid"`
61+
AdditionalGroups []int `json:"additional_groups"`
6262
Config *configs.Config `json:"config"`
6363
Networks []*network `json:"network"`
6464
PassedFilesCount int `json:"passed_files_count"`
@@ -208,7 +208,7 @@ func startInitialization() (retErr error) {
208208
}
209209

210210
func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSocket, pidfdSocket, fifoFile, logPipe *os.File) error {
211-
env, homeSet, err := prepareEnv(config.Env)
211+
env, err := prepareEnv(config.Env, config.UID)
212212
if err != nil {
213213
return err
214214
}
@@ -226,7 +226,6 @@ func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSock
226226
pidfdSocket: pidfdSocket,
227227
config: config,
228228
logPipe: logPipe,
229-
addHome: !homeSet,
230229
}
231230
return i.Init()
232231
case initStandard:
@@ -238,7 +237,6 @@ func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSock
238237
config: config,
239238
fifoFile: fifoFile,
240239
logPipe: logPipe,
241-
addHome: !homeSet,
242240
}
243241
return i.Init()
244242
}
@@ -274,7 +272,7 @@ func verifyCwd() error {
274272
// finalizeNamespace drops the caps, sets the correct user
275273
// and working dir, and closes any leaked file descriptors
276274
// before executing the command inside the namespace.
277-
func finalizeNamespace(config *initConfig, addHome bool) error {
275+
func finalizeNamespace(config *initConfig) error {
278276
// Ensure that all unwanted fds we may have accidentally
279277
// inherited are marked close-on-exec so they stay out of the
280278
// container
@@ -320,7 +318,7 @@ func finalizeNamespace(config *initConfig, addHome bool) error {
320318
if err := system.SetKeepCaps(); err != nil {
321319
return fmt.Errorf("unable to set keep caps: %w", err)
322320
}
323-
if err := setupUser(config, addHome); err != nil {
321+
if err := setupUser(config); err != nil {
324322
return fmt.Errorf("unable to setup user: %w", err)
325323
}
326324
// Change working directory AFTER the user has been set up, if we haven't done it yet.
@@ -438,52 +436,19 @@ func syncParentSeccomp(pipe *syncSocket, seccompFd int) error {
438436
return readSync(pipe, procSeccompDone)
439437
}
440438

441-
// setupUser changes the groups, gid, and uid for the user inside the container,
442-
// and appends user's HOME to config.Env if addHome is true.
443-
func setupUser(config *initConfig, addHome bool) error {
444-
// Set up defaults.
445-
defaultExecUser := user.ExecUser{
446-
Uid: 0,
447-
Gid: 0,
448-
Home: "/",
449-
}
450-
451-
passwdPath, err := user.GetPasswdPath()
452-
if err != nil {
453-
return err
454-
}
455-
456-
groupPath, err := user.GetGroupPath()
457-
if err != nil {
458-
return err
459-
}
460-
461-
execUser, err := user.GetExecUserPath(config.User, &defaultExecUser, passwdPath, groupPath)
462-
if err != nil {
463-
return err
464-
}
465-
466-
var addGroups []int
467-
if len(config.AdditionalGroups) > 0 {
468-
addGroups, err = user.GetAdditionalGroupsPath(config.AdditionalGroups, groupPath)
469-
if err != nil {
470-
return err
471-
}
472-
}
473-
474-
if config.RootlessEUID {
439+
// setupUser changes the groups, gid, and uid for the user inside the container.
440+
func setupUser(config *initConfig) error {
441+
if config.RootlessEUID && len(config.AdditionalGroups) > 0 {
475442
// We cannot set any additional groups in a rootless container and thus
476443
// we bail if the user asked us to do so. TODO: We currently can't do
477444
// this check earlier, but if libcontainer.Process.User was typesafe
478445
// this might work.
479-
if len(addGroups) > 0 {
480-
return errors.New("cannot set any additional groups in a rootless container")
481-
}
446+
return errors.New("cannot set any additional groups in a rootless container")
482447
}
483448

484449
// Before we change to the container's user make sure that the processes
485450
// STDIO is correctly owned by the user that we are switching to.
486-
if err := fixStdioPermissions(execUser); err != nil {
451+
if err := fixStdioPermissions(config.UID); err != nil {
487452
return err
488453
}
489454

@@ -502,36 +467,30 @@ func setupUser(config *initConfig, addHome bool) error {
502467
allowSupGroups := !config.RootlessEUID && string(bytes.TrimSpace(setgroups)) != "deny"
503468

504469
if allowSupGroups {
505-
suppGroups := append(execUser.Sgids, addGroups...)
506-
if err := unix.Setgroups(suppGroups); err != nil {
470+
if err := unix.Setgroups(config.AdditionalGroups); err != nil {
507471
return &os.SyscallError{Syscall: "setgroups", Err: err}
508472
}
509473
}
510474

511-
if err := unix.Setgid(execUser.Gid); err != nil {
475+
if err := unix.Setgid(config.GID); err != nil {
512476
if err == unix.EINVAL {
513-
return fmt.Errorf("cannot setgid to unmapped gid %d in user namespace", execUser.Gid)
477+
return fmt.Errorf("cannot setgid to unmapped gid %d in user namespace", config.GID)
514478
}
515479
return err
516480
}
517-
if err := unix.Setuid(execUser.Uid); err != nil {
481+
if err := unix.Setuid(config.UID); err != nil {
518482
if err == unix.EINVAL {
519-
return fmt.Errorf("cannot setuid to unmapped uid %d in user namespace", execUser.Uid)
483+
return fmt.Errorf("cannot setuid to unmapped uid %d in user namespace", config.UID)
520484
}
521485
return err
522486
}
523-
524-
// If we didn't get HOME already, set it based on the user's HOME.
525-
if addHome {
526-
config.Env = append(config.Env, "HOME="+execUser.Home)
527-
}
528487
return nil
529488
}
530489

531-
// fixStdioPermissions fixes the permissions of PID 1's STDIO within the container to the specified user.
490+
// fixStdioPermissions fixes the permissions of PID 1's STDIO within the container to the specified uid.
532491
// The ownership needs to match because it is created outside of the container and needs to be
533492
// localized.
534-
func fixStdioPermissions(u *user.ExecUser) error {
493+
func fixStdioPermissions(uid int) error {
535494
var null unix.Stat_t
536495
if err := unix.Stat("/dev/null", &null); err != nil {
537496
return &os.PathError{Op: "stat", Path: "/dev/null", Err: err}
@@ -544,7 +503,7 @@ func fixStdioPermissions(u *user.ExecUser) error {
544503

545504
// Skip chown if uid is already the one we want or any of the STDIO descriptors
546505
// were redirected to /dev/null.
547-
if int(s.Uid) == u.Uid || s.Rdev == null.Rdev {
506+
if int(s.Uid) == uid || s.Rdev == null.Rdev {
548507
continue
549508
}
550509

@@ -554,7 +513,7 @@ func fixStdioPermissions(u *user.ExecUser) error {
554513
// that users expect to be able to actually use their console. Without
555514
// this code, you couldn't effectively run as a non-root user inside a
556515
// container and also have a console set up.
557-
if err := file.Chown(u.Uid, int(s.Gid)); err != nil {
516+
if err := file.Chown(uid, int(s.Gid)); err != nil {
558517
// If we've hit an EINVAL then s.Gid isn't mapped in the user
559518
// namespace. If we've hit an EPERM then the inode's current owner
560519
// is not mapped in our user namespace (in particular,

libcontainer/integration/exec_test.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ func TestAdditionalGroups(t *testing.T) {
399399
Env: standardEnvironment,
400400
Stdin: nil,
401401
Stdout: &stdout,
402-
AdditionalGroups: []string{"plugdev", "audio"},
402+
AdditionalGroups: []int{3333, 99999},
403403
Init: true,
404404
}
405405
err = container.Run(&pconfig)
@@ -410,13 +410,11 @@ func TestAdditionalGroups(t *testing.T) {
410410

411411
outputGroups := stdout.String()
412412

413-
// Check that the groups output has the groups that we specified
414-
if !strings.Contains(outputGroups, "audio") {
415-
t.Fatalf("Listed groups do not contain the audio group as expected: %v", outputGroups)
416-
}
417-
418-
if !strings.Contains(outputGroups, "plugdev") {
419-
t.Fatalf("Listed groups do not contain the plugdev group as expected: %v", outputGroups)
413+
// Check that the groups output has the groups that we specified.
414+
for _, gid := range pconfig.AdditionalGroups {
415+
if !strings.Contains(outputGroups, strconv.Itoa(gid)) {
416+
t.Errorf("Listed groups do not contain gid %d as expected: %v", gid, outputGroups)
417+
}
420418
}
421419
}
422420

0 commit comments

Comments
 (0)