internal/accounts/accounts_unix.go (341 lines of code) (raw):

// Copyright 2024 Google LLC // // 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 // // https://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. //go:build !windows package accounts import ( "bufio" "context" "errors" "fmt" "os" "os/user" "path/filepath" "strconv" "strings" "sync" "syscall" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) const ( // getentNoSuchKey is the exit code returned by getent when a key is not // found in the database. // // Per documentation, exit code 2: "One or more supplied key could not be // found in the database", see the man page: // // https://man7.org/linux/man-pages/man1/getent.1.html. getentNoSuchKey = 2 ) var ( // systemsHomeDir is the base directory for system's users home directories. systemsHomeDir = "/home" // googleUsersMu is a mutex to protect operations manipulating // googleUsersFile. googleUsersMu sync.RWMutex // This is slightly misleading legacy name. This is not a list of all users // created by google software on a GCE VM, this is a list of users manually // created by the guest-agent. At time of writing, this means that users // only end up in this file if they are created from metadata ssh keys. googleUsersFile = "/var/lib/google/google_users" ) // UnixUID returns the UID of the user as an integer. func (u *User) UnixUID() int { val, err := strconv.Atoi(u.UID) // The validity of the UID must be checked during the instantiation of // User objects. if err != nil { panic(fmt.Errorf("failed to convert UID to int: %v", err)) } return val } // UnixGID returns the GID of the user as an integer. func (u *User) UnixGID() int { val, err := strconv.Atoi(u.GID) // The validity of the GID must be checked during the instantiation of // User objects. if err != nil { panic(fmt.Errorf("failed to convert UID to int: %v", err)) } return val } // ValidateUnixIDS validates the UID and GID of the user - it determines if the // set values are valid integers. func (u *User) ValidateUnixIDS() error { if _, err := strconv.Atoi(u.UID); err != nil { return fmt.Errorf("failed to convert UID to int: %v", err) } if _, err := strconv.Atoi(u.GID); err != nil { return fmt.Errorf("failed to convert GID to int: %v", err) } return nil } // UnixGID returns the GID of the group as an integer. func (g *Group) UnixGID() int { val, err := strconv.Atoi(g.GID) // The validity of the GID must be checked during the instantiation of // User objects. if err != nil { panic(fmt.Errorf("failed to convert UID to int: %v", err)) } return val } // ValidateUnixGID validates the GID of the group - it determines if the // set values are valid integers. func (g *Group) ValidateUnixGID() error { if _, err := strconv.Atoi(g.GID); err != nil { return fmt.Errorf("failed to convert GID to int: %v", err) } return nil } // SetPassword sets the users password. This is unimplemented on unix. func (u *User) SetPassword(context.Context, string) error { return errors.New("setting user passwords is not supported on unix") } // FindUser gets the information of the user, returning user.UnkownUserError if // the user does not exist on the system or the wrapped run error if the user // list could not be obtained. // // Any user returned by this function is guaranteed to have a valid UID and GID // - a call to ValidateUnixIDS() will never return an error. func FindUser(ctx context.Context, username string) (*User, error) { getent, err := run.WithContext(ctx, run.Options{ OutputType: run.OutputStdout, ExecMode: run.ExecModeSync, Name: "getent", Args: []string{"passwd", username}, }) if err != nil { // No such key exit code is returned when the user does not exist. if err, ok := run.AsExitError(err); ok && err.ExitCode() == getentNoSuchKey { return nil, user.UnknownUserError(username) } return nil, fmt.Errorf("could not get user list: %w", err) } // The result of getent will contain a single entry (given we are querying a // single user). passwdEntry, err := parsePasswdEntry(getent.Output, username) if err != nil { return nil, fmt.Errorf("could not parse user %s: %w", username, err) } return passwdEntry, nil } // parsePasswdEntry parses /etc/passwd style input for the named user. func parsePasswdEntry(line string, username string) (*User, error) { line = strings.TrimSpace(strings.TrimSuffix(line, "\n")) prefix := username + ":" // Validate the correctness of the entry format, it should contain the // username followed by a colon as a prefix (i.e. "kevin:"). if !strings.HasPrefix(line, prefix) { return nil, fmt.Errorf("invalid passwd entry for %q, expected prefix %q", username, prefix) } // kevin:x:1005:1006::/home/kevin:/usr/bin/zsh parts := strings.SplitN(string(line), ":", 7) if len(parts) < 7 { return nil, fmt.Errorf("invalid passwd entry for %s", username) } res := &User{ Username: parts[0], Password: parts[1], UID: parts[2], GID: parts[3], Name: parts[4], HomeDir: parts[5], Shell: parts[6], } if err := res.ValidateUnixIDS(); err != nil { return nil, err } return res, nil } // CreateUser creates a user with the given username. Depending on user // configuration, options such as UID and GID may be ignored. If accurate // information about the created user is important the caller should call // FindUser after creation. Returns the wrapped run error if the command failed. func CreateUser(ctx context.Context, u *User) error { useUID, useGID := u.UnixUID(), u.UnixGID() // If the it's not possible to reuse the homedir, the user will be created // with the requested UID and GID. useUID, useGID = reuseHomeDir(u.Username, useUID, useGID) config := cfg.Retrieve() cmd := config.Accounts.UserAddCmd if useUID > 0 { cmd = fmt.Sprintf("%s -u %d", cmd, useUID) } if useGID > 0 { cmd = fmt.Sprintf("%s -g %d", cmd, useGID) } if _, err := runCommandTemplate(ctx, cmd, u, nil); err != nil { return fmt.Errorf("failed to run useraddcmd %s: %w", cmd, err) } if err := addToGUsers(ctx, u.Username); err != nil { galog.Errorf("user %s was created but not added to google users: %v", u.Username, err) } return nil } // reuseHomeDir Tries to determine the users UID and GID based on the attributes // of a left over home directory - kept after the user removal - in case this // user existed before. func reuseHomeDir(username string, useUID int, useGID int) (int, int) { config := cfg.Retrieve() resUID, resGID := useUID, useGID if !config.Accounts.ReuseHomedir { return resUID, resGID } lastUID, lastGID, err := userHomeDirUIDAndGID(username) if err != nil { galog.V(2).Debugf("not reusing UID and GID for %s: %v", username, err) return resUID, resGID } if useUID != lastUID { galog.Debugf("caller requested to create user %s with uid %d, but ReuseHomedir is enabled and the user's last uid was %d", username, useUID, lastUID) } resUID = lastUID if useGID != lastGID { galog.Debugf("caller requested to create user %s with gid %d, but ReuseHomedir is enabled and the user's last gid was %d", username, useGID, lastGID) } resGID = lastGID return resUID, resGID } // userHomeDirUIDAndGID returns the UID and GID of the user's home directory. func userHomeDirUIDAndGID(uname string) (int, int, error) { dir, err := os.Stat(filepath.Join(systemsHomeDir, uname)) if err != nil { return -1, -1, fmt.Errorf("could not stat user's(%q) directory: %w", uname, err) } stat, ok := dir.Sys().(*syscall.Stat_t) if !ok { return -1, -1, fmt.Errorf("could not get stat_t for %s", dir.Name()) } return int(stat.Uid), int(stat.Gid), nil } // Write name to google users file if it's not already there. Returns wrapped os // errors on failure. func addToGUsers(ctx context.Context, username string) error { googleUsersMu.Lock() defer googleUsersMu.Unlock() gusersDir := filepath.Dir(googleUsersFile) if err := os.MkdirAll(gusersDir, 0755); err != nil { return fmt.Errorf("could not create directory %s for google_users: %w", gusersDir, err) } gusers, err := os.OpenFile(googleUsersFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600) if err != nil { return fmt.Errorf("failed to open google_users file: %w", err) } defer gusers.Close() // Determine if the user is already in the file. bs := bufio.NewScanner(gusers) for bs.Scan() { if bs.Text() == username { return nil } if err := bs.Err(); err != nil { return fmt.Errorf("failed to read google_users data %s: %w", googleUsersFile, err) } } // No user found, append the user to the file. data := fmt.Sprintf("%s\n", username) n, err := gusers.WriteString(data) if err != nil { return fmt.Errorf("failed to append %s to %s: %w", username, googleUsersFile, err) } if n == len(data) { return fmt.Errorf("failed writing %s to %s, wrote %d bytes, expected %d", username, googleUsersFile, n, len(data)) } return nil } // removeFromGUsers removes the user's entry file from google users file. func removeFromGUsers(ctx context.Context, username string) error { googleUsersMu.Lock() defer googleUsersMu.Unlock() // If the file does not exists, we don't have to do anything - there's no // entry to be removed. if !file.Exists(googleUsersFile, file.TypeFile) { galog.V(2).Debugf("Google users file %s does not exist, skipping.", googleUsersFile) return nil } gusersFile, err := os.ReadFile(googleUsersFile) if err != nil { return fmt.Errorf("could not read google user's file %w", err) } lines := strings.Split(string(gusersFile), "\n") for i, line := range lines { if line != username { continue } // Write the file with all lines before the user's line and all lines after // the user's line. lines = append(lines[:i], lines[i+1:]...) data := []byte(strings.Join(lines, "\n")) if err := file.SaferWriteFile(ctx, data, googleUsersFile, file.Options{Perm: 0600}); err != nil { return fmt.Errorf("failed writing google users file %s: %w", googleUsersFile, err) } return nil } // No need to write anything if user was not found. return nil } // ListGoogleUsers lists users created by the guest agent. See // googleUsersFile comment for details. Returns wrapped os errors on failure. func ListGoogleUsers(ctx context.Context) ([]string, error) { googleUsersMu.RLock() defer googleUsersMu.RUnlock() gusersFile, err := os.ReadFile(googleUsersFile) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, fmt.Errorf("could not read google_users file %w", err) } // Make sure we are filtering out empty lines. gusersList := strings.Split(string(gusersFile), "\n") out := make([]string, 0, len(gusersList)) for _, elem := range gusersList { if elem != "" { out = append(out, elem) } } return out, nil } // CreateGroup creates a group with the given group name. Returns the wrapped // run error if the command failed. func CreateGroup(ctx context.Context, groupName string) error { cmd := cfg.Retrieve().Accounts.GroupAddCmd if _, err := runCommandTemplate(ctx, cmd, nil, &Group{Name: groupName}); err != nil { return fmt.Errorf("failed run group add command %s: %w", cmd, err) } return nil } // AddUserToGroup adds the user to the named group. Returns the wrapped // run error if the command failed. func AddUserToGroup(ctx context.Context, u *User, g *Group) error { cmd := cfg.Retrieve().Accounts.GPasswdAddCmd if _, err := runCommandTemplate(ctx, cmd, u, g); err != nil { return fmt.Errorf("failed to run password add command %s: %w", cmd, err) } return nil } // RemoveUserFromGroup removes the user from the named group. Returns the run // error if the command failed. func RemoveUserFromGroup(ctx context.Context, u *User, g *Group) error { cmd := cfg.Retrieve().Accounts.GPasswdRemoveCmd if _, err := runCommandTemplate(ctx, cmd, u, g); err != nil { return fmt.Errorf("failed to run gpasswd_remove_cmd %s: %w", cmd, err) } return nil } // DelUser removes the user from the OS. Returns the wrapped // run error if the command failed. func DelUser(ctx context.Context, u *User) error { cmd := cfg.Retrieve().Accounts.UserDelCmd if _, err := runCommandTemplate(ctx, cmd, u, nil); err != nil { return fmt.Errorf("failed to run userdel_cmd %s: %w", cmd, err) } if err := removeFromGUsers(ctx, u.Username); err != nil { return err } return nil } // FindGroup gets the information of the group, returning ErrGroupNotExist if // the group does not exist on the system. Returns the wrappe run error if the // command failed. // // Any group returned by this function is guaranteed to have a valid GID - a // call to ValidateUnixGID() will never return an error. func FindGroup(ctx context.Context, groupName string) (*Group, error) { getent, err := run.WithContext(ctx, run.Options{ OutputType: run.OutputStdout, ExecMode: run.ExecModeSync, Name: "getent", Args: []string{"group", groupName}, }) if err != nil { // No such key exit code is returned when the user does not exist. if err, ok := run.AsExitError(err); ok && err.ExitCode() == getentNoSuchKey { return nil, user.UnknownGroupError(groupName) } return nil, fmt.Errorf("could not get group: %w", err) } // The result of getent will contain a single entry (given we are querying a // single group). groupEntry, err := parseGroupEntry(getent.Output, groupName) if err != nil { return nil, fmt.Errorf("could not parse group %s: %w", groupName, err) } return groupEntry, nil } // parseGroupEntry parses /etc/group style input for the named group. func parseGroupEntry(line string, groupName string) (*Group, error) { line = strings.TrimSpace(strings.TrimSuffix(line, "\n")) prefix := groupName + ":" // Validate the correctness of the entry format, it should contain the group // name followed by a colon as a prefix (i.e. "staff:"). if !strings.HasPrefix(line, prefix) { return nil, fmt.Errorf("invalid group entry for %q, expected prefix %q", groupName, prefix) } // staff:!:1:shadow,cjf parts := strings.SplitN(string(line), ":", 4) if len(parts) < 4 { return nil, fmt.Errorf("invalid passwd entry for %s", groupName) } var members []string for _, m := range strings.Split(parts[3], ",") { if strings.TrimSpace(m) != "" { members = append(members, m) } } res := &Group{ Name: parts[0], GID: parts[2], Members: members, } if err := res.ValidateUnixGID(); err != nil { return nil, err } return res, nil } // run a templated command in the style of cfg.Accounts config options. see // replaceTemplate and cfg for options. func runCommandTemplate(ctx context.Context, cmd string, u *User, g *Group) (*run.Result, error) { var input string before, after, found := strings.Cut(cmd, "|") if found { input = execCommandTemplate(before, u, g) cmd = after } cmd = execCommandTemplate(cmd, u, g) tokens := strings.Fields(cmd) if len(tokens) < 1 { return nil, errors.New("no command configured") } cmdopts := run.Options{ OutputType: run.OutputCombined, ExecMode: run.ExecModeSync, Name: tokens[0], Args: tokens[1:], Input: input, } return run.WithContext(ctx, cmdopts) } // execCommandTemplate replaces {user} and {group} in the given string with the // given user and group. func execCommandTemplate(in string, u *User, g *Group) string { out := in if u != nil { out = strings.Replace(out, "{user}", u.Username, 1) } if g != nil { out = strings.Replace(out, "{group}", g.Name, 1) } return out }