mirror of
https://github.com/gravitational/teleport
synced 2024-10-21 17:53:28 +00:00
ab2279634d
* Introduce new tbot output configuration * Remove code that will be included in future test refactor PR * Add config version header * Fix TestInitSymlink * Add support for `standard` database type * Set CA type * `make fix-imports` * More closely mimic original database output behaviour * Make output of additional TLS files for application output optional * Spell compatability properly * Add test for config marshalling * Fix cluster field yaml name * Fix YAML marshalling/unmarshalling * Fix sidecar invocation of tbot * Add WrapDestination helper function to protect wrappedDestination * Apply changes to sidecar * Spell Marshalling the way the linter insists * Fix some logging * Tidy mutex usage on outputRenewalCache * Fix ssh_host generation * Get rid of `destinationWrapper` * Single l in Unmarshaling * Fix operator sidecar tbot * Add UnmarshalYAML for SSHHostOutput * Use `KubernetesCluster` instead of `ClusterName` for clarity * Rename `Subtype` -> `Format` for `DatabaseOutput` * Remove a very british "u" from Behaviour * Add godoc for interfacemethods * Fix double dot in comment Co-authored-by: Michael Wilson <mike@mdwn.dev> * Use const for database formats * Reuse constant type string in Stringer * Add godoc comment explaining behaviour if no destination found * Inject executablePathGetter rather than using package level variable * Use correct case in error * Add warning for destination reuse * Try to improve confusing log message * Remove 'u' from behaviour --------- Co-authored-by: Michael Wilson <mike@mdwn.dev>
544 lines
16 KiB
Go
544 lines
16 KiB
Go
/*
|
|
Copyright 2022 Gravitational, Inc.
|
|
|
|
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
|
|
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 (
|
|
"fmt"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
|
|
"github.com/gravitational/teleport/api/constants"
|
|
"github.com/gravitational/teleport/lib/tbot/botfs"
|
|
"github.com/gravitational/teleport/lib/tbot/config"
|
|
"github.com/gravitational/teleport/lib/tbot/identity"
|
|
)
|
|
|
|
// RootUID is the UID of the root user
|
|
const RootUID = "0"
|
|
|
|
// aclTestFailedMessage is printed when an ACL test fails.
|
|
const aclTestFailedMessage = "ACLs are not usable for destination %s; " +
|
|
"Change the destination's ACL mode to `off` to silence this warning."
|
|
|
|
// getInitArtifacts returns a map of all desired artifacts for the destination
|
|
func getInitArtifacts(output config.Output) map[string]bool {
|
|
// true = directory, false = regular file
|
|
toCreate := map[string]bool{}
|
|
|
|
// Collect all base artifacts and filter for the destination.
|
|
for _, artifact := range identity.GetArtifacts() {
|
|
if artifact.Matches(identity.DestinationKinds()...) {
|
|
toCreate[artifact.Key] = false
|
|
}
|
|
}
|
|
|
|
// Collect all config template artifacts.
|
|
for _, fd := range output.Describe() {
|
|
toCreate[fd.Name] = fd.IsDir
|
|
}
|
|
|
|
return toCreate
|
|
}
|
|
|
|
// getExistingArtifacts fetches all entries in a destination directory
|
|
func getExistingArtifacts(dir string) (map[string]bool, error) {
|
|
existing := map[string]bool{}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
existing[entry.Name()] = entry.IsDir()
|
|
}
|
|
|
|
return existing, nil
|
|
}
|
|
|
|
// diffArtifacts computes the difference of two artifact sets
|
|
func diffArtifacts(a, b map[string]bool) map[string]bool {
|
|
diff := map[string]bool{}
|
|
|
|
for k, v := range a {
|
|
if _, ok := b[k]; !ok {
|
|
diff[k] = v
|
|
}
|
|
}
|
|
|
|
return diff
|
|
}
|
|
|
|
// testACL creates a temporary file and attempts to apply an ACL to it. If the
|
|
// ACL is successfully applied, returns nil; otherwise, returns the error.
|
|
func testACL(directory string, ownerUser *user.User, opts *botfs.ACLOptions) error {
|
|
// Note: we need to create the test file in the dest dir to ensure we
|
|
// actually test the target filesystem.
|
|
id, err := uuid.NewRandom()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
testFile := filepath.Join(directory, id.String())
|
|
if err := botfs.Create(testFile, false, botfs.SymlinksInsecure); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
defer func() {
|
|
err := os.Remove(testFile)
|
|
if err != nil {
|
|
log.Debugf("Failed to delete ACL test file %q", testFile)
|
|
}
|
|
}()
|
|
|
|
//nolint:staticcheck // staticcheck doesn't like nop implementations in fs_other.go
|
|
if err := botfs.ConfigureACL(testFile, ownerUser, opts); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensurePermissionsParams sets permissions options
|
|
type ensurePermissionsParams struct {
|
|
// dirPath is the directory containing the file
|
|
dirPath string
|
|
|
|
// symlinksMode configures symlink attack mitigation behavior
|
|
symlinksMode botfs.SymlinksMode
|
|
|
|
// ownerUser is the user that should own the file
|
|
ownerUser *user.User
|
|
|
|
// ownerGroup is the group that should own the file
|
|
ownerGroup *user.Group
|
|
|
|
// aclOptions contains configuration for ACLs, if they are in use. nil if
|
|
// ACLs are disabled
|
|
aclOptions *botfs.ACLOptions
|
|
|
|
// toCreate is a set of files that will be newly created, used to reduce
|
|
// log spam when configuring new files
|
|
toCreate map[string]bool
|
|
}
|
|
|
|
// ensurePermissions verifies permissions on the given path and, when
|
|
// possible, attempts to fix permissions / ACLs on any misconfigured paths.
|
|
func ensurePermissions(params *ensurePermissionsParams, key string, isDir bool) error {
|
|
path := filepath.Join(params.dirPath, key)
|
|
|
|
//nolint:staticcheck // this entirely innocuous line generates "related
|
|
// information" lints for a false positive staticcheck lint relating to
|
|
// nop function implementations in fs_other.go.
|
|
stat, err := os.Stat(path)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
cleanPath := filepath.Clean(path)
|
|
resolved, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Precomputed flag to determine if certain log messages should be hidden.
|
|
// We expect ownership, ACLs, etc to be wrong on initial create so warnings
|
|
// are not desirable.
|
|
_, newlyCreated := params.toCreate[key]
|
|
verboseLogging := key != "" && !newlyCreated
|
|
|
|
// This is unlikely as CreateSecure() refuses to follow symlinks, but users
|
|
// could move things around after the fact.
|
|
if cleanPath != resolved {
|
|
switch params.symlinksMode {
|
|
case botfs.SymlinksSecure:
|
|
return trace.BadParameter("Path %q contains symlinks which is not "+
|
|
"allowed for security reasons.", path)
|
|
case botfs.SymlinksInsecure:
|
|
// do nothing
|
|
default:
|
|
log.Warnf("Path %q contains symlinks and may be subject to symlink "+
|
|
"attacks. If this is intentional, consider setting `symlinks: "+
|
|
"insecure` in destination config.", path)
|
|
}
|
|
}
|
|
|
|
// A few conditions we won't try to handle...
|
|
if isDir && !stat.IsDir() {
|
|
return trace.BadParameter("File %s is expected to be a directory but is a file", path)
|
|
} else if !isDir && stat.IsDir() {
|
|
return trace.BadParameter("File %s is expected to be a file but is a directory", path)
|
|
}
|
|
|
|
currentUser, err := user.Current()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Correct ownership.
|
|
ownedByDesiredOwner, err := botfs.IsOwnedBy(stat, params.ownerUser)
|
|
if err != nil {
|
|
log.WithError(err).Debugf("Could not determine file ownership of %q", path)
|
|
|
|
// Can't read file ownership on this platform (e.g. Windows), so always
|
|
// attempt to chown (which does work on Windows)
|
|
ownedByDesiredOwner = false
|
|
}
|
|
|
|
if !ownedByDesiredOwner {
|
|
// If we're not running as root, this will probably fail.
|
|
if currentUser.Uid != RootUID && runtime.GOOS != constants.WindowsOS {
|
|
log.Warnf("Not running as root, ownership change is likely to fail.")
|
|
}
|
|
|
|
uid, err := strconv.Atoi(params.ownerUser.Uid)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
gid, err := strconv.Atoi(params.ownerGroup.Gid)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if verboseLogging {
|
|
log.Warnf("Ownership of %q is incorrect and will be corrected to %s", path, params.ownerUser.Username)
|
|
}
|
|
|
|
err = os.Chown(path, uid, gid)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
if params.aclOptions != nil {
|
|
// We can verify ACLs as any user with read, but can only correct ACLs
|
|
// as root or the owner.
|
|
// Note that we rely on VerifyACL to return some error if permissions
|
|
// are incorrect.
|
|
|
|
//nolint:staticcheck // staticcheck doesn't like nop implementations in fs_other.go
|
|
err = botfs.VerifyACL(path, params.aclOptions)
|
|
//nolint:staticcheck // staticcheck doesn't like nop implementations in fs_other.go
|
|
if err != nil && (currentUser.Uid == RootUID || currentUser.Uid == params.ownerUser.Uid) {
|
|
if verboseLogging {
|
|
log.Warnf("ACL for %q is not correct and will be corrected: %v", path, err)
|
|
}
|
|
|
|
return trace.Wrap(botfs.ConfigureACL(path, params.ownerUser, params.aclOptions))
|
|
} else if err != nil {
|
|
log.Errorf("ACL for %q is incorrect but `tbot init` must be run "+
|
|
"as root or the owner (%s) to correct it: %v",
|
|
path, params.ownerUser.Username, err)
|
|
return trace.AccessDenied("Elevated permissions required")
|
|
}
|
|
|
|
// ACL is valid.
|
|
return nil
|
|
}
|
|
|
|
desiredMode := botfs.DefaultMode
|
|
if stat.IsDir() {
|
|
desiredMode = botfs.DefaultDirMode
|
|
}
|
|
|
|
// Using regular permissions, so attempt to correct the file mode.
|
|
if stat.Mode().Perm() != desiredMode {
|
|
if err := os.Chmod(path, desiredMode); err != nil {
|
|
return trace.Wrap(err, "Could not fix permissions on file %q, expected %#o", path, desiredMode)
|
|
}
|
|
|
|
log.Infof("Corrected permissions on %q from %#o to %#o", path, stat.Mode().Perm(), botfs.DefaultMode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseOwnerString parses and looks up an user and group from a user:group
|
|
// owner string.
|
|
func parseOwnerString(owner string) (*user.User, *user.Group, error) {
|
|
ownerParts := strings.Split(owner, ":")
|
|
if len(ownerParts) != 2 {
|
|
return nil, nil, trace.BadParameter("invalid owner string: %q", owner)
|
|
}
|
|
|
|
ownerUser, err := user.Lookup(ownerParts[0])
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
ownerGroup, err := user.LookupGroup(ownerParts[1])
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
return ownerUser, ownerGroup, nil
|
|
}
|
|
|
|
// getOwner determines the user/group owner given a CLI parameter (--owner)
|
|
// and desired default value. An empty defaultOwner defaults to the current
|
|
// user and their primary group.
|
|
func getOwner(cliOwner, defaultOwner string) (*user.User, *user.Group, error) {
|
|
if cliOwner != "" {
|
|
// If --owner is set, always use it.
|
|
log.Debugf("Attempting to use explicitly requested owner: %s", cliOwner)
|
|
return parseOwnerString(cliOwner)
|
|
}
|
|
|
|
if defaultOwner != "" {
|
|
log.Debugf("Attempting to use default owner: %s", defaultOwner)
|
|
// If a default owner is specified, try it instead.
|
|
return parseOwnerString(defaultOwner)
|
|
}
|
|
|
|
log.Debugf("Will use current user as owner.")
|
|
// Otherwise, return the current user and group
|
|
currentUser, err := user.Current()
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
currentGroup, err := user.LookupGroupId(currentUser.Gid)
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
return currentUser, currentGroup, nil
|
|
}
|
|
|
|
// getAndTestACLOptions gets options needed to configure an ACL from CLI
|
|
// options and attempts to configure a test ACL to validate them. Ownership is
|
|
// not validated here.
|
|
func getAndTestACLOptions(cf *config.CLIConf, destDir string) (*user.User, *user.Group, *botfs.ACLOptions, error) {
|
|
if cf.BotUser == "" {
|
|
return nil, nil, nil, trace.BadParameter("--bot-user must be set")
|
|
}
|
|
|
|
if cf.ReaderUser == "" {
|
|
return nil, nil, nil, trace.BadParameter("--reader-user must be set")
|
|
}
|
|
|
|
botUser, err := user.Lookup(cf.BotUser)
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
botGroup, err := user.LookupGroupId(botUser.Gid)
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
readerUser, err := user.Lookup(cf.ReaderUser)
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
opts := botfs.ACLOptions{
|
|
BotUser: botUser,
|
|
ReaderUser: readerUser,
|
|
}
|
|
|
|
// Default to letting the bot own the destination, since by this point we
|
|
// know the bot user definitely exists and is a reasonable owner choice.
|
|
defaultOwner := fmt.Sprintf("%s:%s", botUser.Username, botGroup.Name)
|
|
|
|
ownerUser, ownerGroup, err := getOwner(cf.Owner, defaultOwner)
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
err = testACL(destDir, ownerUser, &opts)
|
|
if err != nil && trace.IsAccessDenied(err) {
|
|
return nil, nil, nil, trace.Wrap(err, "The destination %q does not appear to be writable", destDir)
|
|
} else if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err, "ACL support may need to be enabled for the filesystem.")
|
|
}
|
|
|
|
return ownerUser, ownerGroup, &opts, nil
|
|
}
|
|
|
|
func onInit(botConfig *config.BotConfig, cf *config.CLIConf) error {
|
|
var output config.Output
|
|
var err error
|
|
// First, resolve the correct output. If using a config file with only
|
|
// 1 destination we can assume we want to init that one; otherwise,
|
|
// --init-dir is required.
|
|
if cf.InitDir == "" {
|
|
if len(botConfig.Outputs) == 1 {
|
|
output = botConfig.Outputs[0]
|
|
} else {
|
|
return trace.BadParameter("An output to initialize must be specified with --init-dir")
|
|
}
|
|
} else {
|
|
output, err = botConfig.GetOutputByPath(cf.InitDir)
|
|
if err != nil {
|
|
return trace.WrapWithMessage(err, "Could not find specified destination %q", cf.InitDir)
|
|
}
|
|
|
|
if output == nil {
|
|
// TODO: in the future if/when other backends are supported,
|
|
// destination might be nil because the user tried to enter a non
|
|
// filesystem path, so this error message could be misleading.
|
|
return trace.NotFound("Cannot initialize destination %q because "+
|
|
"it has not been configured.", cf.InitDir)
|
|
}
|
|
}
|
|
|
|
destImpl := output.GetDestination()
|
|
|
|
destDir, ok := destImpl.(*config.DestinationDirectory)
|
|
if !ok {
|
|
return trace.BadParameter("`tbot init` only supports directory destinations")
|
|
}
|
|
|
|
log.Infof("Initializing destination: %s", destImpl)
|
|
|
|
// Create the directory if needed. We haven't checked directory ownership,
|
|
// but it will fail when the ACLs are created if anything is misconfigured.
|
|
if err := output.Init(); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Next, test if we want + have ACL support, and set aclOpts if we do.
|
|
// Desired owner is derived from the ACL mode.
|
|
var aclOpts *botfs.ACLOptions
|
|
var ownerUser *user.User
|
|
var ownerGroup *user.Group
|
|
|
|
switch destDir.ACLs {
|
|
case botfs.ACLRequired, botfs.ACLTry:
|
|
log.Debug("Testing for ACL support...")
|
|
|
|
// Awkward control flow here, but we want these to fail together.
|
|
ownerUser, ownerGroup, aclOpts, err = getAndTestACLOptions(cf, destDir.Path)
|
|
if err != nil {
|
|
if destDir.ACLs == botfs.ACLRequired {
|
|
// ACLs were specifically requested (vs "try" mode), so fail.
|
|
return trace.Wrap(err, aclTestFailedMessage, destImpl)
|
|
}
|
|
|
|
// Otherwise, fall back to no ACL with a warning.
|
|
log.WithError(err).Warnf(aclTestFailedMessage, destImpl)
|
|
aclOpts = nil
|
|
|
|
// We'll also need to re-fetch the owner as the defaults are
|
|
// different in the fallback case.
|
|
ownerUser, ownerGroup, err = getOwner(cf.Owner, "")
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
} else if aclOpts.ReaderUser.Uid == ownerUser.Uid {
|
|
log.Warnf("The destination owner (%s) and reader (%s) are the "+
|
|
"same. This will break OpenSSH.", aclOpts.ReaderUser.Username,
|
|
ownerUser.Username)
|
|
}
|
|
default:
|
|
log.Info("ACLs disabled for this destination.")
|
|
ownerUser, ownerGroup, err = getOwner(cf.Owner, "")
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Next, resolve what we want and what we already have.
|
|
desired := getInitArtifacts(output)
|
|
existing, err := getExistingArtifacts(destDir.Path)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
toCreate := diffArtifacts(desired, existing)
|
|
toRemove := diffArtifacts(existing, desired)
|
|
|
|
// Based on this, create any new files.
|
|
if len(toCreate) > 0 {
|
|
log.Infof("Attempting to create: %v", toCreate)
|
|
|
|
for key, isDir := range toCreate {
|
|
path := filepath.Join(destDir.Path, key)
|
|
if err := botfs.Create(path, isDir, destDir.Symlinks); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
log.Infof("Created: %s", path)
|
|
}
|
|
} else {
|
|
log.Info("Nothing to create.")
|
|
}
|
|
|
|
// ... and warn about / remove any unneeded files.
|
|
if len(toRemove) > 0 && cf.Clean {
|
|
log.Infof("Attempting to remove: %v", toRemove)
|
|
|
|
var errors []error
|
|
|
|
for key := range toRemove {
|
|
path := filepath.Join(destDir.Path, key)
|
|
|
|
if err := os.RemoveAll(path); err != nil {
|
|
errors = append(errors, err)
|
|
} else {
|
|
log.Infof("Removed: %s", path)
|
|
}
|
|
}
|
|
|
|
if err := trace.NewAggregate(errors...); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
} else if len(toRemove) > 0 {
|
|
log.Warnf("Unexpected files found in destination directory, consider " +
|
|
"removing it manually or rerunning `tbot init` with the `--clean` " +
|
|
"flag.")
|
|
} else {
|
|
log.Info("Nothing to remove.")
|
|
}
|
|
|
|
params := ensurePermissionsParams{
|
|
dirPath: destDir.Path,
|
|
aclOptions: aclOpts,
|
|
ownerUser: ownerUser,
|
|
ownerGroup: ownerGroup,
|
|
symlinksMode: destDir.Symlinks,
|
|
toCreate: toCreate,
|
|
}
|
|
|
|
// Check and set permissions on the directory itself.
|
|
if err := ensurePermissions(¶ms, "", true); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Lastly, set and check permissions on all the desired files.
|
|
for key, isDir := range desired {
|
|
if err := ensurePermissions(¶ms, key, isDir); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
log.Infof("destination %s has been initialized. Note that these files "+
|
|
"will be empty and invalid until the bot issues certificates.",
|
|
destImpl)
|
|
|
|
return nil
|
|
}
|