google_guest_agent/oslogin.go (405 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 (
"context"
"fmt"
"os"
"os/exec"
"runtime"
"slices"
"strings"
"time"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/cfg"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/events"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/events/sshtrustedca"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/run"
"github.com/GoogleCloudPlatform/guest-agent/google_guest_agent/sshca"
"github.com/GoogleCloudPlatform/guest-agent/metadata"
"github.com/GoogleCloudPlatform/guest-logging-go/logger"
)
var (
googleComment = "# Added by Google Compute Engine OS Login."
googleBlockStart = "#### Google OS Login control. Do not edit this section. ####"
googleBlockEnd = "#### End Google OS Login control section. ####"
trustedCAWatcher events.Watcher
// deprecatedConfigDirectives contains a list of configuration directives (or lines)
// that we no longer support and should not be considered for updated versions of a
// given configuration file.
deprecatedConfigDirectives = map[string][]string{
"/etc/pam.d/su": {"account [success=bad ignore=ignore] pam_oslogin_login.so"},
}
)
type osloginMgr struct{}
// We also read project keys first, letting instance-level keys take
// precedence.
func getOSLoginEnabled(md *metadata.Descriptor) (bool, bool, bool, bool) {
var enable bool
if md.Project.Attributes.EnableOSLogin != nil {
enable = *md.Project.Attributes.EnableOSLogin
}
if md.Instance.Attributes.EnableOSLogin != nil {
enable = *md.Instance.Attributes.EnableOSLogin
}
var twofactor bool
if md.Project.Attributes.TwoFactor != nil {
twofactor = *md.Project.Attributes.TwoFactor
}
if md.Instance.Attributes.TwoFactor != nil {
twofactor = *md.Instance.Attributes.TwoFactor
}
var skey bool
if md.Project.Attributes.SecurityKey != nil {
skey = *md.Project.Attributes.SecurityKey
}
if md.Instance.Attributes.SecurityKey != nil {
skey = *md.Instance.Attributes.SecurityKey
}
var reqCerts bool
if md.Project.Attributes.RequireCerts != nil {
reqCerts = *md.Project.Attributes.RequireCerts
}
if md.Instance.Attributes.RequireCerts != nil {
reqCerts = *md.Instance.Attributes.RequireCerts
}
return enable, twofactor, skey, reqCerts
}
func enableDisableOSLoginCertAuth(ctx context.Context) error {
if newMetadata == nil {
logger.Infof("Could not enable/disable OSLogin Cert Auth, metadata is not initialized.")
return nil
}
eventManager := events.Get()
osLoginEnabled, _, _, _ := getOSLoginEnabled(newMetadata)
if osLoginEnabled {
if trustedCAWatcher == nil {
trustedCAWatcher = sshtrustedca.New(sshtrustedca.DefaultPipePath)
if err := eventManager.AddWatcher(ctx, trustedCAWatcher); err != nil {
return err
}
sshca.Init()
}
}
return nil
}
func (o *osloginMgr) Diff(ctx context.Context) (bool, error) {
oldEnable, oldTwoFactor, oldSkey, oldReqCerts := getOSLoginEnabled(oldMetadata)
enable, twofactor, skey, reqCerts := getOSLoginEnabled(newMetadata)
return oldMetadata.Project.ProjectID == "" ||
// True on first run or if any value has changed.
(oldTwoFactor != twofactor) ||
(oldEnable != enable) ||
(oldSkey != skey) ||
(oldReqCerts != reqCerts), nil
}
func (o *osloginMgr) Timeout(ctx context.Context) (bool, error) {
return false, nil
}
func (o *osloginMgr) Disabled(ctx context.Context) (bool, error) {
return runtime.GOOS == "windows", nil
}
func (o *osloginMgr) Set(ctx context.Context) error {
// We need to know if it was previously enabled for the clearing of
// metadata-based SSH keys.
oldEnable, _, _, _ := getOSLoginEnabled(oldMetadata)
enable, twofactor, skey, reqCerts := getOSLoginEnabled(newMetadata)
cleanupDeprecatedDirectives()
if enable && !oldEnable {
logger.Infof("Enabling OS Login")
newMetadata.Instance.Attributes.SSHKeys = nil
newMetadata.Project.Attributes.SSHKeys = nil
(&accountsMgr{}).Set(ctx)
}
if !enable && oldEnable {
logger.Infof("Disabling OS Login")
}
if err := writeSSHConfig(enable, twofactor, skey, reqCerts); err != nil {
logger.Errorf("Error updating SSH config: %v.", err)
}
if err := writeNSSwitchConfig(enable); err != nil {
logger.Errorf("Error updating NSS config: %v.", err)
}
if err := writePAMConfig(enable, twofactor); err != nil {
logger.Errorf("Error updating PAM config: %v.", err)
}
if err := writeGroupConf(enable); err != nil {
logger.Errorf("Error updating group.conf: %v.", err)
}
for _, svc := range []string{"nscd", "unscd", "systemd-logind", "cron", "crond"} {
// These services should be restarted if running
logger.Debugf("systemctl try-restart %s, if it exists", svc)
if err := systemctlTryRestart(ctx, svc); err != nil {
logger.Errorf("Error restarting service: %v.", err)
}
}
// SSH should be started if not running, reloaded otherwise.
for _, svc := range []string{"ssh", "sshd"} {
logger.Debugf("systemctl reload-or-restart %s, if it exists", svc)
if err := systemctlReloadOrRestart(ctx, svc); err != nil {
logger.Errorf("Error reloading service: %v.", err)
}
}
now := fmt.Sprintf("%d", time.Now().Unix())
mdsClient.WriteGuestAttributes(ctx, "guest-agent/sshable", now)
if enable {
logger.Debugf("Create OS Login dirs, if needed")
if err := createOSLoginDirs(ctx); err != nil {
logger.Errorf("Error creating OS Login directory: %v.", err)
}
logger.Debugf("create OS Login sudoers config, if needed")
if err := createOSLoginSudoersFile(); err != nil {
logger.Errorf("Error creating OS Login sudoers file: %v.", err)
}
logger.Debugf("starting OS Login nss cache fill")
if err := run.Quiet(ctx, "google_oslogin_nss_cache"); err != nil {
logger.Errorf("Error updating NSS cache: %v.", err)
}
}
return nil
}
func cleanupDeprecatedLines(fpath string, directives []string) error {
// If the file doesn't exist don't even try updating it.
stat, err := os.Stat(fpath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to stat config file: %+v", err)
}
data, err := os.ReadFile(fpath)
if err != nil {
return fmt.Errorf("failed to read file: %+v", err)
}
var updatedLines []string
var totalLines int
for _, line := range strings.Split(string(data), "\n") {
if !slices.Contains(directives, line) {
updatedLines = append(updatedLines, line)
}
totalLines++
}
// Don't attempt to update the config file if no lines werer removed/avoided.
if totalLines == len(updatedLines) {
return nil
}
err = os.WriteFile(fpath, []byte(strings.Join(updatedLines, "\n")), stat.Mode())
if err != nil {
return fmt.Errorf("failed to update deprecated configuration directives: %+v", err)
}
return nil
}
// cleanupDeprecatedDirectives checks if a given configuration line is an old
// configuration that was deprecated and we should not consider it for the updated
// version.
func cleanupDeprecatedDirectives() {
for k, v := range deprecatedConfigDirectives {
if err := cleanupDeprecatedLines(k, v); err != nil {
logger.Errorf("failed to clean up deprecated directives: %+v", err)
}
}
}
func filterGoogleLines(contents string) []string {
var isgoogle, isgoogleblock bool
var filtered []string
for _, line := range strings.Split(contents, "\n") {
switch {
case strings.Contains(line, googleComment) && !isgoogleblock:
isgoogle = true
case strings.Contains(line, googleBlockEnd):
isgoogleblock = false
isgoogle = false
case isgoogleblock, strings.Contains(line, googleBlockStart):
isgoogleblock = true
case isgoogle:
isgoogle = false
default:
filtered = append(filtered, line)
}
}
return filtered
}
func writeConfigFile(path, contents string) error {
logger.Debugf("writing %s", path)
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0777)
if err != nil {
return err
}
defer closeFile(file)
file.WriteString(contents)
return nil
}
func updateSSHConfig(sshConfig string, enable, twofactor, skey, reqCerts bool) string {
// TODO: this feels like a case for a text/template
challengeResponseEnable := "ChallengeResponseAuthentication yes"
authorizedKeysCommand := "AuthorizedKeysCommand /usr/bin/google_authorized_keys"
if skey {
authorizedKeysCommand = "AuthorizedKeysCommand /usr/bin/google_authorized_keys_sk"
}
if runtime.GOOS == "freebsd" {
authorizedKeysCommand = "AuthorizedKeysCommand /usr/local/bin/google_authorized_keys"
if skey {
authorizedKeysCommand = "AuthorizedKeysCommand /usr/local/bin/google_authorized_keys_sk"
}
}
authorizedKeysUser := "AuthorizedKeysCommandUser root"
// Certificate based authentication.
authorizedPrincipalsCommand := "AuthorizedPrincipalsCommand /usr/bin/google_authorized_principals %u %k"
authorizedPrincipalsUser := "AuthorizedPrincipalsCommandUser root"
trustedUserCAKeys := "TrustedUserCAKeys " + sshtrustedca.DefaultPipePath
twoFactorAuthMethods := "AuthenticationMethods publickey,keyboard-interactive"
if (osInfo.OS == "rhel" || osInfo.OS == "centos") && osInfo.Version.Major == 6 {
authorizedKeysUser = "AuthorizedKeysCommandRunAs root"
twoFactorAuthMethods = "RequiredAuthentications2 publickey,keyboard-interactive"
}
matchblock1 := `Match User sa_*`
matchblock2 := ` AuthenticationMethods publickey`
filtered := filterGoogleLines(string(sshConfig))
if enable {
osLoginBlock := []string{googleBlockStart}
// Metadata overrides the config file.
if reqCerts {
osLoginBlock = append(osLoginBlock, trustedUserCAKeys, authorizedPrincipalsCommand, authorizedPrincipalsUser)
} else {
if cfg.Get().OSLogin.CertAuthentication {
osLoginBlock = append(osLoginBlock, trustedUserCAKeys, authorizedPrincipalsCommand, authorizedPrincipalsUser)
}
osLoginBlock = append(osLoginBlock, authorizedKeysCommand, authorizedKeysUser)
}
if twofactor {
osLoginBlock = append(osLoginBlock, twoFactorAuthMethods, challengeResponseEnable)
}
osLoginBlock = append(osLoginBlock, googleBlockEnd)
filtered = append(osLoginBlock, filtered...)
if twofactor {
filtered = append(filtered, googleBlockStart, matchblock1, matchblock2, googleBlockEnd)
}
}
return strings.Join(filtered, "\n")
}
func writeSSHConfig(enable, twofactor, skey, reqCerts bool) error {
sshConfig, err := os.ReadFile("/etc/ssh/sshd_config")
if err != nil {
return err
}
proposed := updateSSHConfig(string(sshConfig), enable, twofactor, skey, reqCerts)
if proposed == string(sshConfig) {
return nil
}
return writeConfigFile("/etc/ssh/sshd_config", proposed)
}
func updateNSSwitchConfig(nsswitch string, enable bool) string {
oslogin := " cache_oslogin oslogin"
var filtered []string
for _, line := range strings.Split(string(nsswitch), "\n") {
if strings.HasPrefix(line, "passwd:") || strings.HasPrefix(line, "group:") {
present := strings.Contains(line, "oslogin")
if enable && !present {
line += oslogin
} else if !enable && present {
line = strings.Replace(line, oslogin, "", 1)
}
if runtime.GOOS == "freebsd" {
line = strings.Replace(line, "compat", "files", 1)
}
}
filtered = append(filtered, line)
}
return strings.Join(filtered, "\n")
}
func writeNSSwitchConfig(enable bool) error {
nsswitch, err := os.ReadFile("/etc/nsswitch.conf")
if err != nil {
return err
}
proposed := updateNSSwitchConfig(string(nsswitch), enable)
if proposed == string(nsswitch) {
return nil
}
return writeConfigFile("/etc/nsswitch.conf", proposed)
}
func updatePAMsshdPamless(pamsshd string, enable, twofactor bool) string {
authOSLogin := "auth [success=done perm_denied=die default=ignore] pam_oslogin_login.so"
authGroup := "auth [default=ignore] pam_group.so"
sessionHomeDir := "session [success=ok default=ignore] pam_mkhomedir.so"
if runtime.GOOS == "freebsd" {
authOSLogin = "auth optional pam_oslogin_login.so"
authGroup = "auth optional pam_group.so"
sessionHomeDir = "session optional pam_mkhomedir.so"
}
filtered := filterGoogleLines(string(pamsshd))
if enable {
topOfFile := []string{googleBlockStart}
if twofactor {
topOfFile = append(topOfFile, authOSLogin)
}
topOfFile = append(topOfFile, authGroup, googleBlockEnd)
bottomOfFile := []string{googleBlockStart, sessionHomeDir, googleBlockEnd}
filtered = append(topOfFile, filtered...)
filtered = append(filtered, bottomOfFile...)
}
return strings.Join(filtered, "\n")
}
func writePAMConfig(enable, twofactor bool) error {
pamsshd, err := os.ReadFile("/etc/pam.d/sshd")
if err != nil {
return err
}
proposed := updatePAMsshdPamless(string(pamsshd), enable, twofactor)
if proposed != string(pamsshd) {
if err := writeConfigFile("/etc/pam.d/sshd", proposed); err != nil {
return err
}
}
return nil
}
func updateGroupConf(groupconf string, enable bool) string {
config := "sshd;*;*;Al0000-2400;video"
filtered := filterGoogleLines(groupconf)
if enable {
filtered = append(filtered, []string{googleComment, config}...)
}
return strings.Join(filtered, "\n")
}
func writeGroupConf(enable bool) error {
groupconf, err := os.ReadFile("/etc/security/group.conf")
if err != nil {
return err
}
proposed := updateGroupConf(string(groupconf), enable)
if proposed != string(groupconf) {
if err := writeConfigFile("/etc/security/group.conf", proposed); err != nil {
return err
}
}
return nil
}
// Creates necessary OS Login directories if they don't exist.
func createOSLoginDirs(ctx context.Context) error {
restorecon, restoreconerr := exec.LookPath("restorecon")
for _, dir := range []string{"/var/google-sudoers.d", "/var/google-users.d"} {
err := os.Mkdir(dir, 0750)
if err != nil && !os.IsExist(err) {
return err
}
if restoreconerr == nil {
run.Quiet(ctx, restorecon, dir)
}
}
return nil
}
func createOSLoginSudoersFile() error {
osloginSudoers := "/etc/sudoers.d/google-oslogin"
if runtime.GOOS == "freebsd" {
osloginSudoers = "/usr/local" + osloginSudoers
}
sudoFile, err := os.OpenFile(osloginSudoers, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0440)
if err != nil {
if os.IsExist(err) {
return nil
}
return err
}
fmt.Fprintf(sudoFile, "#includedir /var/google-sudoers.d\n")
return sudoFile.Close()
}
// systemctlTryRestart tries to restart a systemd service if it is already
// running. Stopped services will be ignored.
func systemctlTryRestart(ctx context.Context, servicename string) error {
if !systemctlUnitExists(ctx, servicename) {
return nil
}
return run.Quiet(ctx, "systemctl", "try-restart", servicename+".service")
}
// systemctlReloadOrRestart tries to reload a running systemd service if
// supported, restart otherwise. Stopped services will be started.
func systemctlReloadOrRestart(ctx context.Context, servicename string) error {
if !systemctlUnitExists(ctx, servicename) {
return nil
}
return run.Quiet(ctx, "systemctl", "reload-or-restart", servicename+".service")
}
// systemctlStart tries to start a stopped systemd service. Started services
// will be ignored.
func systemctlStart(ctx context.Context, servicename string) error {
if !systemctlUnitExists(ctx, servicename) {
return nil
}
return run.Quiet(ctx, "systemctl", "start", servicename+".service")
}
func systemctlUnitExists(ctx context.Context, servicename string) bool {
res := run.WithOutput(ctx, "systemctl", "list-units", "--all", servicename+".service")
return !strings.Contains(res.StdOut, "0 loaded units listed")
}