google_guest_agent/non_windows_accounts.go (389 lines of code) (raw):
// Copyright 2019 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.
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/cfg"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/run"
"github.com/GoogleCloudPlatform/guest-agent/utils"
"github.com/GoogleCloudPlatform/guest-logging-go/logger"
)
var (
// sshKeys is a cache of what we have added to each managed users' authorized
// keys file. Avoids necessity of re-reading all files on every change.
sshKeys map[string][]string
googleUsersFile = "/var/lib/google/google_users"
)
// compareStringSlice returns true if two string slices are equal, false
// otherwise. Does not modify the slices.
func compareStringSlice(first, second []string) bool {
if len(first) != len(second) {
return false
}
for _, list := range [][]string{first, second} {
sortfunc := func(i, j int) bool { return list[i] < list[j] }
list = append([]string{}, list...)
sort.Slice(list, sortfunc)
}
for idx := range first {
if first[idx] != second[idx] {
return false
}
}
return true
}
func removeExpiredKeys(keys []string) []string {
var validKeys []string
for _, key := range keys {
if err := utils.CheckExpiredKey(key); err == nil {
validKeys = append(validKeys, key)
}
}
return validKeys
}
type accountsMgr struct{}
func (a *accountsMgr) Diff(ctx context.Context) (bool, error) {
// If any keys have changed.
if !compareStringSlice(newMetadata.Instance.Attributes.SSHKeys, oldMetadata.Instance.Attributes.SSHKeys) {
return true, nil
}
if !compareStringSlice(newMetadata.Project.Attributes.SSHKeys, oldMetadata.Project.Attributes.SSHKeys) {
return true, nil
}
if newMetadata.Instance.Attributes.BlockProjectKeys != oldMetadata.Instance.Attributes.BlockProjectKeys {
return true, nil
}
// If any on-disk keys have expired.
for _, keys := range sshKeys {
if len(keys) != len(removeExpiredKeys(keys)) {
return true, nil
}
}
// If we've just disabled OS Login.
oldOslogin, _, _, _ := getOSLoginEnabled(oldMetadata)
newOslogin, _, _, _ := getOSLoginEnabled(newMetadata)
if oldOslogin && !newOslogin {
return true, nil
}
return false, nil
}
func (a *accountsMgr) Timeout(ctx context.Context) (bool, error) {
return false, nil
}
func (a *accountsMgr) Disabled(ctx context.Context) (bool, error) {
config := cfg.Get()
oslogin, _, _, _ := getOSLoginEnabled(newMetadata)
return false || runtime.GOOS == "windows" || oslogin || !config.Daemons.AccountsDaemon, nil
}
func (a *accountsMgr) Set(ctx context.Context) error {
config := cfg.Get()
if sshKeys == nil {
logger.Debugf("initialize sshKeys map")
sshKeys = make(map[string][]string)
}
logger.Debugf("create sudoers file if needed")
if err := createSudoersFile(); err != nil {
logger.Errorf("Error creating google-sudoers file: %v.", err)
}
logger.Debugf("create sudoers group if needed")
if err := createSudoersGroup(ctx, config); err != nil {
logger.Errorf("Error creating google-sudoers group: %v.", err)
}
mdkeys := newMetadata.Instance.Attributes.SSHKeys
if !newMetadata.Instance.Attributes.BlockProjectKeys {
mdkeys = append(mdkeys, newMetadata.Project.Attributes.SSHKeys...)
}
mdKeyMap := getUserKeys(mdkeys)
logger.Debugf("read google users file")
gUsers, err := readGoogleUsersFile()
if err != nil {
// TODO: is this OK to continue past?
logger.Errorf("Couldn't read google_users file: %v.", err)
}
// Update SSH keys, creating Google users as needed.
for user, userKeys := range mdKeyMap {
if _, err := getPasswd(user); err != nil {
logger.Infof("Creating user %s.", user)
if err := createGoogleUser(ctx, config, user); err != nil {
logger.Errorf("Error creating user: %s.", err)
continue
}
gUsers[user] = ""
}
if _, ok := gUsers[user]; !ok {
logger.Infof("Adding existing user %s to google-sudoers group.", user)
if err := addUserToGroup(ctx, user, "google-sudoers"); err != nil {
logger.Errorf("%v.", err)
}
}
if !compareStringSlice(userKeys, sshKeys[user]) {
logger.Infof("Updating keys for user %s.", user)
if err := updateAuthorizedKeysFile(ctx, user, userKeys); err != nil {
logger.Errorf("Error updating SSH keys for %s: %v.", user, err)
continue
}
sshKeys[user] = userKeys
}
}
// Remove Google users not found in metadata.
for user := range gUsers {
if _, ok := mdKeyMap[user]; !ok && user != "" {
logger.Infof("Removing user %s.", user)
err = removeGoogleUser(ctx, config, user)
if err != nil {
logger.Errorf("Error removing user: %v.", err)
}
delete(sshKeys, user)
}
}
// Update the google_users file if we've added or removed any users.
logger.Debugf("write google_users file")
if err := writeGoogleUsersFile(); err != nil {
logger.Errorf("Error writing google_users file: %v.", err)
}
// Start SSHD if not started. We do this in agent instead of adding a
// Wants= directive, and here instead of instance setup, so that this
// can be disabled by the instance configs file.
for _, svc := range []string{"ssh", "sshd"} {
// Ignore output, it's just a best effort.
systemctlStart(ctx, svc)
}
return nil
}
var badSSHKeys []string
// getUserKeys returns the keys which are not expired and non-expiring key.
// valid formats are:
// user:ssh-rsa [KEY_VALUE] [USERNAME]
// user:ssh-rsa [KEY_VALUE]
// user:ssh-rsa [KEY_VALUE] google-ssh {"userName":"[USERNAME]","expireOn":"[EXPIRE_TIME]"}
// user:[KEY_OPTIONS] ssh-rsa [KEY_VALUE]
func getUserKeys(mdkeys []string) map[string][]string {
mdKeyMap := make(map[string][]string)
for i := 0; i < len(mdkeys); i++ {
trimmedKey := strings.Trim(mdkeys[i], " ")
if trimmedKey != "" {
user, keyVal, err := utils.GetUserKey(trimmedKey)
if err == nil {
err = utils.ValidateUserKey(user, keyVal)
}
if err != nil {
if !slices.Contains(badSSHKeys, trimmedKey) {
logger.Errorf("%s: %s", err.Error(), trimmedKey)
badSSHKeys = append(badSSHKeys, trimmedKey)
}
continue
}
// key which is not expired or non-expiring key, add it.
userKeys := mdKeyMap[user]
userKeys = append(userKeys, keyVal)
mdKeyMap[user] = userKeys
}
}
return mdKeyMap
}
// passwdEntry is a user.User with omitted passwd fields restored.
type passwdEntry struct {
Username string
Passwd string
UID int
GID int
Name string
HomeDir string
Shell string
}
// getPasswd returns a passwdEntry from the local passwd database. Code adapted from os/user
func getPasswd(user string) (*passwdEntry, error) {
prefix := []byte(user + ":")
colon := []byte{':'}
parse := func(line []byte) (*passwdEntry, error) {
if !bytes.HasPrefix(line, prefix) || bytes.Count(line, colon) < 6 {
return nil, nil
}
// 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", user)
}
uid, err := strconv.Atoi(parts[2])
if err != nil {
return nil, fmt.Errorf("invalid passwd entry for %s", user)
}
gid, err := strconv.Atoi(parts[3])
if err != nil {
return nil, fmt.Errorf("invalid passwd entry for %s", user)
}
u := &passwdEntry{
Username: parts[0],
Passwd: parts[1],
UID: uid,
GID: gid,
Name: parts[4],
HomeDir: parts[5],
Shell: parts[6],
}
return u, nil
}
passwd, err := os.Open("/etc/passwd")
if err != nil {
return nil, err
}
bs := bufio.NewScanner(passwd)
for bs.Scan() {
line := bs.Bytes()
// There's no spec for /etc/passwd or /etc/group, but we try to follow
// the same rules as the glibc parser, which allows comments and blank
// space at the beginning of a line.
line = bytes.TrimSpace(line)
if len(line) == 0 || line[0] == '#' {
continue
}
v, err := parse(line)
if v != nil || err != nil {
return v, err
}
}
return nil, fmt.Errorf("user not found")
}
func writeGoogleUsersFile() error {
dir := path.Dir(googleUsersFile)
if _, err := os.Stat(dir); err != nil {
if err = os.Mkdir(dir, 0755); err != nil {
return err
}
}
gfile, err := os.OpenFile(googleUsersFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err == nil {
defer gfile.Close()
for user := range sshKeys {
fmt.Fprintf(gfile, "%s\n", user)
}
}
return err
}
func readGoogleUsersFile() (map[string]string, error) {
res := make(map[string]string)
gUsers, err := os.ReadFile(googleUsersFile)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
for _, user := range strings.Split(string(gUsers), "\n") {
if user != "" {
res[user] = ""
}
}
return res, nil
}
// Replaces {user} or {group} in command string. Supports legacy python-era
// user command overrides.
func createUserGroupCmd(cmd, user, group string) (string, []string) {
cmd = strings.Replace(cmd, "{user}", user, 1)
cmd = strings.Replace(cmd, "{group}", group, 1)
// We don't run the command here because we might need the exit codes.
tokens := strings.Fields(cmd)
return tokens[0], tokens[1:]
}
// createGoogleUser creates a Google managed user account if needed and adds it
// to the configured groups.
func createGoogleUser(ctx context.Context, config *cfg.Sections, user string) error {
var uid, gid string
if config.Accounts.ReuseHomedir {
uid, gid = getUIDAndGID(fmt.Sprintf("/home/%s", user))
}
if err := createUser(ctx, user, uid, gid); err != nil {
return err
}
groups := config.Accounts.Groups
for _, group := range strings.Split(groups, ",") {
addUserToGroup(ctx, user, group)
}
return addUserToGroup(ctx, user, "google-sudoers")
}
// removeGoogleUser removes Google managed users. If deprovision_remove is true, the
// user and its home directory are removed. Otherwise, SSH keys and sudoer
// permissions are removed but the user remains on the system. Group membership
// is not changed.
func removeGoogleUser(ctx context.Context, config *cfg.Sections, user string) error {
if config.Accounts.DeprovisionRemove {
userdel := config.Accounts.UserDelCmd
name, args := createUserGroupCmd(userdel, user, "")
return run.Quiet(ctx, name, args...)
}
if err := updateAuthorizedKeysFile(ctx, user, []string{}); err != nil {
return err
}
gpasswddel := config.Accounts.GPasswdRemoveCmd
name, args := createUserGroupCmd(gpasswddel, user, "google-sudoers")
return run.Quiet(ctx, name, args...)
}
// createSudoersFile creates the google_sudoers configuration file if it does
// not exist and specifies the group 'google-sudoers' should have all
// permissions.
func createSudoersFile() error {
sudoFile, err := os.OpenFile("/etc/sudoers.d/google_sudoers", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0440)
if err != nil {
if os.IsExist(err) {
return nil
}
return err
}
defer sudoFile.Close()
fmt.Fprintf(sudoFile, "%%google-sudoers ALL=(ALL:ALL) NOPASSWD:ALL\n")
return nil
}
// createSudoersGroup creates the google-sudoers group if it does not exist.
func createSudoersGroup(ctx context.Context, config *cfg.Sections) error {
groupadd := config.Accounts.GroupAddCmd
name, args := createUserGroupCmd(groupadd, "", "google-sudoers")
ret := run.WithOutput(ctx, name, args...)
if ret.ExitCode == 9 {
// 9 means group already exists.
return nil
}
if ret.ExitCode != 0 {
return error(ret)
}
logger.Infof("Created google sudoers file")
return nil
}
// updateAuthorizedKeysFile adds provided keys to the user's SSH
// AuthorizedKeys file. The file and containing directory are created if it
// does not exist. Uses a temporary file to avoid partial updates in case of
// errors. If no keys are provided, the authorized keys file is removed.
func updateAuthorizedKeysFile(ctx context.Context, user string, keys []string) error {
gcomment := "# Added by Google"
passwd, err := getPasswd(user)
if err != nil {
return err
}
if passwd.HomeDir == "" {
return fmt.Errorf("user %s has no homedir set", user)
}
if passwd.Shell == "/sbin/nologin" {
return nil
}
sshpath := path.Join(passwd.HomeDir, ".ssh")
if _, err := os.Stat(sshpath); err != nil {
if os.IsNotExist(err) {
if err = os.Mkdir(sshpath, 0700); err != nil {
return err
}
if err = os.Chown(sshpath, passwd.UID, passwd.GID); err != nil {
return err
}
} else {
return err
}
}
akpath := path.Join(sshpath, "authorized_keys")
// Remove empty file.
if len(keys) == 0 {
os.Remove(akpath)
return nil
}
tempPath := akpath + ".google"
akcontents, err := os.ReadFile(akpath)
if err != nil && !os.IsNotExist(err) {
return err
}
var isgoogle bool
var userKeys []string
for _, key := range strings.Split(string(akcontents), "\n") {
if key == "" {
continue
}
if isgoogle {
isgoogle = false
continue
}
if key == gcomment {
isgoogle = true
continue
}
userKeys = append(userKeys, key)
}
newfile, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return err
}
defer newfile.Close()
for _, key := range userKeys {
fmt.Fprintf(newfile, "%s\n", key)
}
for _, key := range keys {
fmt.Fprintf(newfile, "%s\n%s\n", gcomment, key)
}
err = os.Chown(tempPath, passwd.UID, passwd.GID)
if err != nil {
// Existence of temp file will block further updates for this user.
// Don't catch remove error, nothing we can do. Return the
// chown error which caused the issue.
os.Remove(tempPath)
return fmt.Errorf("error setting ownership of new keys file: %v", err)
}
_, err = exec.LookPath("restorecon")
if err == nil {
if err := run.Quiet(ctx, "restorecon", tempPath); err != nil {
return fmt.Errorf("error setting selinux context: %+v", err)
}
}
return os.Rename(tempPath, akpath)
}