cmd/core_plugin/oslogin/oslogin_linux.go (434 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distrbuted 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 linux
// Package oslogin contains the Linux implementation of the OS Login module.
package oslogin
import (
"context"
"fmt"
"io/fs"
"os"
"os/exec"
"strings"
"sync/atomic"
"github.com/GoogleCloudPlatform/galog"
"github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/events"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/pipewatcher"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/run"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/textconfig"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file"
)
const (
// osloginModuleID is the ID of the OS Login module.
osloginModuleID = "oslogin"
// defaultPipePath is the default path to the ssh trusted ca pipe.
defaultPipePath = "/etc/ssh/oslogin_trustedca.pub"
// defaultPipeMode is the default mode for the ssh trusted ca pipe, it aligns
// with the distribution's default mode for /etc/ssh/.
defaultPipeMode = 0755
// sshcaEventWatcherID is the ID of the ssh trusted ca pipe event watcher.
sshcaEventWatcherID = "oslogin-sshca-pipe-event-watcher"
// sshcaPipeWatcherReadEventID is the ID of the ssh trusted ca pipe event
// watcher read event.
sshcaPipeWatcherReadEventID = "oslogin-sshca-pipe-event-watcher,read"
// defaultSSHDConfigPath is the default path to the openssh daemon
// configuration file.
defaultSSHDConfigPath = "/etc/ssh/sshd_config"
// defaultNSSwitchConfigPath is the default path to the NSSwitch configuration
// file.
defaultNSSwitchConfigPath = "/etc/nsswitch.conf"
// defaultPAMConfigPath is the default path to the PAM configuration file.
defaultPAMConfigPath = "/etc/pam.d/sshd"
// defaultGroupConfigPath is the default path to the group configuration file.
defaultGroupConfigPath = "/etc/security/group.conf"
// defaultSudoersPath is the default path to the sudoers file.
defaultSudoersPath = "/etc/sudoers.d/google-sudoers"
)
var (
// sshcaPipeWatcherOpts are the ssh trusted ca pipe event watcher options.
sshcaPipeWatcherOpts = pipewatcher.Options{
PipePath: defaultPipePath,
Mode: defaultPipeMode,
ReadEventID: sshcaPipeWatcherReadEventID,
}
// osloginConfigMode is the mode for all OSLogin configuration files.
osloginConfigMode = fs.FileMode(0644)
// defaultAuthorizedKeysCommandPaths are the possible paths to the authorized
// keyscommand binaries.
defaultAuthorizedKeysCommandPaths = []string{
"/usr/bin/google_authorized_keys",
"/usr/local/bin/google_authorized_keys",
}
// defaultAuthorizedKeysCommandSKPaths are the possible paths to the
// authorized keys command binaries (in the security key case/variation).
defaultAuthorizedKeysCommandSKPaths = []string{
"/usr/bin/google_authorized_keys_sk",
"/usr/local/bin/google_authorized_keys_sk",
}
// defaultServices are the services to restart after configuration changes.
// Each sub-array of the map indicates that only one of those services need to
// be successfully restarted.
defaultServices = map[daemon.RestartMethod][]serviceRestartConfig{
daemon.ReloadOrRestart: []serviceRestartConfig{
{
protocol: serviceRestartAtLeastOne,
services: []string{"ssh", "sshd"},
},
},
daemon.TryRestart: []serviceRestartConfig{
{
protocol: serviceRestartOptional,
services: []string{"nscd", "unscd"},
},
{
protocol: serviceRestartAtLeastOne,
services: []string{"systemd-logind"},
},
{
protocol: serviceRestartAtLeastOne,
services: []string{"cron", "crond"},
},
},
}
// defaultOSLoginDirs are the directories to create for OSLogin, if necessary.
defaultOSLoginDirs = []string{
"/var/google-sudoers.d",
"/var/google-users.d",
}
// defaultDeprecatedEntries are the deprecated files to clean up. This is a map
// of the file path to the key-value pairs to remove.
defaultDeprecatedEntries = map[string][]*textconfig.Entry{
"/etc/pam.d/su": []*textconfig.Entry{
textconfig.NewEntry("account", "[success=bad ignore=ignore] pam_oslogin_login.so"),
},
}
// osloginConfigOpts are the oslogin configuration file options. This is used
// in all the configuration files related to OSLogin.
osloginConfigOpts = textconfig.Options{
Delimiters: &textconfig.Delimiter{
Start: "#### Google OS Login control. Do not edit this section. ####",
End: "#### End Google OS Login control section. ####",
},
}
// execLookPath is stubbed out for testing.
execLookPath = exec.LookPath
)
// osloginModule is the OS Login module.
type osloginModule struct {
// prevMetadata is the previous metadata descriptor.
prevMetadata *metadata.Descriptor
// pipeEventHandler is the ssh trusted ca pipe event handler.
pipeEventHandler *PipeEventHandler
// pipeEventWatcher is the ssh trusted ca pipe event watcher.
pipeEventWatcher *pipewatcher.Handle
// enabled is true if the module is enabled.
enabled atomic.Bool
// failedConfiguration indicates if configuration setup has failed.
failedConfiguration atomic.Bool
// sshdConfigPath is the path to the openssh daemon configuration file.
sshdConfigPath string
// nsswitchConfigPath is the path to the NSSwitch configuration file.
nsswitchConfigPath string
// pamConfigPath is the path to the PAM configuration file.
pamConfigPath string
// groupConfPath is the path to the group configuration file.
groupConfigPath string
// authorizedKeysCommandPaths are the possible paths to the authorized keys
// command binaries.
authorizedKeysCommandPaths []string
// authorizedKeysCommandSKPaths are the possible paths to the authorized keys
// command binaries (in the security key case/variation).
authorizedKeysCommandSKPaths []string
// services is a map of restart methods to the services to restart.
services map[daemon.RestartMethod][]serviceRestartConfig
// osloginDirs are the directories to create for OSLogin, if necessary.
osloginDirs []string
// sudoers is the path to the sudoers file.
sudoers string
// deprecatedEntries are the deprecated files to clean up.
deprecatedEntries map[string][]*textconfig.Entry
}
// serviceRestartProtocol is the protocol to use when restarting a service.
type serviceRestartProtocol int
const (
// serviceRestartAtLeastOne indicates that at least one service must be
// successfully restarted. This is the default protocol.
serviceRestartAtLeastOne serviceRestartProtocol = iota
// serviceRestartOptional indicates that if all services fail to restart,
// the configuration will still be considered successful. This is primarily
// used for services that don't necessarily exist on all platforms.
serviceRestartOptional
)
// serviceRestartConfig is the configuration for restarting a service.
// This is primarily a wrapper around the serviceRestartProtocol enum to make
// it easier to pass around.
type serviceRestartConfig struct {
protocol serviceRestartProtocol
services []string
}
// NewModule returns a new oslogin module for late registration.
func NewModule(context.Context) *manager.Module {
module := &osloginModule{
sshdConfigPath: defaultSSHDConfigPath,
nsswitchConfigPath: defaultNSSwitchConfigPath,
pamConfigPath: defaultPAMConfigPath,
groupConfigPath: defaultGroupConfigPath,
authorizedKeysCommandPaths: defaultAuthorizedKeysCommandPaths,
authorizedKeysCommandSKPaths: defaultAuthorizedKeysCommandSKPaths,
services: defaultServices,
osloginDirs: defaultOSLoginDirs,
sudoers: defaultSudoersPath,
deprecatedEntries: defaultDeprecatedEntries,
}
return &manager.Module{
ID: osloginModuleID,
Setup: module.moduleSetup,
}
}
// moduleSetup is the setup function for the oslogin module.
func (mod *osloginModule) moduleSetup(ctx context.Context, data any) error {
desc, ok := data.(*metadata.Descriptor)
if !ok {
return fmt.Errorf("oslogin module expects a metadata descriptor in the data pointer")
}
if err := mod.removeDeprecatedEntries(); err != nil {
galog.Errorf("Failed to remove deprecated entries: %v", err)
}
// Do the initial first setup execution in the module initialization, it will
// be handled by the metadata longpoll event handler/subscriber after the
// first setup.
_ = mod.osloginSetup(ctx, desc)
// Subscribe to the metadata longpoll event.
sub := events.EventSubscriber{Name: osloginModuleID, Callback: mod.metadataSubscriber}
events.FetchManager().Subscribe(metadata.LongpollEvent, sub)
return nil
}
// metadataSubscriber is the callback for the metadata event and handles the
// platform oslogin configuration changes.
func (mod *osloginModule) metadataSubscriber(ctx context.Context, evType string, data any, evData *events.EventData) bool {
desc, ok := evData.Data.(*metadata.Descriptor)
// If the event manager is passing a non expected data type we log it and
// don't renew the handler.
if !ok {
galog.Errorf("event's data is not a metadata descriptor: %+v", evData.Data)
return false
}
// If the event manager is passing/reporting an error we log it and keep
// renewing the handler.
if evData.Error != nil {
galog.Debugf("Metadata event watcher reported error: %s, skipping.", evData.Error)
return true
}
return mod.osloginSetup(ctx, desc)
}
// osloginSetup is the actual oslogin's configuration entry point.
func (mod *osloginModule) osloginSetup(ctx context.Context, desc *metadata.Descriptor) bool {
defer func() { mod.prevMetadata = desc }()
// If the metadata has not changed, we return early.
// We don't need to clean up the files here because the textconfig library
// rolls back its previous changes before applying new ones.
if !mod.metadataChanged(desc) && !mod.failedConfiguration.Load() {
return true
}
evManager := events.FetchManager()
// If the module is disabled make sure the configuration is disabled and
// return early.
if !desc.OSLoginEnabled() {
defer func() { mod.enabled.Store(false) }()
// If the module is disabled now but was previously enabled do the
// run the disabling path.
if mod.enabled.Load() {
if err := mod.disableOSLogin(ctx, evManager); err != nil {
// Failed to restart the necessary services.
galog.Errorf("Failed to disable OS Login: %v", err)
mod.failedConfiguration.Store(true)
return true
}
mod.failedConfiguration.Store(false)
}
mod.enabled.Store(false)
return true
}
// Enable/start the ssh trusted ca pipe event handler.
if mod.pipeEventHandler == nil {
mod.pipeEventHandler = newPipeEventHandler(pipeWatcherSubscriberID, metadata.New())
}
// Enable/start the ssh trusted ca pipe event watcher.
if mod.pipeEventWatcher == nil {
mod.pipeEventWatcher = pipewatcher.New(sshcaEventWatcherID, sshcaPipeWatcherOpts)
evManager.AddWatcher(ctx, mod.pipeEventWatcher)
}
var failed bool
// Write SSH config.
if err := mod.setupOpenSSH(desc); err != nil {
galog.Errorf("Failed to setup openssh: %v", err)
failed = true
}
// Write NSSwitch config.
if err := mod.setupNSSwitch(false); err != nil {
galog.Errorf("Failed to setup nsswitch: %v", err)
failed = true
}
// Write PAM config.
if err := mod.setupPAM(); err != nil {
galog.Errorf("Failed to setup pam: %v", err)
failed = true
}
// Write Group config.
if err := mod.setupGroup(); err != nil {
galog.Errorf("Failed to setup group: %v", err)
failed = true
}
// Restart services. This is not a blocker.
if err := mod.restartServices(ctx); err != nil {
galog.Errorf("Failed to restart services: %v", err)
failed = true
}
// Create the necessary OSLogin directories and other files.
if err := mod.setupOSLoginDirs(ctx); err != nil {
galog.Errorf("Failed to setup OSLogin directories: %v", err)
failed = true
}
if err := mod.setupOSLoginSudoers(); err != nil {
galog.Errorf("Failed to create OSLogin sudoers file: %v", err)
failed = true
}
// Fill NSS cache.
if _, err := run.WithContext(ctx, run.Options{
Name: "google_oslogin_nss_cache",
OutputType: run.OutputNone,
}); err != nil {
galog.Errorf("Failed to fill NSS cache: %v", err)
failed = true
}
mod.enabled.Store(!failed)
mod.failedConfiguration.Store(failed)
return true
}
// setupOpenSSH configures the openssh daemon.
func (mod *osloginModule) setupOpenSSH(desc *metadata.Descriptor) error {
sshdCfg := textconfig.New(mod.sshdConfigPath, osloginConfigMode, osloginConfigOpts)
block := textconfig.NewBlock(textconfig.Top)
sshdCfg.AddBlock(block)
// Determine the authorized keys command binary.
authorizedKeysCommand, err := availableBinary(mod.authorizedKeysCommandPaths)
if err != nil {
return fmt.Errorf("failed to find authorized keys command binary: %w", err)
}
if desc.SecurityKeyEnabled() {
authorizedKeysCommand, err = availableBinary(mod.authorizedKeysCommandSKPaths)
if err != nil {
return fmt.Errorf("failed to find authorized keys command binary: %w", err)
}
}
cfg := cfg.Retrieve()
certReq := desc.CertRequiredEnabled()
if certReq || cfg.OSLogin.CertAuthentication {
// Add the relevant certificate authority keys.
block.Append("TrustedUserCAKeys", defaultPipePath)
block.Append("AuthorizedPrincipalsCommand", "/usr/bin/google_authorized_principals %u %k")
block.Append("AuthorizedPrincipalsCommandUser", "root")
}
if !certReq && cfg.OSLogin.CertAuthentication {
block.Append("AuthorizedKeysCommand", authorizedKeysCommand)
block.Append("AuthorizedKeysCommandUser", "root")
}
// Add two-factor authentication configuration if enabled.
if desc.TwoFactorEnabled() {
block.Append("AuthenticationMethods", "publickey,keyboard-interactive")
block.Append("ChallengeResponseAuthentication", "yes")
twoFABlock := textconfig.NewBlock(textconfig.Bottom)
sshdCfg.AddBlock(twoFABlock)
twoFABlock.Append("Match", "User sa_*")
twoFABlock.Append("AuthenticationMethods", "publickey")
}
if err := sshdCfg.Apply(); err != nil {
return fmt.Errorf("failed to apply openssh config: %w", err)
}
return nil
}
// setupNSSwitch configures the NSSwitch configuration file. If cleanup is true
// then the configuration is rolled back, otherwise it is set up.
func (mod *osloginModule) setupNSSwitch(cleanup bool) error {
nsswitch, err := os.ReadFile(mod.nsswitchConfigPath)
if err != nil {
return fmt.Errorf("failed to read nsswitch.conf: %w", err)
}
var lines []string
for _, line := range strings.Split(string(nsswitch), "\n") {
if strings.HasPrefix(line, "passwd:") || strings.HasPrefix(line, "group:") {
if cleanup {
line = strings.Replace(line, "cache_oslogin oslogin", "", 1)
line = strings.TrimSpace(line)
} else {
if !strings.Contains(line, "oslogin") {
line += " cache_oslogin oslogin"
}
}
}
lines = append(lines, line)
}
if err := os.WriteFile(mod.nsswitchConfigPath, []byte(strings.Join(lines, "\n")), osloginConfigMode); err != nil {
return fmt.Errorf("failed to write nsswitch.conf: %w", err)
}
return nil
}
// setupPAM configures the PAM module.
func (mod *osloginModule) setupPAM() error {
pamConfig := textconfig.New(mod.pamConfigPath, osloginConfigMode, osloginConfigOpts)
topBlock := textconfig.NewBlock(textconfig.Top)
bottomBlock := textconfig.NewBlock(textconfig.Bottom)
pamConfig.AddBlock(topBlock)
pamConfig.AddBlock(bottomBlock)
pamOSLogin := "[success=done perm_denied=die default=ignore]"
pamGroup := "[default=ignore]"
session := "[success=ok default=ignore]"
topBlock.Append("auth", fmt.Sprintf("%s pam_oslogin_login.so", pamOSLogin))
topBlock.Append("auth", fmt.Sprintf("%s pam_group.so", pamGroup))
bottomBlock.Append("session", fmt.Sprintf("%s pam_mkhomedir.so", session))
if err := pamConfig.Apply(); err != nil {
return fmt.Errorf("failed to apply pam config: %w", err)
}
return nil
}
// setupGroup configures the group config.
func (mod *osloginModule) setupGroup() error {
groupConf := textconfig.New(mod.groupConfigPath, osloginConfigMode, osloginConfigOpts)
block := textconfig.NewBlock(textconfig.Bottom)
groupConf.AddBlock(block)
config := "sshd;*;*;Al0000-2400;video"
block.Append(config, "")
if err := groupConf.Apply(); err != nil {
return fmt.Errorf("failed to apply group config: %w", err)
}
return nil
}
// availableBinary returns the first binary in the fpath that exists.
func availableBinary(fpath []string) (string, error) {
for _, f := range fpath {
if file.Exists(f, file.TypeFile) {
return f, nil
}
}
return "", fmt.Errorf("no binary found in %v", fpath)
}
// removeDeprecatedEntries removes the deprecated entries from the files.
func (mod *osloginModule) removeDeprecatedEntries() error {
// Clean up the deprecated files.
for f, entries := range mod.deprecatedEntries {
deprecatedEntryOpts := textconfig.Options{
Delimiters: &textconfig.Delimiter{
Start: "#### Google OS Login control. Do not edit this section. ####",
End: "#### End Google OS Login control section. ####",
},
DeprecatedEntries: entries,
}
handle := textconfig.New(f, osloginConfigMode, deprecatedEntryOpts)
if err := handle.Cleanup(); err != nil {
return fmt.Errorf("failed to cleanup deprecated file %s: %w", f, err)
}
}
return nil
}
// disableOSLogin stop internal "services" and rollback user's configuration.
func (mod *osloginModule) disableOSLogin(ctx context.Context, evManager *events.Manager) error {
// Make sure we only stop responding to requests to the ssh trusted ca pipe
// when all user's configuration is rolled back.
defer func() {
mod.pipeEventHandler.Close()
mod.pipeEventHandler = nil
evManager.RemoveWatcher(ctx, mod.pipeEventWatcher)
mod.pipeEventWatcher = nil
}()
// Rollback all the configuration.
sshdCfg := textconfig.New(mod.sshdConfigPath, osloginConfigMode, osloginConfigOpts)
if err := sshdCfg.Cleanup(); err != nil {
return fmt.Errorf("failed to rollback openssh config: %w", err)
}
if err := mod.setupNSSwitch(true); err != nil {
return fmt.Errorf("failed to rollback nsswitch config: %w", err)
}
pamCfg := textconfig.New(mod.pamConfigPath, osloginConfigMode, osloginConfigOpts)
if err := pamCfg.Cleanup(); err != nil {
return fmt.Errorf("failed to rollback pam config: %w", err)
}
groupCfg := textconfig.New(mod.groupConfigPath, osloginConfigMode, osloginConfigOpts)
if err := groupCfg.Cleanup(); err != nil {
return fmt.Errorf("failed to rollback group config: %w", err)
}
// Restart the services to reflect the rollback.
if err := mod.restartServices(ctx); err != nil {
return fmt.Errorf("failed to restart services: %w", err)
}
return nil
}
// setupOSLoginDirs creates the necessary directories for OSLogin, if necessary.
// This also runs restorecon on the new directories.
func (mod *osloginModule) setupOSLoginDirs(ctx context.Context) error {
restorecon, restoreconerr := execLookPath("restorecon")
for _, dir := range mod.osloginDirs {
if err := os.MkdirAll(dir, 0750); err != nil {
return fmt.Errorf("failed to create oslogin directory: %w", err)
}
if restoreconerr != nil {
continue
}
if _, err := run.WithContext(ctx, run.Options{
OutputType: run.OutputNone,
Name: restorecon,
Args: []string{dir},
}); err != nil {
return fmt.Errorf("failed to run restorecon on %s: %w", dir, err)
}
}
return nil
}
// setupOSLoginSudoers creates the necessary sudoers file for OSLogin,
// if necessary.
func (mod *osloginModule) setupOSLoginSudoers() error {
if file.Exists(mod.sudoers, file.TypeFile) {
galog.Debugf("Sudoers file %s already exists, skipping.", mod.sudoers)
return nil
}
if err := os.WriteFile(mod.sudoers, []byte("#includedir /var/google-sudoers.d\n"), 0440); err != nil {
return fmt.Errorf("failed to write sudoers file: %w", err)
}
return nil
}
// metadataChanged returns true if the metadata has changed or if it's being
// called on behalf of the first handler's execution.
func (mod *osloginModule) metadataChanged(desc *metadata.Descriptor) bool {
// If the module has not been initialized yet then we return true to force
// the first execution of the setup.
if mod.prevMetadata == nil {
return true
}
// Have the metadata's oslogin knobs changed?
if desc.OSLoginEnabled() != mod.prevMetadata.OSLoginEnabled() {
return true
}
// Have the metadata's two factor authentication knobs changed?
if desc.TwoFactorEnabled() != mod.prevMetadata.TwoFactorEnabled() {
return true
}
// Has the metadata's security key knobs changed?
if desc.SecurityKeyEnabled() != mod.prevMetadata.SecurityKeyEnabled() {
return true
}
// Have the metadata's cert required knobs changed?
if desc.CertRequiredEnabled() != mod.prevMetadata.CertRequiredEnabled() {
return true
}
// No changes detected.
return false
}
// restartServices restarts the provided services with the provided methods.
func (mod *osloginModule) restartServices(ctx context.Context) error {
for method, serviceConfigs := range mod.services {
// One of the services in each service list must be successfully restarted.
for _, serviceConfig := range serviceConfigs {
// Indicates if one of the services in the list was successfully
// restarted.
var passed bool
for _, service := range serviceConfig.services {
if found, err := daemon.CheckUnitExists(ctx, service); !found {
if err != nil {
galog.Errorf("failed to check if service %s exists: %v", service, err)
}
continue
}
if err := daemon.RestartService(ctx, service, method); err != nil {
galog.Errorf("failed to restart service %s: %v", service, err)
continue
}
passed = true
break
}
if !passed && serviceConfig.protocol == serviceRestartAtLeastOne {
return fmt.Errorf("failed to restart one of %v", serviceConfig.services)
}
}
}
return nil
}