diff --git a/api/constants/constants.go b/api/constants/constants.go
index 8c940dc72e9..a73125e4b39 100644
--- a/api/constants/constants.go
+++ b/api/constants/constants.go
@@ -610,3 +610,17 @@ const (
// EICEDisabledMessage is the message that gets returned to the user when they try to use this functionality.
EICEDisabledMessage = "support for accessing EC2 instances using EC2 Instance Connect Endpoint was removed"
)
+
+const (
+ // TeleportDropGroup is a default group that users of the teleport automated user
+ // provisioning system get added to when provisioned in INSECURE_DROP mode. This
+ // prevents already existing users from being tampered with or deleted.
+ TeleportDropGroup = "teleport-system"
+ // TeleportKeepGroup is a default group that users of the teleport automated user
+ // provisioning system get added to when provisioned in KEEP mode. This prevents
+ // already existing users from being tampered with or deleted.
+ TeleportKeepGroup = "teleport-keep"
+ // TeleportStaticGroup is a default group that static host users get added to. This
+ // prevents already existing users from being tampered with or deleted.
+ TeleportStaticGroup = "teleport-static"
+)
diff --git a/api/types/constants.go b/api/types/constants.go
index 9f9564b7957..058b363922b 100644
--- a/api/types/constants.go
+++ b/api/types/constants.go
@@ -17,6 +17,7 @@ limitations under the License.
package types
import (
+ "github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/types/common"
)
@@ -1763,18 +1764,28 @@ var KubernetesCoreResourceKinds = map[string]struct{}{
"services": {},
}
+// TODO(espadolini): delete in v20
const (
// TeleportDropGroup is a default group that users of the teleport automated user
// provisioning system get added to when provisioned in INSECURE_DROP mode. This
// prevents already existing users from being tampered with or deleted.
- TeleportDropGroup = "teleport-system"
+ //
+ // Deprecated: use [constants.TeleportDropGroup].
+ //go:fix inline
+ TeleportDropGroup = constants.TeleportDropGroup
// TeleportKeepGroup is a default group that users of the teleport automated user
// provisioning system get added to when provisioned in KEEP mode. This prevents
// already existing users from being tampered with or deleted.
- TeleportKeepGroup = "teleport-keep"
+ //
+ // Deprecated: use [constants.TeleportKeepGroup].
+ //go:fix inline
+ TeleportKeepGroup = constants.TeleportKeepGroup
// TeleportStaticGroup is a default group that static host users get added to. This
// prevents already existing users from being tampered with or deleted.
- TeleportStaticGroup = "teleport-static"
+ //
+ // Deprecated: use [constants.TeleportStaticGroup].
+ //go:fix inline
+ TeleportStaticGroup = constants.TeleportStaticGroup
)
const (
diff --git a/constants.go b/constants.go
index f50716ed7f2..3983093b109 100644
--- a/constants.go
+++ b/constants.go
@@ -577,24 +577,6 @@ const (
JumpCloud = "jumpcloud"
)
-const (
- // RemoteCommandSuccess is returned when a command has successfully executed.
- RemoteCommandSuccess = 0
- // RemoteCommandFailure is returned when a command has failed to execute and
- // we don't have another status code for it.
- RemoteCommandFailure = 255
- // HomeDirNotFound is returned when the "teleport checkhomedir" command cannot
- // find the user's home directory.
- HomeDirNotFound = 254
- // HomeDirNotAccessible is returned when the "teleport checkhomedir" command has
- // found the user's home directory, but the user does NOT have permissions to
- // access it.
- HomeDirNotAccessible = 253
- // UnexpectedCredentials is returned when a command is no longer running with the expected
- // credentials.
- UnexpectedCredentials = 252
-)
-
// MaxResourceSize is the maximum size (in bytes) of a serialized resource. This limit is
// typically only enforced against resources that are likely to arbitrarily grow (e.g. PluginData).
const MaxResourceSize = 1000000
@@ -958,28 +940,6 @@ const (
)
const (
- // ExecSubCommand is the sub-command Teleport uses to re-exec itself for
- // command execution (exec and shells).
- ExecSubCommand = "exec"
-
- // NetworkingSubCommand is the sub-command Teleport uses to re-exec itself
- // for networking operations. e.g. local/remote port forwarding, agent forwarding,
- // or x11 forwarding.
- NetworkingSubCommand = "networking"
-
- // CheckHomeDirSubCommand is the sub-command Teleport uses to re-exec itself
- // to check if the user's home directory exists.
- CheckHomeDirSubCommand = "checkhomedir"
-
- // ParkSubCommand is the sub-command Teleport uses to re-exec itself as a
- // specific UID to prevent the matching user from being deleted before
- // spawning the intended child process.
- ParkSubCommand = "park"
-
- // SFTPSubCommand is the sub-command Teleport uses to re-exec itself to
- // handle SFTP connections.
- SFTPSubCommand = "sftp"
-
// WaitSubCommand is the sub-command Teleport uses to wait
// until a domain name stops resolving. Its main use is to ensure no
// auth instances are still running the previous major version.
diff --git a/constants_test.go b/constants_test.go
index 53e0365cba5..ae5eca95a24 100644
--- a/constants_test.go
+++ b/constants_test.go
@@ -16,17 +16,17 @@
package teleport
-import "github.com/gravitational/teleport/session/logutils"
+import "github.com/gravitational/teleport/session/logconstants"
-// static assertions that [logutils.ComponentKey] and [logutils.ComponentFields]
+// static assertions that [logconstants.ComponentKey] and [logconstants.ComponentFields]
// are equal to the respective consts defined in this package; we can't just
// define them to be equal because the true definition belongs here and we want
// to avoid circular module requirements
func _() {
- const mustBeTrue = ComponentKey == logutils.ComponentKey
+ const mustBeTrue = ComponentKey == logconstants.ComponentKey
_ = map[bool]struct{}{false: struct{}{}, mustBeTrue: struct{}{}}
}
func _() {
- const mustBeTrue = ComponentFields == logutils.ComponentFields
+ const mustBeTrue = ComponentFields == logconstants.ComponentFields
_ = map[bool]struct{}{false: struct{}{}, mustBeTrue: struct{}{}}
}
diff --git a/integration/helpers/testmain.go b/integration/helpers/testmain.go
index bf0d84a3ee5..07094cf7b1b 100644
--- a/integration/helpers/testmain.go
+++ b/integration/helpers/testmain.go
@@ -26,8 +26,8 @@ import (
"github.com/gravitational/teleport/lib/cryptosuites/cryptosuitestest"
"github.com/gravitational/teleport/lib/modules"
- "github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/utils/log/logtest"
+ "github.com/gravitational/teleport/session/reexec"
"github.com/gravitational/teleport/tool/teleport/common"
)
@@ -42,7 +42,7 @@ func TestMainImplementation(m *testing.M) {
modules.SetInsecureTestMode(true)
// If the test is re-executing itself, execute the command that comes over
// the pipe.
- if srv.IsReexec() {
+ if reexec.IsReexec() {
defer cancel()
common.Run(common.Options{Args: os.Args[1:]})
return
diff --git a/integration/hostuser_test.go b/integration/hostuser_test.go
index 486548f79a0..cf17f4e41fb 100644
--- a/integration/hostuser_test.go
+++ b/integration/hostuser_test.go
@@ -40,6 +40,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ apiconstants "github.com/gravitational/teleport/api/constants"
decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1"
labelv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/label/v1"
userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2"
@@ -253,7 +254,7 @@ func TestRootHostUsers(t *testing.T) {
closer, err := users.UpsertUser(testuser, &decisionpb.HostUsersInfo{Groups: testGroups, Mode: decisionpb.HostUserMode_HOST_USER_MODE_DROP})
require.NoError(t, err)
- testGroups = append(testGroups, types.TeleportDropGroup)
+ testGroups = append(testGroups, apiconstants.TeleportDropGroup)
t.Cleanup(func() { cleanupUsersAndGroups([]string{testuser}, testGroups) })
u, err := user.Lookup(testuser)
@@ -282,7 +283,7 @@ func TestRootHostUsers(t *testing.T) {
})
require.NoError(t, err)
- t.Cleanup(func() { cleanupUsersAndGroups([]string{testuser}, []string{types.TeleportDropGroup}) })
+ t.Cleanup(func() { cleanupUsersAndGroups([]string{testuser}, []string{apiconstants.TeleportDropGroup}) })
group, err := user.LookupGroupId(testGID)
require.NoError(t, err)
@@ -307,7 +308,7 @@ func TestRootHostUsers(t *testing.T) {
closer, err := users.UpsertUser(testuser, &decisionpb.HostUsersInfo{Mode: decisionpb.HostUserMode_HOST_USER_MODE_KEEP})
require.NoError(t, err)
require.Nil(t, closer)
- t.Cleanup(func() { cleanupUsersAndGroups([]string{testuser}, []string{types.TeleportKeepGroup}) })
+ t.Cleanup(func() { cleanupUsersAndGroups([]string{testuser}, []string{apiconstants.TeleportKeepGroup}) })
u, err := user.Lookup(testuser)
require.NoError(t, err)
@@ -373,7 +374,7 @@ func TestRootHostUsers(t *testing.T) {
t.Cleanup(func() {
cleanupUsersAndGroups(
[]string{"teleport-user1", "teleport-user2", "teleport-user3", "teleport-user4"},
- []string{types.TeleportDropGroup, types.TeleportKeepGroup})
+ []string{apiconstants.TeleportDropGroup, apiconstants.TeleportKeepGroup})
})
err = users.DeleteAllUsers()
@@ -598,13 +599,13 @@ func TestRootHostUsers(t *testing.T) {
})
t.Run("Test migrate unmanaged user", func(t *testing.T) {
- t.Cleanup(func() { cleanupUsersAndGroups([]string{testuser}, []string{types.TeleportKeepGroup}) })
+ t.Cleanup(func() { cleanupUsersAndGroups([]string{testuser}, []string{apiconstants.TeleportKeepGroup}) })
users := srv.NewHostUsers(context.Background(), presence, "host_uuid")
_, err := host.UserAdd(testuser, nil, host.UserOpts{})
require.NoError(t, err)
- closer, err := users.UpsertUser(testuser, &decisionpb.HostUsersInfo{Mode: decisionpb.HostUserMode_HOST_USER_MODE_KEEP, Groups: []string{types.TeleportKeepGroup}})
+ closer, err := users.UpsertUser(testuser, &decisionpb.HostUsersInfo{Mode: decisionpb.HostUserMode_HOST_USER_MODE_KEEP, Groups: []string{apiconstants.TeleportKeepGroup}})
require.NoError(t, err)
require.Nil(t, closer)
@@ -614,7 +615,7 @@ func TestRootHostUsers(t *testing.T) {
gids, err := u.GroupIds()
require.NoError(t, err)
- keepGroup, err := user.LookupGroup(types.TeleportKeepGroup)
+ keepGroup, err := user.LookupGroup(apiconstants.TeleportKeepGroup)
require.NoError(t, err)
require.Contains(t, gids, keepGroup.Gid)
})
@@ -888,7 +889,7 @@ func testStaticHostUsers(t *testing.T, nodeUUID, goodLogin, goodLoginWithShell,
userGroups = append(userGroups, group.Name)
}
require.Subset(t, userGroups, groups)
- require.Contains(t, userGroups, types.TeleportStaticGroup)
+ require.Contains(t, userGroups, apiconstants.TeleportStaticGroup)
// Check that the sudoers file was created.
require.FileExists(t, sudoersPath(goodLogin, nodeUUID))
userShells, err := getUserShells("/etc/passwd")
diff --git a/lib/srv/ctx.go b/lib/srv/ctx.go
index e0c7ade3c67..cf8499b4cb0 100644
--- a/lib/srv/ctx.go
+++ b/lib/srv/ctx.go
@@ -61,6 +61,7 @@ import (
"github.com/gravitational/teleport/session/envutils"
"github.com/gravitational/teleport/session/networking/x11"
"github.com/gravitational/teleport/session/pam/pamcfg"
+ "github.com/gravitational/teleport/session/reexec"
)
var ctxID int32
@@ -207,7 +208,7 @@ type Server interface {
// ChildLogConfig is the log configuration for handling logs from child processes.
type ChildLogConfig struct {
- ExecLogConfig
+ reexec.ExecLogConfig
// Writer is the output writer to use for the logger. May be nil.
Writer io.Writer
@@ -1066,7 +1067,7 @@ func (c *ServerContext) LogValue() slog.Value {
)
}
-func getPAMConfig(c *ServerContext) (*PAMConfig, error) {
+func getPAMConfig(c *ServerContext) (*reexec.PAMConfig, error) {
// PAM should be disabled.
if c.srv.Component() != teleport.ComponentNode {
return nil, nil
@@ -1117,7 +1118,7 @@ func getPAMConfig(c *ServerContext) (*PAMConfig, error) {
}
}
- return &PAMConfig{
+ return &reexec.PAMConfig{
UsePAMAuth: localPAMConfig.UsePAMAuth,
ServiceName: localPAMConfig.ServiceName,
Environment: environment,
@@ -1126,7 +1127,7 @@ func getPAMConfig(c *ServerContext) (*PAMConfig, error) {
// ExecCommand takes a *ServerContext and extracts the parts needed to create
// an *execCommand which can be re-sent to Teleport.
-func (c *ServerContext) ExecCommand() (*ExecCommand, error) {
+func (c *ServerContext) ExecCommand() (*reexec.ExecCommand, error) {
// Extract the command to be executed. This only exists if command execution
// (exec or shell) is being requested, port forwarding has no command to
// execute.
@@ -1163,7 +1164,7 @@ func (c *ServerContext) ExecCommand() (*ExecCommand, error) {
}
// Create the execCommand that will be sent to the child process.
- return &ExecCommand{
+ return &reexec.ExecCommand{
LogConfig: c.srv.ChildLogConfig().ExecLogConfig,
Command: command,
DestinationAddress: c.DstAddr,
@@ -1285,10 +1286,10 @@ func closeAll(closers ...io.Closer) error {
return trace.NewAggregate(errs...)
}
-func newUaccMetadata(c *ServerContext) (*UaccMetadata, error) {
+func newUaccMetadata(c *ServerContext) (*reexec.UaccMetadata, error) {
utmpPath, wtmpPath, btmpPath, wtmpdbPath := c.srv.GetUserAccountingPaths()
- return &UaccMetadata{
- RemoteAddr: utils.FromAddr(c.ConnectionContext.ServerConn.RemoteAddr()),
+ return &reexec.UaccMetadata{
+ RemoteAddr: reexec.NetAddrFromAddr(c.ConnectionContext.ServerConn.RemoteAddr()),
UtmpPath: utmpPath,
WtmpPath: wtmpPath,
BtmpPath: btmpPath,
@@ -1408,7 +1409,7 @@ func (c *ServerContext) WaitForChild(ctx context.Context) error {
// Session Recording events to the SSH session.
var waitErr error
if bpfService.Enabled() {
- if waitErr = waitForSignal(ctx, c.readyr, childReadyWaitTimeout); waitErr != nil {
+ if waitErr = reexec.WaitForSignal(ctx, c.readyr, childReadyWaitTimeout); waitErr != nil {
c.Logger.ErrorContext(ctx, "Child process never became ready.", "error", waitErr)
}
}
diff --git a/lib/srv/exec.go b/lib/srv/exec.go
index 412446da0b1..7f1bbbe5df9 100644
--- a/lib/srv/exec.go
+++ b/lib/srv/exec.go
@@ -19,8 +19,8 @@
package srv
import (
- "bufio"
"context"
+ "encoding/json"
"errors"
"fmt"
"io"
@@ -43,16 +43,9 @@ import (
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/sshutils/reexec"
- "github.com/gravitational/teleport/lib/utils"
-)
-
-const (
- defaultPath = "/bin:/usr/bin:/usr/local/bin:/sbin"
- defaultEnvPath = "PATH=" + defaultPath
- defaultRootPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
- defaultEnvRootPath = "PATH=" + defaultRootPath
- defaultTerm = "xterm"
- defaultLoginDefsPath = "/etc/login.defs"
+ "github.com/gravitational/teleport/session/envutils"
+ sessionreexec "github.com/gravitational/teleport/session/reexec"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
)
// ExecResult is used internally to send the result of a command execution from
@@ -591,66 +584,6 @@ func emitExecAuditEvent(ctx *ServerContext, cmd string, execErr error) {
}
}
-// getDefaultEnvPath returns the default value of PATH environment variable for
-// new logins (prior to shell) based on login.defs. Returns a string which
-// looks like "PATH=/usr/bin:/bin"
-func getDefaultEnvPath(uid string, loginDefsPath string) string {
- envPath := defaultEnvPath
- envRootPath := defaultEnvRootPath
-
- // open file, if it doesn't exist return a default path and move on
- f, err := utils.OpenFileAllowingUnsafeLinks(loginDefsPath)
- if err != nil {
- if uid == "0" {
- slog.DebugContext(context.Background(), "Unable to open login.defs, returning default su path", "login_defs_path", loginDefsPath, "error", err, "default_path", defaultEnvRootPath)
- return defaultEnvRootPath
- }
- slog.DebugContext(context.Background(), "Unable to open login.defs, returning default path", "login_defs_path", loginDefsPath, "error", err, "default_path", defaultEnvPath)
- return defaultEnvPath
- }
- defer f.Close()
-
- // read path from login.defs file (/etc/login.defs) line by line:
- scanner := bufio.NewScanner(f)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
-
- // skip comments and empty lines:
- if line == "" || line[0] == '#' {
- continue
- }
-
- // look for a line that starts with ENV_PATH or ENV_SUPATH
- fields := strings.Fields(line)
- if len(fields) > 1 {
- if fields[0] == "ENV_PATH" {
- envPath = fields[1]
- }
- if fields[0] == "ENV_SUPATH" {
- envRootPath = fields[1]
- }
- }
- }
-
- // if any error occurs while reading the file, return the default value
- err = scanner.Err()
- if err != nil {
- if uid == "0" {
- slog.WarnContext(context.Background(), "Unable to read login.defs, returning default su path", "login_defs_path", loginDefsPath, "error", err, "default_path", defaultEnvRootPath)
- return defaultEnvRootPath
- }
- slog.WarnContext(context.Background(), "Unable to read login.defs, returning default path", "login_defs_path", loginDefsPath, "error", err, "default_path", defaultEnvPath)
- return defaultEnvPath
- }
-
- // if requesting path for uid 0 and no ENV_SUPATH is given, fallback to
- // ENV_PATH first, then the default path.
- if uid == "0" {
- return envRootPath
- }
- return envPath
-}
-
// parseSecureCopy will parse a command and return if it's secure copy or not.
func parseSecureCopy(path string) (string, string, bool, error) {
parts := strings.Fields(path)
@@ -687,7 +620,7 @@ func parseSecureCopy(path string) (string, string, bool, error) {
func exitCode(err error) int {
// If no error occurred, return 0 (success).
if err == nil {
- return teleport.RemoteCommandSuccess
+ return reexecconstants.RemoteCommandSuccess
}
var execExitErr *exec.ExitError
@@ -697,7 +630,7 @@ func exitCode(err error) int {
case errors.As(err, &execExitErr):
waitStatus, ok := execExitErr.Sys().(syscall.WaitStatus)
if !ok {
- return teleport.RemoteCommandFailure
+ return reexecconstants.RemoteCommandFailure
}
return waitStatus.ExitStatus()
// Remote execution.
@@ -706,6 +639,98 @@ func exitCode(err error) int {
// An error occurred, but the type is unknown, return a generic 255 code.
default:
slog.DebugContext(context.Background(), "Unknown error returned when executing command", "error", err)
- return teleport.RemoteCommandFailure
+ return reexecconstants.RemoteCommandFailure
+ }
+}
+
+// ConfigureCommand creates a command fully configured to execute. This
+// function is used by Teleport to re-execute itself and pass whatever data
+// is need to the child to actually execute the shell.
+func ConfigureCommand(ctx *ServerContext, extraFiles ...*os.File) (*exec.Cmd, error) {
+ // Create a os.Pipe and start copying over the payload to execute. While the
+ // pipe buffer is quite large (64k) some users have run into the pipe
+ // blocking writes on much smaller buffers (7k) leading to Teleport being
+ // unable to run some exec commands.
+ //
+ // To not depend on the OS implementation of a pipe, instead the copy should
+ // be non-blocking. The io.Copy will be closed when either when the child
+ // process has fully read in the payload or the process exits with an error
+ // (and closes all child file descriptors).
+ //
+ // See the below for details.
+ //
+ // https://man7.org/linux/man-pages/man7/pipe.7.html
+ cmdmsg, err := ctx.ExecCommand()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ go copyCommand(ctx.CancelContext(), ctx.cmdw, cmdmsg)
+
+ // Find the Teleport executable and its directory on disk.
+ executable, err := os.Executable()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ // The channel/request type determines the subcommand to execute.
+ var subCommand string
+ switch ctx.ExecType {
+ case reexecconstants.NetworkingSubCommand:
+ subCommand = reexecconstants.NetworkingSubCommand
+ default:
+ subCommand = reexecconstants.ExecSubCommand
+ }
+
+ // Build the list of arguments to have Teleport re-exec itself. The "-d" flag
+ // is appended if Teleport is running in debug mode.
+ args := []string{executable, subCommand}
+
+ // build env for `teleport exec`
+ env := &envutils.SafeEnv{}
+ env.AddExecEnvironment()
+
+ // Build the "teleport exec" command.
+ cmd := &exec.Cmd{
+ Path: executable,
+ Args: args,
+ Env: *env,
+ ExtraFiles: []*os.File{
+ ctx.cmdr,
+ ctx.logw,
+ ctx.contr,
+ ctx.readyw,
+ ctx.killShellr,
+ },
+ }
+ // Add extra files if applicable.
+ if len(extraFiles) > 0 {
+ cmd.ExtraFiles = append(cmd.ExtraFiles, extraFiles...)
+ }
+
+ // Perform OS-specific tweaks to the command.
+ sessionreexec.CommandOSTweaks(cmd)
+
+ return cmd, nil
+}
+
+// copyCommand will copy the provided command to the child process over the
+// pipe attached to the context.
+func copyCommand(ctx context.Context, cmdw *os.File, cmdmsg *sessionreexec.ExecCommand) {
+ defer func() {
+ err := cmdw.Close()
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to close command pipe", "error", err)
+ }
+
+ // Set to nil so the close in the context doesn't attempt to re-close.
+ cmdw = nil
+ }()
+
+ // Write command bytes to pipe. The child process will read the command
+ // to execute from this pipe.
+ if err := json.NewEncoder(cmdw).Encode(cmdmsg); err != nil {
+ slog.ErrorContext(ctx, "Failed to copy command over pipe", "error", err)
+ return
}
}
diff --git a/lib/srv/exec_linux_test.go b/lib/srv/exec_linux_test.go
index 9fc70600d32..323854497af 100644
--- a/lib/srv/exec_linux_test.go
+++ b/lib/srv/exec_linux_test.go
@@ -31,11 +31,13 @@ import (
"testing"
"time"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1"
"github.com/gravitational/teleport/lib/utils/testutils"
"github.com/gravitational/teleport/session/host"
+ "github.com/gravitational/teleport/session/reexec"
)
func TestOSCommandPrep(t *testing.T) {
@@ -74,7 +76,7 @@ func TestOSCommandPrep(t *testing.T) {
require.NoError(t, os.Chown(tempHome, uid, -1))
expectedEnv := []string{
"LANG=en_US.UTF-8",
- getDefaultEnvPath(usr.Uid, defaultLoginDefsPath),
+ reexec.GetDefaultEnvPath(usr.Uid),
fmt.Sprintf("HOME=%s", usr.HomeDir),
fmt.Sprintf("USER=%s", username),
"SHELL=/bin/sh",
@@ -92,11 +94,11 @@ func TestOSCommandPrep(t *testing.T) {
// Empty command (simple shell).
execCmd, err := scx.ExecCommand()
require.NoError(t, err)
- execCmd.stdin = os.Stdin
- execCmd.stdout = os.Stdout
- execCmd.stderr = os.Stderr
+ execCmd.Stdin = os.Stdin
+ execCmd.Stdout = os.Stdout
+ execCmd.Stderr = os.Stderr
- cmd, err := buildCommand(execCmd, usr, nil)
+ cmd, err := reexec.BuildCommand(execCmd, usr, nil)
require.NoError(t, err)
require.NotNil(t, cmd)
@@ -110,11 +112,11 @@ func TestOSCommandPrep(t *testing.T) {
scx.execRequest.SetCommand("ls -lh /etc")
execCmd, err = scx.ExecCommand()
require.NoError(t, err)
- execCmd.stdin = os.Stdin
- execCmd.stdout = os.Stdout
- execCmd.stderr = os.Stderr
+ execCmd.Stdin = os.Stdin
+ execCmd.Stdout = os.Stdout
+ execCmd.Stderr = os.Stderr
- cmd, err = buildCommand(execCmd, usr, nil)
+ cmd, err = reexec.BuildCommand(execCmd, usr, nil)
require.NoError(t, err)
require.NotNil(t, cmd)
@@ -128,11 +130,11 @@ func TestOSCommandPrep(t *testing.T) {
scx.execRequest.SetCommand("top")
execCmd, err = scx.ExecCommand()
require.NoError(t, err)
- execCmd.stdin = os.Stdin
- execCmd.stdout = os.Stdout
- execCmd.stderr = os.Stderr
+ execCmd.Stdin = os.Stdin
+ execCmd.Stdout = os.Stdout
+ execCmd.Stderr = os.Stderr
- cmd, err = buildCommand(execCmd, usr, nil)
+ cmd, err = reexec.BuildCommand(execCmd, usr, nil)
require.NoError(t, err)
require.Equal(t, "/bin/sh", cmd.Path)
@@ -145,7 +147,7 @@ func TestOSCommandPrep(t *testing.T) {
usr.HomeDir = "/wrong/place"
root := string(os.PathSeparator)
expectedEnv[2] = "HOME=/wrong/place"
- cmd, err = buildCommand(execCmd, usr, nil)
+ cmd, err = reexec.BuildCommand(execCmd, usr, nil)
require.NoError(t, err)
require.Equal(t, root, cmd.Dir)
@@ -214,3 +216,13 @@ func TestContinue(t *testing.T) {
require.NoError(t, err)
}
}
+
+func changeHomeDir(t *testing.T, username, home string) {
+ usermodBin, err := exec.LookPath("usermod")
+ assert.NoError(t, err, "usermod binary must be present")
+
+ cmd := exec.Command(usermodBin, "--home", home, username)
+ _, err = cmd.CombinedOutput()
+ assert.NoError(t, err, "changing home should not error")
+ assert.Equal(t, 0, cmd.ProcessState.ExitCode(), "changing home should exit 0")
+}
diff --git a/lib/srv/exec_test.go b/lib/srv/exec_test.go
index d5b38453e38..d486ac99213 100644
--- a/lib/srv/exec_test.go
+++ b/lib/srv/exec_test.go
@@ -28,12 +28,13 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
- "github.com/gravitational/teleport"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/sshutils"
"github.com/gravitational/teleport/lib/utils/log/logtest"
+ "github.com/gravitational/teleport/session/reexec"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
)
// TestMain will re-execute Teleport to run a command if "exec" is passed to
@@ -43,8 +44,8 @@ func TestMain(m *testing.M) {
modules.SetInsecureTestMode(true)
// If the test is re-executing itself, execute the command that comes over
// the pipe.
- if IsReexec() {
- RunAndExit(os.Args[1])
+ if reexec.IsReexec() {
+ reexec.RunAndExit(os.Args[1])
return
}
@@ -91,21 +92,21 @@ func TestEmitExecAuditEvent(t *testing.T) {
inCommand: "exit 0",
inError: nil,
outCommand: "exit 0",
- outCode: strconv.Itoa(teleport.RemoteCommandSuccess),
+ outCode: strconv.Itoa(reexecconstants.RemoteCommandSuccess),
},
// Exited with error.
{
inCommand: "exit 255",
inError: fmt.Errorf("unknown error"),
outCommand: "exit 255",
- outCode: strconv.Itoa(teleport.RemoteCommandFailure),
+ outCode: strconv.Itoa(reexecconstants.RemoteCommandFailure),
},
// Command injection.
{
inCommand: "/bin/teleport scp --remote-addr=127.0.0.1:50862 --local-addr=127.0.0.1:54895 -f ~/file.txt && touch /tmp/new.txt",
inError: fmt.Errorf("unknown error"),
outCommand: "/bin/teleport scp --remote-addr=127.0.0.1:50862 --local-addr=127.0.0.1:54895 -f ~/file.txt && touch /tmp/new.txt",
- outCode: strconv.Itoa(teleport.RemoteCommandFailure),
+ outCode: strconv.Itoa(reexecconstants.RemoteCommandFailure),
},
}
for _, tt := range tests {
@@ -125,17 +126,6 @@ func TestEmitExecAuditEvent(t *testing.T) {
}
}
-func TestLoginDefsParser(t *testing.T) {
- t.Parallel()
-
- expectedEnvSuPath := "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/bar"
- expectedSuPath := "PATH=/usr/local/bin:/usr/bin:/bin:/foo"
-
- require.Equal(t, expectedEnvSuPath, getDefaultEnvPath("0", "../../fixtures/login.defs"))
- require.Equal(t, expectedSuPath, getDefaultEnvPath("1000", "../../fixtures/login.defs"))
- require.Equal(t, defaultEnvPath, getDefaultEnvPath("1000", "bad/file"))
-}
-
func newExecServerContext(t *testing.T, srv Server) *ServerContext {
scx := newTestServerContext(t, srv, nil, nil)
diff --git a/lib/srv/forward/sshserver.go b/lib/srv/forward/sshserver.go
index 61511534729..6f0b1c86e3d 100644
--- a/lib/srv/forward/sshserver.go
+++ b/lib/srv/forward/sshserver.go
@@ -60,6 +60,7 @@ import (
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/session/networking/x11"
"github.com/gravitational/teleport/session/pam/pamcfg"
+ "github.com/gravitational/teleport/session/reexec"
)
// Server is a forwarding server. Server is used to create a single in-memory
@@ -568,7 +569,7 @@ func (s *Server) GetLockWatcher() *services.LockWatcher {
// does not spawn child processes.
func (s *Server) ChildLogConfig() srv.ChildLogConfig {
return srv.ChildLogConfig{
- ExecLogConfig: srv.ExecLogConfig{},
+ ExecLogConfig: reexec.ExecLogConfig{},
Writer: io.Discard,
}
}
diff --git a/lib/srv/git/forward.go b/lib/srv/git/forward.go
index 3c7cb3e3408..e861fe93efd 100644
--- a/lib/srv/git/forward.go
+++ b/lib/srv/git/forward.go
@@ -48,6 +48,7 @@ import (
"github.com/gravitational/teleport/lib/utils"
logutils "github.com/gravitational/teleport/lib/utils/log"
"github.com/gravitational/teleport/session/pam/pamcfg"
+ "github.com/gravitational/teleport/session/reexec"
)
var (
@@ -765,7 +766,7 @@ func (s *ForwardServer) GetSELinuxEnabled() bool {
// does not spawn child processes.
func (s *ForwardServer) ChildLogConfig() srv.ChildLogConfig {
return srv.ChildLogConfig{
- ExecLogConfig: srv.ExecLogConfig{},
+ ExecLogConfig: reexec.ExecLogConfig{},
Writer: io.Discard,
}
}
diff --git a/lib/srv/mock_test.go b/lib/srv/mock_test.go
index dd2b088b834..648c21fcea6 100644
--- a/lib/srv/mock_test.go
+++ b/lib/srv/mock_test.go
@@ -55,6 +55,7 @@ import (
"github.com/gravitational/teleport/lib/utils/clocki"
"github.com/gravitational/teleport/lib/utils/log/logtest"
"github.com/gravitational/teleport/session/pam/pamcfg"
+ "github.com/gravitational/teleport/session/reexec"
)
func newTestServerContext(t *testing.T, srv Server, sessionJoiningRoleSet services.RoleSet, accessPermit *decisionpb.SSHAccessPermit) *ServerContext {
@@ -348,7 +349,7 @@ func (m *mockServer) GetSELinuxEnabled() bool {
// ChildLogConfig returns a noop log configuration.
func (m *mockServer) ChildLogConfig() ChildLogConfig {
return ChildLogConfig{
- ExecLogConfig: ExecLogConfig{
+ ExecLogConfig: reexec.ExecLogConfig{
Level: slog.LevelDebug,
Format: "json",
},
diff --git a/lib/srv/reexec_inline.go b/lib/srv/reexec_inline.go
new file mode 100644
index 00000000000..1f2e0e3d0e1
--- /dev/null
+++ b/lib/srv/reexec_inline.go
@@ -0,0 +1,26 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package srv
+
+import (
+ "github.com/gravitational/teleport/session/reexec"
+)
+
+//go:fix inline
+func IsReexec() bool {
+ return reexec.IsReexec()
+}
diff --git a/lib/srv/reexec_test.go b/lib/srv/reexec_test.go
index 46edbfdaaca..a5b2f3ceea7 100644
--- a/lib/srv/reexec_test.go
+++ b/lib/srv/reexec_test.go
@@ -29,168 +29,23 @@ import (
"net/http"
"net/http/httptest"
"os"
- "os/exec"
"os/user"
"path/filepath"
- "strconv"
"syscall"
"testing"
- "github.com/gravitational/trace"
- "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh/agent"
- "github.com/gravitational/teleport"
decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1"
- "github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/sshagent"
"github.com/gravitational/teleport/lib/utils/testutils"
"github.com/gravitational/teleport/session/host"
"github.com/gravitational/teleport/session/networking"
"github.com/gravitational/teleport/session/networking/x11"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
)
-type stubUser struct {
- gid string
- uid string
- groupIDS []string
-}
-
-func (s *stubUser) GID() string {
- return s.gid
-}
-
-func (s *stubUser) UID() string {
- return s.uid
-}
-
-func (s *stubUser) GroupIds() ([]string, error) {
- return s.groupIDS, nil
-}
-
-func TestStartNewParker(t *testing.T) {
- currentUser, err := user.Current()
- require.NoError(t, err)
- currentUID, err := strconv.ParseUint(currentUser.Uid, 10, 32)
- require.NoError(t, err)
- currentGID, err := strconv.ParseUint(currentUser.Gid, 10, 32)
- require.NoError(t, err)
-
- t.Parallel()
-
- type args struct {
- credential *syscall.Credential
- loginAsUser string
- localUser *stubUser
- }
- tests := []struct {
- name string
- args args
- newOsPack func(t *testing.T) (*osWrapper, func())
- wantErr require.ErrorAssertionFunc
- }{
- {
- name: "empty credentials does nothing",
- wantErr: require.NoError,
- newOsPack: func(t *testing.T) (*osWrapper, func()) {
- return &osWrapper{}, func() {}
- },
- },
- {
- name: "missing Teleport group returns no error",
- wantErr: require.NoError,
- newOsPack: func(t *testing.T) (*osWrapper, func()) {
- return &osWrapper{
- LookupGroup: func(name string) (*user.Group, error) {
- require.Equal(t, types.TeleportDropGroup, name)
- return nil, user.UnknownGroupError(types.TeleportDropGroup)
- },
- }, func() {}
- },
- },
- {
- name: "different group doesn't start parker",
- wantErr: require.NoError,
- newOsPack: func(t *testing.T) (*osWrapper, func()) {
- return &osWrapper{
- LookupGroup: func(name string) (*user.Group, error) {
- require.Equal(t, types.TeleportDropGroup, name)
- return &user.Group{Gid: "1234"}, nil
- },
- CommandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd {
- require.FailNow(t, "CommandContext should not be called")
- return nil
- },
- }, func() {}
- },
- args: args{
- credential: &syscall.Credential{Gid: 1000},
- localUser: &stubUser{
- uid: "1001",
- gid: "1003",
- groupIDS: []string{"1003"},
- },
- },
- },
- {
- name: "parker is started",
- wantErr: require.NoError,
- newOsPack: func(t *testing.T) (*osWrapper, func()) {
- parkerStarted := false
-
- return &osWrapper{
- LookupGroup: func(name string) (*user.Group, error) {
- require.Equal(t, types.TeleportDropGroup, name)
- return &user.Group{Gid: currentUser.Gid}, nil
- },
- CommandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd {
- require.NotNil(t, ctx)
- require.Len(t, arg, 1)
- require.Equal(t, teleport.ParkSubCommand, arg[0])
- parkerStarted = true
- return exec.CommandContext(ctx, name, arg...)
- },
- LookupUser: func(username string) (*user.User, error) {
- return &user.User{Uid: currentUser.Uid, Gid: currentUser.Gid}, nil
- },
- }, func() {
- require.True(t, parkerStarted, "parker process didn't start")
- }
- },
- args: args{
- credential: &syscall.Credential{
- Uid: uint32(currentUID),
- Gid: uint32(currentGID),
- // Changing to false causes "fork/exec /proc/self/exe: operation not permitted"
- // to be returned when creating the park process.
- NoSetGroups: true,
- },
- localUser: &stubUser{
- uid: currentUser.Uid,
- gid: currentUser.Gid,
- groupIDS: []string{currentUser.Gid},
- },
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- osPack, assertExpected := tt.newOsPack(t)
-
- ctx, cancel := context.WithCancel(context.Background())
- t.Cleanup(cancel) // cancel to stop the park process.
-
- err := osPack.startNewParker(ctx, tt.args.credential, tt.args.loginAsUser, tt.args.localUser)
- tt.wantErr(t, err, fmt.Sprintf("startNewParker(%v, %+v, %v, %+v)", ctx, tt.args.credential, tt.args.loginAsUser, tt.args.localUser))
-
- assertExpected()
- })
- }
-}
-
func newHTTPTestServer(t *testing.T, listener net.Listener) *httptest.Server {
var err error
if listener == nil {
@@ -235,7 +90,7 @@ func testNetworkingCommand(t *testing.T, login string) {
srv := newMockServer(t)
scx := newTestServerContext(t, srv, nil, &decisionpb.SSHAccessPermit{})
- scx.ExecType = teleport.NetworkingSubCommand
+ scx.ExecType = reexecconstants.NetworkingSubCommand
if login != "" {
scx.Identity.Login = login
}
@@ -412,152 +267,3 @@ func testX11Forward(ctx context.Context, t *testing.T, proc *networking.Process,
require.NoError(t, err)
require.Equal(t, fakeXauthEntry, readXauthEntry)
}
-
-func TestRootCheckHomeDir(t *testing.T) {
- testutils.RequireRoot(t)
-
- // this test manipulates global state, ensure we're not going to run it in
- // parallel with something else
- t.Setenv("foo", "bar")
-
- tmp := t.TempDir()
- require.NoError(t, os.Chmod(filepath.Dir(tmp), 0777))
- require.NoError(t, os.Chmod(tmp, 0777))
-
- home := filepath.Join(tmp, "home")
- noAccess := filepath.Join(tmp, "no_access")
- file := filepath.Join(tmp, "file")
- notFound := filepath.Join(tmp, "not_found")
-
- require.NoError(t, os.Mkdir(home, 0700))
- require.NoError(t, os.Mkdir(noAccess, 0700))
- _, err := os.Create(file)
- require.NoError(t, err)
-
- login := testutils.GenerateLocalUsername(t)
- _, err = host.UserAdd(login, nil, host.UserOpts{Home: home})
- require.NoError(t, err)
- t.Cleanup(func() {
- // change back to accessible home so deletion works
- changeHomeDir(t, login, home)
- _, err := host.UserDel(login)
- require.NoError(t, err)
- })
-
- testUser, err := user.Lookup(login)
- require.NoError(t, err)
-
- uid, err := strconv.Atoi(testUser.Uid)
- require.NoError(t, err)
-
- gid, err := strconv.Atoi(testUser.Gid)
- require.NoError(t, err)
-
- require.NoError(t, os.Chown(home, uid, gid))
- require.NoError(t, os.Chown(file, uid, gid))
-
- hasAccess, err := CheckHomeDir(testUser)
- require.NoError(t, err)
- require.True(t, hasAccess)
-
- changeHomeDir(t, login, file)
- hasAccess, err = CheckHomeDir(testUser)
- require.NoError(t, err)
- require.False(t, hasAccess)
-
- changeHomeDir(t, login, notFound)
- hasAccess, err = CheckHomeDir(testUser)
- require.NoError(t, err)
- require.False(t, hasAccess)
-
- changeHomeDir(t, login, noAccess)
- hasAccess, err = CheckHomeDir(testUser)
- require.NoError(t, err)
- require.False(t, hasAccess)
-}
-
-func changeHomeDir(t *testing.T, username, home string) {
- usermodBin, err := exec.LookPath("usermod")
- assert.NoError(t, err, "usermod binary must be present")
-
- cmd := exec.Command(usermodBin, "--home", home, username)
- _, err = cmd.CombinedOutput()
- assert.NoError(t, err, "changing home should not error")
- assert.Equal(t, 0, cmd.ProcessState.ExitCode(), "changing home should exit 0")
-}
-
-func TestRootOpenFileAsUser(t *testing.T) {
- testutils.RequireRoot(t)
- euid := os.Geteuid()
- egid := os.Getegid()
-
- username := "processing-user"
-
- arg := os.Args[1]
- os.Args[1] = teleport.ExecSubCommand
- defer func() {
- os.Args[1] = arg
- }()
-
- _, err := host.UserAdd(username, nil, host.UserOpts{})
- require.NoError(t, err)
-
- t.Cleanup(func() {
- _, err := host.UserDel(username)
- require.NoError(t, err)
- })
-
- tmp := t.TempDir()
- testFile := filepath.Join(tmp, "testfile")
- fileContent := "one does not simply open without permission"
-
- err = os.WriteFile(testFile, []byte(fileContent), 0777)
- require.NoError(t, err)
-
- testUser, err := user.Lookup(username)
- require.NoError(t, err)
-
- // no access
- file, err := openFileAsUser(testUser, testFile)
- require.True(t, trace.IsAccessDenied(err))
- require.Nil(t, file)
-
- // ensure we fallback to root after
- file, err = os.Open(testFile)
- require.NoError(t, err)
- require.NotNil(t, file)
- file.Close()
-
- // has access
- uid, err := strconv.Atoi(testUser.Uid)
- require.NoError(t, err)
-
- gid, err := strconv.Atoi(testUser.Gid)
- require.NoError(t, err)
-
- err = os.Chown(filepath.Dir(tmp), uid, gid)
- require.NoError(t, err)
-
- err = os.Chown(tmp, uid, gid)
- require.NoError(t, err)
-
- err = os.Chown(testFile, uid, gid)
- require.NoError(t, err)
-
- file, err = openFileAsUser(testUser, testFile)
- require.NoError(t, err)
- require.NotNil(t, file)
-
- data, err := io.ReadAll(file)
- file.Close()
- require.NoError(t, err)
- require.Equal(t, fileContent, string(data))
-
- // not exist
- file, err = openFileAsUser(testUser, filepath.Join(tmp, "no_exist"))
- require.ErrorIs(t, err, os.ErrNotExist)
- require.Nil(t, file)
-
- require.Equal(t, euid, os.Geteuid())
- require.Equal(t, egid, os.Getegid())
-}
diff --git a/lib/srv/regular/get_home_dir.go b/lib/srv/regular/get_home_dir.go
index d87483faddb..a2abbeaea27 100644
--- a/lib/srv/regular/get_home_dir.go
+++ b/lib/srv/regular/get_home_dir.go
@@ -29,6 +29,7 @@ import (
"golang.org/x/crypto/ssh"
"github.com/gravitational/teleport/lib/srv"
+ "github.com/gravitational/teleport/session/reexec"
)
type homeDirSubsys struct {
@@ -50,7 +51,7 @@ func (h *homeDirSubsys) Start(_ context.Context, serverConn *ssh.ServerConn, ch
return trace.Wrap(err)
}
- exists, err := srv.CheckHomeDir(localUser)
+ exists, err := reexec.CheckHomeDir(localUser)
if err != nil {
return trace.Wrap(err)
}
diff --git a/lib/srv/regular/sftp.go b/lib/srv/regular/sftp.go
index 7ddcb12f381..94a2606a856 100644
--- a/lib/srv/regular/sftp.go
+++ b/lib/srv/regular/sftp.go
@@ -41,6 +41,7 @@ import (
"github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/sshutils/reexec"
"github.com/gravitational/teleport/lib/utils"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
)
type sftpSubsys struct {
@@ -108,7 +109,7 @@ func (s *sftpSubsys) Start(ctx context.Context,
defer auditPipeIn.Close()
// Create child process to handle SFTP connection
- execRequest, err := srv.NewExecRequest(serverCtx, teleport.SFTPSubCommand)
+ execRequest, err := srv.NewExecRequest(serverCtx, reexecconstants.SFTPSubCommand)
if err != nil {
return trace.Wrap(err)
}
diff --git a/lib/srv/regular/sshserver.go b/lib/srv/regular/sshserver.go
index bccb28e0e2b..36ed78f0e31 100644
--- a/lib/srv/regular/sshserver.go
+++ b/lib/srv/regular/sshserver.go
@@ -76,6 +76,8 @@ import (
"github.com/gravitational/teleport/session/networking"
"github.com/gravitational/teleport/session/networking/x11"
"github.com/gravitational/teleport/session/pam/pamcfg"
+ "github.com/gravitational/teleport/session/reexec"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
)
// Server implements SSH server that uses configuration backend and
@@ -370,7 +372,7 @@ func (s *Server) ChildLogConfig() srv.ChildLogConfig {
// return a noop log configuration
return srv.ChildLogConfig{
- ExecLogConfig: srv.ExecLogConfig{},
+ ExecLogConfig: reexec.ExecLogConfig{},
Writer: io.Discard,
}
}
@@ -824,7 +826,7 @@ func SetImmutableLabels(labels map[string]string) ServerOption {
func SetChildLogConfig(cfg *servicecfg.Config) ServerOption {
return func(s *Server) error {
s.childLogConfig = &srv.ChildLogConfig{
- ExecLogConfig: srv.ExecLogConfig{
+ ExecLogConfig: reexec.ExecLogConfig{
Level: cfg.LoggerLevel.Level(),
Format: strings.ToLower(cfg.LogConfig.Format),
ExtraFields: cfg.LogConfig.ExtraFields,
@@ -1303,7 +1305,7 @@ func (s *Server) startNetworkingProcess(scx *srv.ServerContext) (*networking.Pro
return nil, trace.Wrap(err)
}
nsctx.SessionRecordingConfig.SetMode(types.RecordOff)
- nsctx.ExecType = teleport.NetworkingSubCommand
+ nsctx.ExecType = reexecconstants.NetworkingSubCommand
scx.Parent().AddCloser(nsctx)
// Create command to re-exec Teleport which will handle networking requests. The
diff --git a/lib/srv/regular/sshserver_test.go b/lib/srv/regular/sshserver_test.go
index 422b67fd296..7b90bd82170 100644
--- a/lib/srv/regular/sshserver_test.go
+++ b/lib/srv/regular/sshserver_test.go
@@ -86,6 +86,7 @@ import (
"github.com/gravitational/teleport/lib/utils/log/logtest"
"github.com/gravitational/teleport/session/networking/x11"
"github.com/gravitational/teleport/session/pam/pamcfg"
+ "github.com/gravitational/teleport/session/reexec"
)
// teleportTestUser is additional user used for tests
@@ -101,8 +102,8 @@ var wildcardAllow = types.Labels{
func TestMain(m *testing.M) {
logtest.InitLogger(testing.Verbose)
modules.SetInsecureTestMode(true)
- if srv.IsReexec() {
- srv.RunAndExit(os.Args[1])
+ if reexec.IsReexec() {
+ reexec.RunAndExit(os.Args[1])
return
}
@@ -198,7 +199,7 @@ func (f *sshTestFixture) newSSHClient(ctx context.Context, t testing.TB, user *u
func setChildLogConfigForTest() ServerOption {
return func(s *Server) error {
s.childLogConfig = &srv.ChildLogConfig{
- ExecLogConfig: srv.ExecLogConfig{
+ ExecLogConfig: reexec.ExecLogConfig{
Level: slog.LevelDebug,
},
Writer: os.Stderr,
diff --git a/lib/srv/regular/sshserver_unix_test.go b/lib/srv/regular/sshserver_unix_test.go
index 3495f986804..d025bbd6782 100644
--- a/lib/srv/regular/sshserver_unix_test.go
+++ b/lib/srv/regular/sshserver_unix_test.go
@@ -36,6 +36,7 @@ import (
"github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/utils/testutils"
"github.com/gravitational/teleport/session/host"
+ "github.com/gravitational/teleport/session/reexec"
)
// BenchmarkRootExecCommand measures performance of running multiple exec requests
@@ -70,7 +71,7 @@ func BenchmarkRootExecCommand(b *testing.B) {
// Re-enable once we have a better way to benchmark with logging enabled.
func(s *Server) error {
s.childLogConfig = &srv.ChildLogConfig{
- ExecLogConfig: srv.ExecLogConfig{
+ ExecLogConfig: reexec.ExecLogConfig{
Level: slog.LevelError,
},
Writer: io.Discard,
diff --git a/lib/srv/term.go b/lib/srv/term.go
index 9a681bbf65f..00404f934de 100644
--- a/lib/srv/term.go
+++ b/lib/srv/term.go
@@ -41,6 +41,11 @@ import (
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
rsession "github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/sshutils/reexec"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
+)
+
+const (
+ defaultTerm = "xterm"
)
// LookupUser is used to mock the value returned by user.Lookup(string).
@@ -197,8 +202,9 @@ func (t *terminal) AddParty(delta int) {
// Replace \n with \r\n so the message is correctly aligned.
var crlfReplacer = strings.NewReplacer("\r\n", "\r\n", "\n", "\r\n")
-// Run will run the terminal. If the shell fails to start due to a [teleport.RemoteCommandFailure],
-// the error will be written to the given error writer.
+// Run will run the terminal. If the shell fails to start due to a
+// [reexecconstants.RemoteCommandFailure], the error will be written to the
+// given error writer.
func (t *terminal) Run(ctx context.Context, errorWriter io.Writer) error {
select {
case <-ctx.Done():
@@ -651,13 +657,13 @@ func (t *remoteTerminal) Wait() (*ExecResult, error) {
}
return &ExecResult{
- Code: teleport.RemoteCommandFailure,
+ Code: reexecconstants.RemoteCommandFailure,
Command: execRequest.GetCommand(),
}, err
}
return &ExecResult{
- Code: teleport.RemoteCommandSuccess,
+ Code: reexecconstants.RemoteCommandSuccess,
Command: execRequest.GetCommand(),
}, nil
}
diff --git a/lib/srv/usermgmt.go b/lib/srv/usermgmt.go
index 93abbdc286d..99f65235b6b 100644
--- a/lib/srv/usermgmt.go
+++ b/lib/srv/usermgmt.go
@@ -36,6 +36,7 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
+ apiconstants "github.com/gravitational/teleport/api/constants"
decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1"
"github.com/gravitational/teleport/api/types"
apiutils "github.com/gravitational/teleport/api/utils"
@@ -157,7 +158,7 @@ type userCloser struct {
}
func (u *userCloser) Close() error {
- teleportGroup, err := u.backend.LookupGroup(types.TeleportDropGroup)
+ teleportGroup, err := u.backend.LookupGroup(apiconstants.TeleportDropGroup)
if err != nil {
return trace.Wrap(err)
}
@@ -301,7 +302,7 @@ func (u *HostUserManagement) updateUser(hostUser HostUser, ui *decisionpb.HostUs
)
if ui.Mode == decisionpb.HostUserMode_HOST_USER_MODE_KEEP {
- _, hasKeepGroup := hostUser.Groups[types.TeleportKeepGroup]
+ _, hasKeepGroup := hostUser.Groups[apiconstants.TeleportKeepGroup]
if !hasKeepGroup {
home, err := u.backend.GetDefaultHomeDirectory(hostUser.Name)
if err != nil {
@@ -580,17 +581,17 @@ func isUnknownGroupError(err error, groupName string) bool {
strings.HasSuffix(err.Error(), syscall.ESRCH.Error())
}
-// DeleteAllUsers removes all temporary users in the [types.TeleportDropGroup]
+// DeleteAllUsers removes all temporary users in the [apiconstants.TeleportDropGroup]
// without any active sessions.
func (u *HostUserManagement) DeleteAllUsers() error {
users, err := u.backend.GetAllUsers()
if err != nil {
return trace.Wrap(err)
}
- teleportGroup, err := u.backend.LookupGroup(types.TeleportDropGroup)
+ teleportGroup, err := u.backend.LookupGroup(apiconstants.TeleportDropGroup)
if err != nil {
- if isUnknownGroupError(err, types.TeleportDropGroup) {
- u.log.DebugContext(u.ctx, "Target group not found, not deleting users", "group", types.TeleportDropGroup)
+ if isUnknownGroupError(err, apiconstants.TeleportDropGroup) {
+ u.log.DebugContext(u.ctx, "Target group not found, not deleting users", "group", apiconstants.TeleportDropGroup)
return nil
}
return trace.Wrap(err)
@@ -747,23 +748,23 @@ func ResolveGroups(logger *slog.Logger, hostUser *HostUser, ui *decisionpb.HostU
}
// because teleport-keep migration requires adding the group to host_groups, we need to note that before wiping the teleport system groups
- _, hasExplicitKeepGroup := groups[types.TeleportKeepGroup]
+ _, hasExplicitKeepGroup := groups[apiconstants.TeleportKeepGroup]
// only one teleport system group should be resolved for a given user, so we remove any of them that might occur within the configured host
// groups since we'll compute the correct group below
- delete(groups, types.TeleportKeepGroup)
- delete(groups, types.TeleportDropGroup)
- delete(groups, types.TeleportStaticGroup)
+ delete(groups, apiconstants.TeleportKeepGroup)
+ delete(groups, apiconstants.TeleportDropGroup)
+ delete(groups, apiconstants.TeleportStaticGroup)
// if we assign a teleport group, it will always coincide with the mode we're currently in, so we can compute it right away
teleportGroup := ""
switch ui.Mode {
case decisionpb.HostUserMode_HOST_USER_MODE_DROP:
- teleportGroup = types.TeleportDropGroup
+ teleportGroup = apiconstants.TeleportDropGroup
case decisionpb.HostUserMode_HOST_USER_MODE_KEEP:
- teleportGroup = types.TeleportKeepGroup
+ teleportGroup = apiconstants.TeleportKeepGroup
case decisionpb.HostUserMode_HOST_USER_MODE_STATIC:
- teleportGroup = types.TeleportStaticGroup
+ teleportGroup = apiconstants.TeleportStaticGroup
}
log := logger.With("teleport_group", teleportGroup)
@@ -774,14 +775,14 @@ func ResolveGroups(logger *slog.Logger, hostUser *HostUser, ui *decisionpb.HostU
// 2. We reconcile an existing managed user
// 3. We migrate an existing unmanaged user
// functionally, there's no difference between 2 and 3 so if we check against all failure modes we can handle all other cases at once
- _, hasDropGroup := hostUser.Groups[types.TeleportDropGroup]
- _, hasKeepGroup := hostUser.Groups[types.TeleportKeepGroup]
+ _, hasDropGroup := hostUser.Groups[apiconstants.TeleportDropGroup]
+ _, hasKeepGroup := hostUser.Groups[apiconstants.TeleportKeepGroup]
migrateStaticUser := takeOwnership && ui.Mode == decisionpb.HostUserMode_HOST_USER_MODE_STATIC
migrateKeepUser := hasExplicitKeepGroup && ui.Mode == decisionpb.HostUserMode_HOST_USER_MODE_KEEP
managedUser := hasKeepGroup || hasDropGroup
- _, staticUser := hostUser.Groups[types.TeleportStaticGroup]
+ _, staticUser := hostUser.Groups[apiconstants.TeleportStaticGroup]
inStaticMode := ui.Mode == decisionpb.HostUserMode_HOST_USER_MODE_STATIC
if (inStaticMode && managedUser) || (!inStaticMode && staticUser) {
diff --git a/lib/srv/usermgmt_test.go b/lib/srv/usermgmt_test.go
index 1cbee66636d..cbcb63151df 100644
--- a/lib/srv/usermgmt_test.go
+++ b/lib/srv/usermgmt_test.go
@@ -30,8 +30,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ apiconstants "github.com/gravitational/teleport/api/constants"
decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1"
- "github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/backend/memory"
"github.com/gravitational/teleport/lib/services/local"
"github.com/gravitational/teleport/lib/utils/log/logtest"
@@ -248,7 +248,7 @@ func TestUserMgmt_CreateTemporaryUser(t *testing.T) {
// temporary users must always include the teleport-service group
require.ElementsMatch(t, []string{
- "hello", "sudo", types.TeleportDropGroup,
+ "hello", "sudo", apiconstants.TeleportDropGroup,
}, backend.users["bob"])
// try create the same user again
@@ -315,7 +315,7 @@ func TestUserMgmtSudoers_CreateTemporaryUser(t *testing.T) {
Mode: decisionpb.HostUserMode_HOST_USER_MODE_DROP,
})
require.ErrorIs(t, err, errUnmanagedUser)
- backend.CreateGroup(types.TeleportDropGroup, "")
+ backend.CreateGroup(apiconstants.TeleportDropGroup, "")
_, err = users.UpsertUser("testuser", &decisionpb.HostUsersInfo{
Mode: decisionpb.HostUserMode_HOST_USER_MODE_DROP,
})
@@ -332,8 +332,8 @@ func TestUserMgmt_DeleteAllTeleportSystemUsers(t *testing.T) {
}
usersDB := []userAndGroups{
- {"fgh", []string{types.TeleportDropGroup}},
- {"xyz", []string{types.TeleportDropGroup}},
+ {"fgh", []string{apiconstants.TeleportDropGroup}},
+ {"xyz", []string{apiconstants.TeleportDropGroup}},
{"pqr", []string{"not-deleted"}},
{"abc", []string{"not-deleted"}},
}
@@ -355,7 +355,7 @@ func TestUserMgmt_DeleteAllTeleportSystemUsers(t *testing.T) {
for _, group := range user.groups {
mgmt.CreateGroup(group, "")
}
- if slices.Contains(user.groups, types.TeleportDropGroup) {
+ if slices.Contains(user.groups, apiconstants.TeleportDropGroup) {
users.UpsertUser(user.user, &decisionpb.HostUsersInfo{
Groups: user.groups,
Mode: decisionpb.HostUserMode_HOST_USER_MODE_DROP,
@@ -442,8 +442,8 @@ func Test_UpdateUserGroups_Keep(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, nil, closer)
assert.Zero(t, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportKeepGroup), backend.users["alice"])
- assert.NotContains(t, backend.users["alice"], types.TeleportDropGroup)
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportKeepGroup), backend.users["alice"])
+ assert.NotContains(t, backend.users["alice"], apiconstants.TeleportDropGroup)
// Update user with new groups.
userinfo.Groups = slices.Clone(allGroups[2:])
@@ -452,16 +452,16 @@ func Test_UpdateUserGroups_Keep(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportKeepGroup), backend.users["alice"])
- assert.NotContains(t, backend.users["alice"], types.TeleportDropGroup)
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportKeepGroup), backend.users["alice"])
+ assert.NotContains(t, backend.users["alice"], apiconstants.TeleportDropGroup)
// Upsert again with same groups should not call UpdateUser.
closer, err = users.UpsertUser("alice", &userinfo)
assert.NoError(t, err)
assert.Equal(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportKeepGroup), backend.users["alice"])
- assert.NotContains(t, backend.users["alice"], types.TeleportDropGroup)
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportKeepGroup), backend.users["alice"])
+ assert.NotContains(t, backend.users["alice"], apiconstants.TeleportDropGroup)
// Do not convert the managed user to static.
userinfo.Mode = decisionpb.HostUserMode_HOST_USER_MODE_STATIC
@@ -469,7 +469,7 @@ func Test_UpdateUserGroups_Keep(t *testing.T) {
assert.ErrorIs(t, err, errStaticConversion)
assert.Equal(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportKeepGroup), backend.users["alice"])
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportKeepGroup), backend.users["alice"])
// Updates with INSECURE_DROP mode should convert the managed user
userinfo.Mode = decisionpb.HostUserMode_HOST_USER_MODE_DROP
@@ -478,8 +478,8 @@ func Test_UpdateUserGroups_Keep(t *testing.T) {
assert.NoError(t, err)
assert.NotEqual(t, nil, closer)
assert.Equal(t, 2, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportDropGroup), backend.users["alice"])
- assert.NotContains(t, backend.users["alice"], types.TeleportKeepGroup)
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportDropGroup), backend.users["alice"])
+ assert.NotContains(t, backend.users["alice"], apiconstants.TeleportKeepGroup)
}
func Test_UpdateUserGroups_Drop(t *testing.T) {
@@ -498,8 +498,8 @@ func Test_UpdateUserGroups_Drop(t *testing.T) {
assert.NoError(t, err)
assert.NotEqual(t, nil, closer)
assert.Zero(t, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportDropGroup), backend.users["alice"])
- assert.NotContains(t, backend.users["alice"], types.TeleportKeepGroup)
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportDropGroup), backend.users["alice"])
+ assert.NotContains(t, backend.users["alice"], apiconstants.TeleportKeepGroup)
// Update user with new groups.
userinfo.Groups = slices.Clone(allGroups[2:])
@@ -508,16 +508,16 @@ func Test_UpdateUserGroups_Drop(t *testing.T) {
assert.NoError(t, err)
assert.NotEqual(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportDropGroup), backend.users["alice"])
- assert.NotContains(t, backend.users["alice"], types.TeleportKeepGroup)
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportDropGroup), backend.users["alice"])
+ assert.NotContains(t, backend.users["alice"], apiconstants.TeleportKeepGroup)
// Upsert again with same groups should not call SetUserGroups.
closer, err = users.UpsertUser("alice", &userinfo)
assert.NoError(t, err)
assert.NotEqual(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportDropGroup), backend.users["alice"])
- assert.NotContains(t, backend.users["alice"], types.TeleportKeepGroup)
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportDropGroup), backend.users["alice"])
+ assert.NotContains(t, backend.users["alice"], apiconstants.TeleportKeepGroup)
// Do not convert the managed user to static.
userinfo.Mode = decisionpb.HostUserMode_HOST_USER_MODE_STATIC
@@ -525,7 +525,7 @@ func Test_UpdateUserGroups_Drop(t *testing.T) {
assert.ErrorIs(t, err, errStaticConversion)
assert.Equal(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportDropGroup), backend.users["alice"])
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportDropGroup), backend.users["alice"])
// Updates with KEEP mode should convert the ephemeral user
userinfo.Mode = decisionpb.HostUserMode_HOST_USER_MODE_KEEP
@@ -535,8 +535,8 @@ func Test_UpdateUserGroups_Drop(t *testing.T) {
assert.Equal(t, nil, closer)
assert.Equal(t, 2, backend.updateUserCalls)
assert.Equal(t, 1, backend.createHomeDirectoryCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportKeepGroup), backend.users["alice"])
- assert.NotContains(t, backend.users["alice"], types.TeleportDropGroup)
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportKeepGroup), backend.users["alice"])
+ assert.NotContains(t, backend.users["alice"], apiconstants.TeleportDropGroup)
}
func Test_UpdateUserGroups_Static(t *testing.T) {
@@ -554,7 +554,7 @@ func Test_UpdateUserGroups_Static(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, nil, closer)
assert.Zero(t, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportStaticGroup), backend.users["alice"])
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportStaticGroup), backend.users["alice"])
// Update user with new groups.
userinfo.Groups = slices.Clone(allGroups[2:])
@@ -562,14 +562,14 @@ func Test_UpdateUserGroups_Static(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportStaticGroup), backend.users["alice"])
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportStaticGroup), backend.users["alice"])
// Upsert again with same groups should not call SetUserGroups.
closer, err = users.UpsertUser("alice", &userinfo)
assert.NoError(t, err)
assert.Equal(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportStaticGroup), backend.users["alice"])
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportStaticGroup), backend.users["alice"])
// Do not convert to KEEP.
userinfo.Mode = decisionpb.HostUserMode_HOST_USER_MODE_KEEP
@@ -577,7 +577,7 @@ func Test_UpdateUserGroups_Static(t *testing.T) {
assert.ErrorIs(t, err, errStaticConversion)
assert.Equal(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(slices.Clone(allGroups[2:]), types.TeleportStaticGroup), backend.users["alice"])
+ assert.ElementsMatch(t, append(slices.Clone(allGroups[2:]), apiconstants.TeleportStaticGroup), backend.users["alice"])
// Do not convert to INSECURE_DROP.
userinfo.Mode = decisionpb.HostUserMode_HOST_USER_MODE_DROP
@@ -585,7 +585,7 @@ func Test_UpdateUserGroups_Static(t *testing.T) {
assert.ErrorIs(t, err, errStaticConversion)
assert.Equal(t, nil, closer)
assert.Equal(t, 1, backend.updateUserCalls)
- assert.ElementsMatch(t, append(slices.Clone(allGroups[2:]), types.TeleportStaticGroup), backend.users["alice"])
+ assert.ElementsMatch(t, append(slices.Clone(allGroups[2:]), apiconstants.TeleportStaticGroup), backend.users["alice"])
}
func Test_DontManageExistingUser(t *testing.T) {
@@ -674,7 +674,7 @@ func Test_DontUpdateUnmanagedUsers(t *testing.T) {
func Test_AllowExplicitlyManageExistingUsers(t *testing.T) {
t.Parallel()
- allGroups := []string{"foo", types.TeleportKeepGroup, types.TeleportDropGroup}
+ allGroups := []string{"foo", apiconstants.TeleportKeepGroup, apiconstants.TeleportDropGroup}
users, backend := initBackend(t, allGroups)
assert.NoError(t, backend.CreateUser("alice-keep", []string{}, host.UserOpts{}))
@@ -692,7 +692,7 @@ func Test_AllowExplicitlyManageExistingUsers(t *testing.T) {
assert.Equal(t, 1, backend.updateUserCalls)
// slice off the end because teleport-system should be explicitly excluded
assert.ElementsMatch(t, allGroups[:2], backend.users["alice-keep"])
- assert.NotContains(t, backend.users["alice-keep"], types.TeleportDropGroup)
+ assert.NotContains(t, backend.users["alice-keep"], apiconstants.TeleportDropGroup)
// Take ownership of existing user when in STATIC mode
userinfo.Mode = decisionpb.HostUserMode_HOST_USER_MODE_STATIC
@@ -701,9 +701,9 @@ func Test_AllowExplicitlyManageExistingUsers(t *testing.T) {
assert.Equal(t, nil, closer)
assert.Equal(t, 2, backend.updateUserCalls)
assert.Contains(t, backend.users["alice-static"], "foo")
- assert.Contains(t, backend.users["alice-static"], types.TeleportStaticGroup)
- assert.NotContains(t, backend.users["alice-static"], types.TeleportKeepGroup)
- assert.NotContains(t, backend.users["alice-static"], types.TeleportDropGroup)
+ assert.Contains(t, backend.users["alice-static"], apiconstants.TeleportStaticGroup)
+ assert.NotContains(t, backend.users["alice-static"], apiconstants.TeleportKeepGroup)
+ assert.NotContains(t, backend.users["alice-static"], apiconstants.TeleportDropGroup)
// Don't take ownership of existing user when in DROP mode
userinfo.Mode = decisionpb.HostUserMode_HOST_USER_MODE_DROP
@@ -719,8 +719,8 @@ func Test_AllowExplicitlyManageExistingUsers(t *testing.T) {
assert.NoError(t, err)
assert.NotEqual(t, nil, closer)
assert.Equal(t, 2, backend.updateUserCalls)
- assert.ElementsMatch(t, []string{"foo", types.TeleportDropGroup}, backend.users["bob"])
- assert.NotContains(t, backend.users["bob"], types.TeleportKeepGroup)
+ assert.ElementsMatch(t, []string{"foo", apiconstants.TeleportDropGroup}, backend.users["bob"])
+ assert.NotContains(t, backend.users["bob"], apiconstants.TeleportKeepGroup)
}
func initBackend(t *testing.T, groups []string) (HostUserManagement, *testHostUserBackend) {
@@ -811,7 +811,7 @@ func TestHostUsersResolveGroups(t *testing.T) {
Groups: []string{"foo", "bar"},
},
- expectGroups: []string{"foo", "bar", types.TeleportDropGroup},
+ expectGroups: []string{"foo", "bar", apiconstants.TeleportDropGroup},
},
{
name: "create keep user",
@@ -822,7 +822,7 @@ func TestHostUsersResolveGroups(t *testing.T) {
Groups: []string{"foo", "bar"},
},
- expectGroups: []string{"foo", "bar", types.TeleportKeepGroup},
+ expectGroups: []string{"foo", "bar", apiconstants.TeleportKeepGroup},
},
{
name: "create static user",
@@ -833,15 +833,15 @@ func TestHostUsersResolveGroups(t *testing.T) {
Groups: []string{"foo", "bar"},
},
- expectGroups: []string{"foo", "bar", types.TeleportStaticGroup},
+ expectGroups: []string{"foo", "bar", apiconstants.TeleportStaticGroup},
},
{
name: "update drop user",
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportDropGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportDropGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
@@ -849,16 +849,16 @@ func TestHostUsersResolveGroups(t *testing.T) {
Groups: []string{"baz", "qux"},
},
- expectGroups: []string{"baz", "qux", types.TeleportDropGroup},
+ expectGroups: []string{"baz", "qux", apiconstants.TeleportDropGroup},
},
{
name: "update keep user",
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportKeepGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportKeepGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
@@ -866,16 +866,16 @@ func TestHostUsersResolveGroups(t *testing.T) {
Groups: []string{"baz", "qux"},
},
- expectGroups: []string{"baz", "qux", types.TeleportKeepGroup},
+ expectGroups: []string{"baz", "qux", apiconstants.TeleportKeepGroup},
},
{
name: "update static user",
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportStaticGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportStaticGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
@@ -883,16 +883,16 @@ func TestHostUsersResolveGroups(t *testing.T) {
Groups: []string{"baz", "qux"},
},
- expectGroups: []string{"baz", "qux", types.TeleportStaticGroup},
+ expectGroups: []string{"baz", "qux", apiconstants.TeleportStaticGroup},
},
{
name: "convert drop to keep",
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportDropGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportDropGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
@@ -900,16 +900,16 @@ func TestHostUsersResolveGroups(t *testing.T) {
Groups: []string{"baz", "qux"},
},
- expectGroups: []string{"baz", "qux", types.TeleportKeepGroup},
+ expectGroups: []string{"baz", "qux", apiconstants.TeleportKeepGroup},
},
{
name: "convert keep to drop",
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportKeepGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportKeepGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
@@ -917,16 +917,16 @@ func TestHostUsersResolveGroups(t *testing.T) {
Groups: []string{"baz", "qux"},
},
- expectGroups: []string{"baz", "qux", types.TeleportDropGroup},
+ expectGroups: []string{"baz", "qux", apiconstants.TeleportDropGroup},
},
{
name: "don't convert drop to static",
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportDropGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportDropGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
@@ -942,9 +942,9 @@ func TestHostUsersResolveGroups(t *testing.T) {
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportKeepGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportKeepGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
@@ -960,9 +960,9 @@ func TestHostUsersResolveGroups(t *testing.T) {
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportStaticGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportStaticGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
@@ -977,9 +977,9 @@ func TestHostUsersResolveGroups(t *testing.T) {
name: "don't convert static to drop",
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportStaticGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportStaticGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
@@ -1001,7 +1001,7 @@ func TestHostUsersResolveGroups(t *testing.T) {
},
ui: &decisionpb.HostUsersInfo{
Mode: decisionpb.HostUserMode_HOST_USER_MODE_DROP,
- Groups: []string{"baz", "qux", types.TeleportDropGroup}, // similarly including TeleportDropGroup to ensure no-op
+ Groups: []string{"baz", "qux", apiconstants.TeleportDropGroup}, // similarly including TeleportDropGroup to ensure no-op
},
takeOwnership: true, // this flag should be a no-op for DROP, so we include it to ensure that behavior
@@ -1050,10 +1050,10 @@ func TestHostUsersResolveGroups(t *testing.T) {
},
ui: &decisionpb.HostUsersInfo{
Mode: decisionpb.HostUserMode_HOST_USER_MODE_KEEP,
- Groups: []string{"baz", "qux", types.TeleportKeepGroup},
+ Groups: []string{"baz", "qux", apiconstants.TeleportKeepGroup},
},
- expectGroups: []string{"baz", "qux", types.TeleportKeepGroup},
+ expectGroups: []string{"baz", "qux", apiconstants.TeleportKeepGroup},
},
{
name: "take over unmanaged user in static mode when migrating",
@@ -1070,38 +1070,38 @@ func TestHostUsersResolveGroups(t *testing.T) {
takeOwnership: true,
- expectGroups: []string{"baz", "qux", types.TeleportStaticGroup},
+ expectGroups: []string{"baz", "qux", apiconstants.TeleportStaticGroup},
},
{
name: "ignore explicitly configured teleport system groups",
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportDropGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportDropGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
Mode: decisionpb.HostUserMode_HOST_USER_MODE_DROP,
- Groups: []string{"baz", types.TeleportStaticGroup, types.TeleportKeepGroup, types.TeleportDropGroup},
+ Groups: []string{"baz", apiconstants.TeleportStaticGroup, apiconstants.TeleportKeepGroup, apiconstants.TeleportDropGroup},
},
- expectGroups: []string{"baz", types.TeleportDropGroup},
+ expectGroups: []string{"baz", apiconstants.TeleportDropGroup},
},
{
name: "return no groups if no change is necessary",
hostUser: &HostUser{
Groups: map[string]struct{}{
- "foo": {},
- "bar": {},
- types.TeleportDropGroup: {},
+ "foo": {},
+ "bar": {},
+ apiconstants.TeleportDropGroup: {},
},
},
ui: &decisionpb.HostUsersInfo{
Mode: decisionpb.HostUserMode_HOST_USER_MODE_DROP,
- Groups: []string{"foo", "bar", types.TeleportDropGroup},
+ Groups: []string{"foo", "bar", apiconstants.TeleportDropGroup},
},
expectGroups: nil,
@@ -1144,8 +1144,8 @@ func TestRegressionGroupErrorDoesNotPanic(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, nil, closer)
assert.Zero(t, backend.updateUserCalls)
- assert.ElementsMatch(t, append(userinfo.Groups, types.TeleportKeepGroup), backend.users["alice"])
- assert.NotContains(t, backend.users["alice"], types.TeleportDropGroup)
+ assert.ElementsMatch(t, append(userinfo.Groups, apiconstants.TeleportKeepGroup), backend.users["alice"])
+ assert.NotContains(t, backend.users["alice"], apiconstants.TeleportDropGroup)
backend.groupDatabaseErr = errors.New("could not find group")
_, err = users.UpsertUser("alice", &userinfo)
diff --git a/lib/sshutils/exec.go b/lib/sshutils/exec.go
index e8476121e86..269cf5b772a 100644
--- a/lib/sshutils/exec.go
+++ b/lib/sshutils/exec.go
@@ -24,7 +24,7 @@ import (
"log/slog"
"syscall"
- "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
)
// errorWithExitStatus defines an interface that provides an ExitStatus
@@ -48,7 +48,7 @@ type execExitError interface {
func ExitCodeFromExecError(err error) int {
// If no error occurred, return 0 (success).
if err == nil {
- return teleport.RemoteCommandSuccess
+ return reexecconstants.RemoteCommandSuccess
}
var execExitErr execExitError
@@ -57,7 +57,7 @@ func ExitCodeFromExecError(err error) int {
case errors.As(err, &execExitErr):
waitStatus, ok := execExitErr.Sys().(syscall.WaitStatus)
if !ok {
- return teleport.RemoteCommandFailure
+ return reexecconstants.RemoteCommandFailure
}
return waitStatus.ExitStatus()
case errors.As(err, &exitErr):
@@ -65,6 +65,6 @@ func ExitCodeFromExecError(err error) int {
// An error occurred, but the type is unknown, return a generic 255 code.
default:
slog.DebugContext(context.Background(), "Unknown error returned when executing command", "error", err)
- return teleport.RemoteCommandFailure
+ return reexecconstants.RemoteCommandFailure
}
}
diff --git a/lib/sshutils/exec_test.go b/lib/sshutils/exec_test.go
index fd24eac6a42..1dfb467033d 100644
--- a/lib/sshutils/exec_test.go
+++ b/lib/sshutils/exec_test.go
@@ -27,7 +27,7 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
- "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
)
type mockErrorWithExitStatus struct {
@@ -66,7 +66,7 @@ func TestExitCodeFromExecError(t *testing.T) {
{
name: "success",
input: nil,
- want: teleport.RemoteCommandSuccess,
+ want: reexecconstants.RemoteCommandSuccess,
},
{
name: "exec exit error",
@@ -76,7 +76,7 @@ func TestExitCodeFromExecError(t *testing.T) {
{
name: "exec exit error with unknown sys",
input: mockExecExitError{sys: "unknown"},
- want: teleport.RemoteCommandFailure,
+ want: reexecconstants.RemoteCommandFailure,
},
{
name: "ssh exit error",
@@ -86,7 +86,7 @@ func TestExitCodeFromExecError(t *testing.T) {
{
name: "unknown error",
input: errors.New("unknown error"),
- want: teleport.RemoteCommandFailure,
+ want: reexecconstants.RemoteCommandFailure,
},
}
diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go
index 31114693db0..fe1b142aad7 100644
--- a/lib/web/apiserver_test.go
+++ b/lib/web/apiserver_test.go
@@ -159,6 +159,7 @@ import (
"github.com/gravitational/teleport/lib/web/terminal"
webui "github.com/gravitational/teleport/lib/web/ui"
"github.com/gravitational/teleport/session/pam/pamcfg"
+ "github.com/gravitational/teleport/session/reexec"
)
const hostID = "00000000-0000-0000-0000-000000000000"
@@ -189,8 +190,8 @@ func TestMain(m *testing.M) {
modules.SetInsecureTestMode(true)
// If the test is re-executing itself, execute the command that comes over
// the pipe.
- if srv.IsReexec() {
- srv.RunAndExit(os.Args[1])
+ if reexec.IsReexec() {
+ reexec.RunAndExit(os.Args[1])
return
}
diff --git a/session/logutils/constants.go b/session/logconstants/constants.go
similarity index 98%
rename from session/logutils/constants.go
rename to session/logconstants/constants.go
index 1253cc425eb..2807ca6947d 100644
--- a/session/logutils/constants.go
+++ b/session/logconstants/constants.go
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package logutils
+package logconstants
// these constants are asserted to be the same as the ones in
// github.com/gravitational/teleport, they are inlined here to avoid circular
diff --git a/session/pam/pam.go b/session/pam/pam.go
index d9e04033033..f2080c4f6d1 100644
--- a/session/pam/pam.go
+++ b/session/pam/pam.go
@@ -59,7 +59,7 @@ import (
"github.com/gravitational/trace"
- "github.com/gravitational/teleport/session/logutils"
+ "github.com/gravitational/teleport/session/logconstants"
"github.com/gravitational/teleport/session/pam/pamcfg"
)
@@ -102,7 +102,7 @@ func init() {
}
// logComponent is the value for the component attribute in logs for this
-// package; it should be paired with [logutils.ComponentKey]. Using a
+// package; it should be paired with [logconstants.ComponentKey]. Using a
// package-level logger with this attribute created with [slog.With] would not
// synchronize correctly with [slog.SetDefault] and copying the packagelogger
// construction from lib/utils/log doesn't seem to be worth it when we have
@@ -154,7 +154,7 @@ func writeCallback(index C.int, stream C.int, s *C.char) {
if err != nil {
slog.ErrorContext(context.Background(),
"Unable to write to output stream",
- logutils.ComponentKey, logComponent,
+ logconstants.ComponentKey, logComponent,
"error", err,
)
return
@@ -174,7 +174,7 @@ func readCallback(index C.int, e C.int) *C.char {
if err != nil {
slog.ErrorContext(context.Background(),
"Unable to read from input stream",
- logutils.ComponentKey, logComponent,
+ logconstants.ComponentKey, logComponent,
"error", err,
)
return nil
@@ -190,7 +190,7 @@ func readCallback(index C.int, e C.int) *C.char {
if err != nil {
slog.ErrorContext(context.Background(),
"Unable to read from input stream",
- logutils.ComponentKey, logComponent,
+ logconstants.ComponentKey, logComponent,
"error", err,
)
return nil
@@ -456,7 +456,7 @@ func (p *PAM) free() {
if retval != C.PAM_SUCCESS {
slog.WarnContext(context.Background(),
"Failed to end PAM transaction",
- logutils.ComponentKey, logComponent,
+ logconstants.ComponentKey, logComponent,
"error", p.codeToError(retval),
)
}
diff --git a/session/reexec/exec.go b/session/reexec/exec.go
new file mode 100644
index 00000000000..6096604224a
--- /dev/null
+++ b/session/reexec/exec.go
@@ -0,0 +1,125 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package reexec
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "log/slog"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
+)
+
+const (
+ defaultPath = "/bin:/usr/bin:/usr/local/bin:/sbin"
+ defaultEnvPath = "PATH=" + defaultPath
+ defaultRootPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+ defaultEnvRootPath = "PATH=" + defaultRootPath
+ defaultLoginDefsPath = "/etc/login.defs"
+)
+
+// GetDefaultEnvPath returns the default value of PATH environment variable for
+// new logins (prior to shell) based on login.defs. Returns a string which
+// looks like "PATH=/usr/bin:/bin"
+func GetDefaultEnvPath(uid string) string {
+ return getDefaultEnvPathWithLoginDefs(uid, defaultLoginDefsPath)
+}
+
+func getDefaultEnvPathWithLoginDefs(uid string, loginDefsPath string) string {
+ envPath := defaultEnvPath
+ envRootPath := defaultEnvRootPath
+
+ // open file, if it doesn't exist return a default path and move on
+ f, err := os.Open(loginDefsPath)
+ if err != nil {
+ if uid == "0" {
+ slog.DebugContext(context.Background(), "Unable to open login.defs, returning default su path", "login_defs_path", loginDefsPath, "error", err, "default_path", defaultEnvRootPath)
+ return defaultEnvRootPath
+ }
+ slog.DebugContext(context.Background(), "Unable to open login.defs, returning default path", "login_defs_path", loginDefsPath, "error", err, "default_path", defaultEnvPath)
+ return defaultEnvPath
+ }
+ defer f.Close()
+
+ // read path from login.defs file (/etc/login.defs) line by line:
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+
+ // skip comments and empty lines:
+ if line == "" || line[0] == '#' {
+ continue
+ }
+
+ // look for a line that starts with ENV_PATH or ENV_SUPATH
+ fields := strings.Fields(line)
+ if len(fields) > 1 {
+ if fields[0] == "ENV_PATH" {
+ envPath = fields[1]
+ }
+ if fields[0] == "ENV_SUPATH" {
+ envRootPath = fields[1]
+ }
+ }
+ }
+
+ // if any error occurs while reading the file, return the default value
+ err = scanner.Err()
+ if err != nil {
+ if uid == "0" {
+ slog.WarnContext(context.Background(), "Unable to read login.defs, returning default su path", "login_defs_path", loginDefsPath, "error", err, "default_path", defaultEnvRootPath)
+ return defaultEnvRootPath
+ }
+ slog.WarnContext(context.Background(), "Unable to read login.defs, returning default path", "login_defs_path", loginDefsPath, "error", err, "default_path", defaultEnvPath)
+ return defaultEnvPath
+ }
+
+ // if requesting path for uid 0 and no ENV_SUPATH is given, fallback to
+ // ENV_PATH first, then the default path.
+ if uid == "0" {
+ return envRootPath
+ }
+ return envPath
+}
+
+// exitCode extracts and returns the exit code from the error.
+func exitCode(err error) int {
+ // If no error occurred, return 0 (success).
+ if err == nil {
+ return reexecconstants.RemoteCommandSuccess
+ }
+
+ var execExitErr *exec.ExitError
+ switch {
+ // Local execution.
+ case errors.As(err, &execExitErr):
+ waitStatus, ok := execExitErr.Sys().(syscall.WaitStatus)
+ if !ok {
+ return reexecconstants.RemoteCommandFailure
+ }
+ return waitStatus.ExitStatus()
+ // An error occurred, but the type is unknown, return a generic 255 code.
+ default:
+ slog.DebugContext(context.Background(), "Unknown error returned when executing command", "error", err)
+ return reexecconstants.RemoteCommandFailure
+ }
+}
diff --git a/session/reexec/exec_test.go b/session/reexec/exec_test.go
new file mode 100644
index 00000000000..ac0867bb9b6
--- /dev/null
+++ b/session/reexec/exec_test.go
@@ -0,0 +1,60 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package reexec
+
+import (
+ "flag"
+ "log/slog"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ if IsReexec() {
+ RunAndExit(os.Args[1])
+ return
+ }
+
+ if !flag.Parsed() {
+ flag.Parse()
+ }
+ if testing.Verbose() {
+ slog.SetDefault(slog.New(slog.NewJSONHandler(
+ os.Stderr,
+ &slog.HandlerOptions{
+ Level: slog.LevelDebug,
+ },
+ )))
+ } else {
+ slog.SetDefault(slog.New(slog.DiscardHandler))
+ }
+
+ os.Exit(m.Run())
+}
+
+func TestLoginDefsParser(t *testing.T) {
+ t.Parallel()
+
+ expectedEnvSuPath := "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/bar"
+ expectedSuPath := "PATH=/usr/local/bin:/usr/bin:/bin:/foo"
+
+ require.Equal(t, expectedEnvSuPath, getDefaultEnvPathWithLoginDefs("0", "../../fixtures/login.defs"))
+ require.Equal(t, expectedSuPath, getDefaultEnvPathWithLoginDefs("1000", "../../fixtures/login.defs"))
+ require.Equal(t, defaultEnvPath, getDefaultEnvPathWithLoginDefs("1000", "bad/file"))
+}
diff --git a/session/reexec/internal/logutils/buffer.go b/session/reexec/internal/logutils/buffer.go
new file mode 100644
index 00000000000..f1b71ba3ebe
--- /dev/null
+++ b/session/reexec/internal/logutils/buffer.go
@@ -0,0 +1,62 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package logutils
+
+import "sync"
+
+// buffer adapted from go/src/fmt/print.go
+type buffer []byte
+
+// Having an initial size gives a dramatic speedup.
+var bufPool = sync.Pool{
+ New: func() any {
+ b := make([]byte, 0, 1024)
+ return (*buffer)(&b)
+ },
+}
+
+func newBuffer() *buffer {
+ return bufPool.Get().(*buffer)
+}
+
+func (b *buffer) Len() int {
+ return len(*b)
+}
+
+func (b *buffer) SetLen(n int) {
+ *b = (*b)[:n]
+}
+
+func (b *buffer) Free() {
+ // To reduce peak allocation, return only smaller buffers to the pool.
+ const maxBufferSize = 16 << 10
+ if cap(*b) <= maxBufferSize {
+ *b = (*b)[:0]
+ bufPool.Put(b)
+ }
+}
+
+func (b *buffer) Reset() {
+ *b = (*b)[:0]
+}
+
+func (b *buffer) Write(p []byte) (int, error) {
+ *b = append(*b, p...)
+ return len(p), nil
+}
+
+func (b *buffer) WriteString(s string) (int, error) {
+ *b = append(*b, s...)
+ return len(s), nil
+}
+
+func (b *buffer) WriteByte(c byte) error {
+ *b = append(*b, c)
+ return nil
+}
+
+func (b *buffer) String() string {
+ return string(*b)
+}
diff --git a/session/reexec/internal/logutils/handle_state.go b/session/reexec/internal/logutils/handle_state.go
new file mode 100644
index 00000000000..6c2dcb46061
--- /dev/null
+++ b/session/reexec/internal/logutils/handle_state.go
@@ -0,0 +1,339 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package logutils
+
+import (
+ "encoding"
+ "fmt"
+ "log/slog"
+ "reflect"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/session/logconstants"
+)
+
+// handleState adapted from go/src/log/slog/handler.go
+type handleState struct {
+ h *SlogTextHandler
+ buf *buffer
+ freeBuf bool // should buf be freed?
+ prefix *buffer // for text: key prefix
+ groups *[]string // pool-allocated slice of active groups, for ReplaceAttr
+}
+
+var groupPool = sync.Pool{New: func() any {
+ s := make([]string, 0, 10)
+ return &s
+}}
+
+func (s *handleState) free() {
+ if s.freeBuf {
+ s.buf.Free()
+ }
+ if gs := s.groups; gs != nil {
+ *gs = (*gs)[:0]
+ groupPool.Put(gs)
+ }
+ s.prefix.Free()
+}
+
+func (s *handleState) openGroups() {
+ for _, n := range s.h.groups[s.h.nOpenGroups:] {
+ s.openGroup(n)
+ }
+}
+
+// openGroup starts a new group of attributes
+// with the given name.
+func (s *handleState) openGroup(name string) {
+ s.prefix.WriteString(name)
+ s.prefix.WriteByte('.')
+
+ // Collect group names for ReplaceAttr.
+ if s.groups != nil {
+ *s.groups = append(*s.groups, name)
+ }
+}
+
+// closeGroup ends the group with the given name.
+func (s *handleState) closeGroup(name string) {
+ *s.prefix = (*s.prefix)[:len(*s.prefix)-len(name)-1 /* for keyComponentSep */]
+
+ if s.groups != nil {
+ *s.groups = (*s.groups)[:len(*s.groups)-1]
+ }
+}
+
+// appendAttrs appends the slice of Attrs.
+// It reports whether something was appended.
+func (s *handleState) appendAttrs(as []slog.Attr) bool {
+ nonEmpty := false
+ for _, a := range as {
+ if s.appendAttr(a) {
+ nonEmpty = true
+ }
+ }
+ return nonEmpty
+}
+
+// appendAttr appends the Attr's key and value.
+// It handles replacement and checking for an empty key.
+// It reports whether something was appended.
+func (s *handleState) appendAttr(a slog.Attr) bool {
+ a.Value = a.Value.Resolve()
+ if rep := s.h.cfg.ReplaceAttr; rep != nil && a.Value.Kind() != slog.KindGroup {
+ var gs []string
+ if s.groups != nil {
+ gs = *s.groups
+ }
+ // a.Value is resolved before calling ReplaceAttr, so the user doesn't have to.
+ a = rep(gs, a)
+ // The ReplaceAttr function may return an unresolved Attr.
+ a.Value = a.Value.Resolve()
+ }
+ // Elide empty Attrs.
+ if a.Equal(slog.Attr{}) {
+ return false
+ }
+
+ // Handle nested attributes from within component fields.
+ if a.Key == logconstants.ComponentFields {
+ nonEmpty := false
+ switch fields := a.Value.Any().(type) {
+ case map[string]any:
+ for k, v := range fields {
+ if s.appendAttr(slog.Any(k, v)) {
+ nonEmpty = true
+ }
+ }
+ return nonEmpty
+ }
+ }
+
+ // Handle special cases before formatting.
+ var traceError bool
+ if a.Value.Kind() == slog.KindAny {
+ switch v := a.Value.Any().(type) {
+ case *slog.Source:
+ a.Value = slog.StringValue(fmt.Sprintf(" %s:%d", v.File, v.Line))
+ case trace.Error:
+ traceError = true
+ a.Value = slog.StringValue("[" + v.DebugReport() + "]")
+ case error:
+ a.Value = slog.StringValue(fmt.Sprintf("[%v]", v))
+ }
+ }
+
+ if a.Value.Kind() == slog.KindGroup {
+ attrs := a.Value.Group()
+ // Output only non-empty groups.
+ if len(attrs) > 0 {
+ // The group may turn out to be empty even though it has attrs (for
+ // example, ReplaceAttr may delete all the attrs).
+ // So remember where we are in the buffer, to restore the position
+ // later if necessary.
+ pos := s.buf.Len()
+ // Inline a group with an empty key.
+ if a.Key != "" {
+ s.openGroup(a.Key)
+ }
+ if !s.appendAttrs(attrs) {
+ s.buf.SetLen(pos)
+ return false
+ }
+ if a.Key != "" {
+ s.closeGroup(a.Key)
+ }
+ }
+
+ return true
+ }
+
+ s.appendKey(a.Key)
+
+ // Write the level key to avoid quoting color formatting that exists or
+ // [trace.Error]s so that the debug report is output in it's entirety.
+ if traceError || a.Key == slog.LevelKey {
+ s.buf.WriteString(a.Value.String())
+ } else {
+ s.appendValue(a.Value)
+ }
+
+ return true
+}
+
+func (s *handleState) appendError(err error) {
+ s.appendString(fmt.Sprintf("!ERROR:%v", err))
+}
+
+func (s *handleState) appendKey(key string) {
+ if s.buf.Len() > 0 {
+ s.buf.WriteString(" ")
+ }
+
+ // These keys should not be included in the output to match
+ // the behavior of the lorgus formatter.
+ if key == slog.TimeKey ||
+ key == logconstants.ComponentKey ||
+ key == slog.LevelKey ||
+ key == CallerField ||
+ key == slog.MessageKey ||
+ key == slog.SourceKey {
+ return
+ }
+
+ if s.prefix != nil && len(*s.prefix) > 0 {
+ // TODO: optimize by avoiding allocation.
+ s.appendString(string(*s.prefix) + key)
+ } else {
+ s.appendString(key)
+ }
+
+ s.buf.WriteByte(':')
+}
+
+func (s *handleState) appendString(str string) {
+ if str == "" {
+ return
+ }
+
+ if needsQuoting(str) {
+ *s.buf = strconv.AppendQuote(*s.buf, str)
+ } else {
+ s.buf.WriteString(str)
+ }
+}
+
+func (s *handleState) appendValue(v slog.Value) {
+ defer func() {
+ if r := recover(); r != nil {
+ // If it panics with a nil pointer, the most likely cases are
+ // an encoding.TextMarshaler or error fails to guard against nil,
+ // in which case "" seems to be the feasible choice.
+ //
+ // Adapted from the code in fmt/print.go.
+ if v := reflect.ValueOf(v.Any()); v.Kind() == reflect.Pointer && v.IsNil() {
+ s.appendString("")
+ return
+ }
+
+ // Otherwise just print the original panic message.
+ s.appendString(fmt.Sprintf("!PANIC: %v", r))
+ }
+ }()
+
+ if err := appendTextValue(s, v); err != nil {
+ s.appendError(err)
+ }
+}
+
+func (s *handleState) appendTime(t time.Time) {
+ *s.buf = appendRFC3339Millis(*s.buf, t)
+}
+
+func (s *handleState) appendNonBuiltIns(r slog.Record) {
+ // preformatted Attrs
+ if pfa := s.h.preformatted; len(pfa) > 0 {
+ s.buf.WriteString(" ")
+ s.buf.Write(pfa)
+ }
+ // Attrs in Record -- unlike the built-in ones, they are in groups started
+ // from WithGroup.
+ // If the record has no Attrs, don't output any groups.
+ if r.NumAttrs() > 0 {
+ s.prefix.WriteString(s.h.groupPrefix)
+ // The group may turn out to be empty even though it has attrs (for
+ // example, ReplaceAttr may delete all the attrs).
+ // So remember where we are in the buffer, to restore the position
+ // later if necessary.
+ pos := s.buf.Len()
+ s.openGroups()
+ empty := true
+ r.Attrs(func(a slog.Attr) bool {
+ // The component is handled by the top level handler.
+ if a.Key == logconstants.ComponentKey {
+ return true
+ }
+ if s.appendAttr(a) {
+ empty = false
+ }
+ return true
+ })
+ if empty {
+ s.buf.SetLen(pos)
+ }
+ }
+}
+
+func byteSlice(a any) ([]byte, bool) {
+ if bs, ok := a.([]byte); ok {
+ return bs, true
+ }
+ // Like Printf's %s, we allow both the slice type and the byte element type to be named.
+ t := reflect.TypeOf(a)
+ if t != nil && t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
+ return reflect.ValueOf(a).Bytes(), true
+ }
+ return nil, false
+}
+
+func appendTextValue(s *handleState, v slog.Value) error {
+ switch v.Kind() {
+ case slog.KindString:
+ s.appendString(v.String())
+ case slog.KindTime:
+ s.appendTime(v.Time())
+ case slog.KindAny:
+ if tm, ok := v.Any().(encoding.TextMarshaler); ok {
+ data, err := tm.MarshalText()
+ if err != nil {
+ return err
+ }
+ // TODO: avoid the conversion to string.
+ s.appendString(string(data))
+ return nil
+ }
+ if bs, ok := byteSlice(v.Any()); ok {
+ // As of Go 1.19, this only allocates for strings longer than 32 bytes.
+ s.buf.WriteString(strconv.Quote(string(bs)))
+ return nil
+ }
+ s.appendString(fmt.Sprintf("%+v", v.Any()))
+ case slog.KindInt64:
+ *s.buf = strconv.AppendInt(*s.buf, v.Int64(), 10)
+ case slog.KindUint64:
+ *s.buf = strconv.AppendUint(*s.buf, v.Uint64(), 10)
+ case slog.KindFloat64:
+ *s.buf = strconv.AppendFloat(*s.buf, v.Float64(), 'g', -1, 64)
+ case slog.KindBool:
+ *s.buf = strconv.AppendBool(*s.buf, v.Bool())
+ case slog.KindDuration:
+ *s.buf = append(*s.buf, v.Duration().String()...)
+ case slog.KindGroup:
+ *s.buf = fmt.Append(*s.buf, v.Group())
+ case slog.KindLogValuer:
+ *s.buf = fmt.Append(*s.buf, v.Any())
+ default:
+ panic(fmt.Sprintf("bad kind: %s", v.Kind()))
+ }
+ return nil
+}
+
+func appendRFC3339Millis(b []byte, t time.Time) []byte {
+ // Format according to time.RFC3339Nano since it is highly optimized,
+ // but truncate it to use millisecond resolution.
+ // Unfortunately, that format trims trailing 0s, so add 1/10 millisecond
+ // to guarantee that there are exactly 4 digits after the period.
+ const prefixLen = len("2006-01-02T15:04:05.000")
+ n := len(b)
+ t = t.Truncate(time.Millisecond).Add(time.Millisecond / 10)
+ b = t.AppendFormat(b, time.RFC3339Nano)
+ b = append(b[:n+prefixLen], b[n+prefixLen+1:]...) // drop the 4th digit
+ return b
+}
diff --git a/session/reexec/internal/logutils/slog.go b/session/reexec/internal/logutils/slog.go
new file mode 100644
index 00000000000..6150351d797
--- /dev/null
+++ b/session/reexec/internal/logutils/slog.go
@@ -0,0 +1,129 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package logutils
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+ "unicode"
+
+ "github.com/gravitational/trace"
+)
+
+const (
+ // TraceLevel is the logging level when set to Trace verbosity.
+ TraceLevel = slog.LevelDebug - 1
+
+ // TraceLevelText is the text representation of Trace verbosity.
+ TraceLevelText = "TRACE"
+
+ noColor = -1
+ red = 31
+ yellow = 33
+ blue = 36
+ gray = 37
+ // LevelField is the log field that stores the verbosity.
+ LevelField = "level"
+ // ComponentField is the log field that stores the calling component.
+ ComponentField = "component"
+ // CallerField is the log field that stores the calling file and line number.
+ CallerField = "caller"
+ // TimestampField is the field that stores the timestamp the log was emitted.
+ TimestampField = "timestamp"
+ messageField = "message"
+ // defaultComponentPadding is a default padding for component field
+ defaultComponentPadding = 11
+ // defaultLevelPadding is a default padding for level field
+ defaultLevelPadding = 4
+)
+
+// SupportedLevelsText lists the supported log levels in their text
+// representation. All strings are in uppercase.
+var SupportedLevelsText = []string{
+ TraceLevelText,
+ slog.LevelDebug.String(),
+ slog.LevelInfo.String(),
+ slog.LevelWarn.String(),
+ slog.LevelError.String(),
+}
+
+func addTracingContextToRecord(ctx context.Context, r *slog.Record) {
+ // there can't be a span from context from OTEL because we don't include
+ // OTEL in this module
+}
+
+var defaultFormatFields = []string{LevelField, ComponentField, CallerField, TimestampField}
+
+var knownFormatFields = map[string]struct{}{
+ LevelField: {},
+ ComponentField: {},
+ CallerField: {},
+ TimestampField: {},
+}
+
+// ValidateFields ensures the provided fields map to the allowed fields. An error
+// is returned if any of the fields are invalid.
+func ValidateFields(formatInput []string) (result []string, err error) {
+ for _, component := range formatInput {
+ component = strings.TrimSpace(component)
+ if _, ok := knownFormatFields[component]; !ok {
+ return nil, trace.BadParameter("invalid log format key: %q", component)
+ }
+ result = append(result, component)
+ }
+ return result, nil
+}
+
+// needsQuoting returns true if any non-printable characters are found.
+func needsQuoting(text string) bool {
+ for _, r := range text {
+ if !unicode.IsPrint(r) {
+ return true
+ }
+ }
+ return false
+}
+
+func padMax(in string, chars int) string {
+ switch {
+ case len(in) < chars:
+ return in + strings.Repeat(" ", chars-len(in))
+ default:
+ return in[:chars]
+ }
+}
+
+// getCaller retrieves source information from the attribute
+// and returns the file and line of the caller. The file is
+// truncated from the absolute path to package/filename.
+func getCaller(s *slog.Source) (file string, line int) {
+ count := 0
+ idx := strings.LastIndexFunc(s.File, func(r rune) bool {
+ if r == '/' {
+ count++
+ }
+
+ return count == 2
+ })
+ file = s.File[idx+1:]
+ line = s.Line
+
+ return file, line
+}
diff --git a/session/reexec/internal/logutils/slog_json_handler.go b/session/reexec/internal/logutils/slog_json_handler.go
new file mode 100644
index 00000000000..19f04057d30
--- /dev/null
+++ b/session/reexec/internal/logutils/slog_json_handler.go
@@ -0,0 +1,165 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package logutils
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/gravitational/teleport/session/logconstants"
+)
+
+// SlogJSONHandlerConfig allows the SlogJSONHandler functionality
+// to be tweaked.
+type SlogJSONHandlerConfig struct {
+ // Level is the minimum record level that will be logged.
+ Level slog.Leveler
+ // ConfiguredFields are fields explicitly set by users to be included in
+ // the output message. If there are any entries configured, they will be honored.
+ // If empty, the default fields will be populated and included in the output.
+ ConfiguredFields []string
+ // ReplaceAttr is called to rewrite each non-group attribute before
+ // it is logged.
+ ReplaceAttr func(groups []string, a slog.Attr) slog.Attr
+}
+
+// SlogJSONHandler is a [slog.Handler] that outputs messages in a json
+// format per the config file.
+type SlogJSONHandler struct {
+ *slog.JSONHandler
+}
+
+// NewSlogJSONHandler creates a SlogJSONHandler that outputs to w.
+func NewSlogJSONHandler(w io.Writer, cfg SlogJSONHandlerConfig) *SlogJSONHandler {
+ withCaller := len(cfg.ConfiguredFields) == 0 || slices.Contains(cfg.ConfiguredFields, CallerField)
+ withComponent := len(cfg.ConfiguredFields) == 0 || slices.Contains(cfg.ConfiguredFields, ComponentField)
+ withTimestamp := len(cfg.ConfiguredFields) == 0 || slices.Contains(cfg.ConfiguredFields, TimestampField)
+
+ return &SlogJSONHandler{
+ JSONHandler: slog.NewJSONHandler(w, &slog.HandlerOptions{
+ AddSource: true,
+ Level: cfg.Level,
+ ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
+ switch a.Key {
+ case logconstants.ComponentKey:
+ if !withComponent {
+ return slog.Attr{}
+ }
+ if a.Value.Kind() != slog.KindString {
+ return a
+ }
+
+ a.Key = ComponentField
+ case slog.LevelKey:
+ // The slog.JSONHandler will inject "level" Attr.
+ // However, this lib's consumer might add an Attr using the same key ("level") and we end up with two records named "level".
+ // We must check its type before assuming this was injected by the slog.JSONHandler.
+ lvl, ok := a.Value.Any().(slog.Level)
+ if !ok {
+ return a
+ }
+
+ var level string
+ switch lvl {
+ case TraceLevel:
+ level = "trace"
+ case slog.LevelDebug:
+ level = "debug"
+ case slog.LevelInfo:
+ level = "info"
+ case slog.LevelWarn:
+ level = "warning"
+ case slog.LevelError:
+ level = "error"
+ default:
+ level = strings.ToLower(lvl.String())
+ }
+
+ a.Value = slog.StringValue(level)
+ case slog.TimeKey:
+ if !withTimestamp {
+ return slog.Attr{}
+ }
+
+ // The slog.JSONHandler will inject "time" Attr.
+ // However, this lib's consumer might add an Attr using the same key ("time") and we end up with two records named "time".
+ // We must check its type before assuming this was injected by the slog.JSONHandler.
+ if a.Value.Kind() != slog.KindTime {
+ return a
+ }
+
+ t := a.Value.Time()
+ if t.IsZero() {
+ return a
+ }
+
+ a.Key = TimestampField
+ a.Value = slog.StringValue(t.Format(time.RFC3339))
+ case slog.MessageKey:
+ // The slog.JSONHandler will inject "msg" Attr.
+ // However, this lib's consumer might add an Attr using the same key ("msg") and we end up with two records named "msg".
+ // We must check its type before assuming this was injected by the slog.JSONHandler.
+ if a.Value.Kind() != slog.KindString {
+ return a
+ }
+ a.Key = messageField
+ case slog.SourceKey:
+ if !withCaller {
+ return slog.Attr{}
+ }
+
+ // The slog.JSONHandler will inject "source" Attr when AddSource is true.
+ // However, this lib's consumer might add an Attr using the same key ("source") and we end up with two records named "source".
+ // We must check its type before assuming this was injected by the slog.JSONHandler.
+ s, ok := a.Value.Any().(*slog.Source)
+ if !ok {
+ return a
+ }
+
+ file, line := getCaller(s)
+ a = slog.String(CallerField, fmt.Sprintf("%s:%d", file, line))
+ }
+
+ // Convert [slog.KindAny] values that are backed by an [error] or [fmt.Stringer]
+ // to strings so that only the message is output instead of a json object. The kind is
+ // first checked to avoid allocating an interface for the values stored inline
+ // in [slog.Attr].
+ if a.Value.Kind() == slog.KindAny {
+ if err, ok := a.Value.Any().(error); ok {
+ a.Value = slog.StringValue(err.Error())
+ }
+
+ if stringer, ok := a.Value.Any().(fmt.Stringer); ok {
+ a.Value = slog.StringValue(stringer.String())
+ }
+ }
+
+ return a
+ },
+ }),
+ }
+}
+
+func (j *SlogJSONHandler) Handle(ctx context.Context, r slog.Record) error {
+ addTracingContextToRecord(ctx, &r)
+ return j.JSONHandler.Handle(ctx, r)
+}
diff --git a/session/reexec/internal/logutils/slog_text_handler.go b/session/reexec/internal/logutils/slog_text_handler.go
new file mode 100644
index 00000000000..c04e01be858
--- /dev/null
+++ b/session/reexec/internal/logutils/slog_text_handler.go
@@ -0,0 +1,387 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package logutils
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "runtime"
+ "slices"
+ "strings"
+ "sync"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/session/logconstants"
+)
+
+// SlogTextHandler is a [slog.Handler] that outputs messages in a textual
+// manner as configured by the Teleport configuration.
+type SlogTextHandler struct {
+ cfg SlogTextHandlerConfig
+ out slogTextHandlerWriter
+ // withCaller indicates whether the location the log was emitted from
+ // should be included in the output message.
+ withCaller bool
+ // withTimestamp indicates whether the times that the log was emitted at
+ // should be included in the output message.
+ withTimestamp bool
+ // rawComponent is the Teleport subcomponent that emitted the log, e.g., "tsh".
+ rawComponent string
+ // component is rawComponent wrapped in square brackets and truncated if necessary to not exceed
+ // cfg.Padding, e.g., "[TSH]".
+ component string
+ // preformatted data from previous calls to WithGroup and WithAttrs.
+ preformatted []byte
+ // groupPrefix is for the text handler only.
+ // It holds the prefix for groups that were already pre-formatted.
+ // A group will appear here when a call to WithGroup is followed by
+ // a call to WithAttrs.
+ groupPrefix string
+ // groups passed in via WithGroup and WithAttrs.
+ groups []string
+ // nOpenGroups the number of groups opened in preformatted.
+ nOpenGroups int
+}
+
+type slogTextHandlerWriter interface {
+ Write(bytes []byte, component string, level slog.Level) error
+}
+
+// SlogTextHandlerConfig allow the SlogTextHandler functionality
+// to be tweaked.
+type SlogTextHandlerConfig struct {
+ // Level is the minimum record level that will be logged.
+ Level slog.Leveler
+ // EnableColors allows the level to be printed in color.
+ EnableColors bool
+ // Padding to use for [ComponentField] to ensure that the initial columns in the output line up.
+ // The component is wrapped in square brackets. If the length of the component exceeds Padding+2,
+ // the component is truncated. If the length is less than Padding+2, the component in square
+ // brackets is followed by spaces to pad it to the given Padding.
+ //
+ // If set to zero, no padding is done and components are not truncated.
+ Padding int
+ // ConfiguredFields are fields explicitly set by users to be included in
+ // the output message. If there are any entries configured, they will be honored.
+ // If empty, the default fields will be populated and included in the output.
+ ConfiguredFields []string
+ // ReplaceAttr is called to rewrite each non-group attribute before
+ // it is logged.
+ ReplaceAttr func(groups []string, a slog.Attr) slog.Attr
+}
+
+// NewSlogTextHandler creates a SlogTextHandler that writes messages to w.
+func NewSlogTextHandler(w io.Writer, cfg SlogTextHandlerConfig) *SlogTextHandler {
+ if cfg.Padding == 0 {
+ cfg.Padding = defaultComponentPadding
+ }
+
+ handler := SlogTextHandler{
+ cfg: cfg,
+ out: newIOWriter(w),
+ withCaller: len(cfg.ConfiguredFields) == 0 || slices.Contains(cfg.ConfiguredFields, CallerField),
+ withTimestamp: len(cfg.ConfiguredFields) == 0 || slices.Contains(cfg.ConfiguredFields, TimestampField),
+ }
+
+ if handler.cfg.ConfiguredFields == nil {
+ handler.cfg.ConfiguredFields = defaultFormatFields
+ }
+
+ return &handler
+}
+
+// Enabled returns whether the provided level will be included in output.
+func (s *SlogTextHandler) Enabled(ctx context.Context, level slog.Level) bool {
+ minLevel := slog.LevelInfo
+ if s.cfg.Level != nil {
+ minLevel = s.cfg.Level.Level()
+ }
+ return level >= minLevel
+}
+
+func (s *SlogTextHandler) newHandleState(buf *buffer, freeBuf bool) handleState {
+ state := handleState{
+ h: s,
+ buf: buf,
+ freeBuf: freeBuf,
+ prefix: newBuffer(),
+ }
+ if s.cfg.ReplaceAttr != nil {
+ state.groups = groupPool.Get().(*[]string)
+ *state.groups = append(*state.groups, s.groups[:s.nOpenGroups]...)
+ }
+ return state
+}
+
+// Handle formats the provided record and writes the output to the
+// destination.
+func (s *SlogTextHandler) Handle(ctx context.Context, r slog.Record) error {
+ state := s.newHandleState(newBuffer(), true)
+ defer state.free()
+
+ addTracingContextToRecord(ctx, &r)
+
+ // Built-in attributes. They are not in a group.
+ stateGroups := state.groups
+ state.groups = nil // So ReplaceAttrs sees no groups instead of the pre groups.
+ rep := s.cfg.ReplaceAttr
+
+ if s.withTimestamp && !r.Time.IsZero() {
+ if rep == nil {
+ state.appendKey(slog.TimeKey)
+ state.appendTime(r.Time.Round(0))
+ } else {
+ state.appendAttr(slog.Time(slog.TimeKey, r.Time.Round(0)))
+ }
+ }
+
+ rawComponent := s.rawComponent
+ // Processing fields in this manner allows users to
+ // configure the level and component position in the output.
+ // This matches the behavior of the original logrus formatter. All other
+ // fields location in the output message are static.
+ for _, field := range s.cfg.ConfiguredFields {
+ switch field {
+ case LevelField:
+ level := formatLevel(r.Level, s.cfg.EnableColors)
+
+ if rep == nil {
+ state.appendKey(slog.LevelKey)
+ // Write the level directly to stat to avoid quoting
+ // color formatting that exists.
+ state.buf.WriteString(level)
+ } else {
+ state.appendAttr(slog.String(slog.LevelKey, level))
+ }
+ case ComponentField:
+ // If a component is provided with the attributes, it should be used instead of
+ // the component set on the handler. Note that if there are multiple components
+ // specified in the arguments, the one with the lowest index is used and the others are ignored.
+ // In the example below, the resulting component in the message output would be "alpaca".
+ //
+ // logger := logger.With(teleport.ComponentKey, "fish")
+ // logger.InfoContext(ctx, "llama llama llama", teleport.ComponentKey, "alpaca", "foo", 123, teleport.ComponentKey, "shark")
+ component := s.component
+ r.Attrs(func(attr slog.Attr) bool {
+ if attr.Key != logconstants.ComponentKey {
+ return true
+ }
+
+ rawComponent = attr.Value.String()
+ component = formatComponent(attr.Value, s.cfg.Padding)
+ return false
+ })
+
+ if rep == nil {
+ state.appendKey(logconstants.ComponentKey)
+ state.appendString(component)
+ } else {
+ state.appendAttr(slog.String(logconstants.ComponentKey, component))
+ }
+ default:
+ if _, ok := knownFormatFields[field]; !ok {
+ return trace.BadParameter("invalid log format key: %v", field)
+ }
+ }
+ }
+
+ if rep == nil {
+ state.appendKey(slog.MessageKey)
+ state.appendString(r.Message)
+ } else {
+ state.appendAttr(slog.String(slog.MessageKey, r.Message))
+ }
+
+ state.groups = stateGroups // Restore groups passed to ReplaceAttrs.
+ state.appendNonBuiltIns(r)
+
+ if r.PC != 0 && s.withCaller {
+ fs := runtime.CallersFrames([]uintptr{r.PC})
+ f, _ := fs.Next()
+
+ src := slog.Source{
+ Function: f.Function,
+ File: f.File,
+ Line: f.Line,
+ }
+ src.File, src.Line = getCaller(&src)
+
+ if rep == nil {
+ state.appendKey(slog.SourceKey)
+ state.appendString(fmt.Sprintf("%s:%d", src.File, src.Line))
+ } else {
+ state.appendAttr(slog.Any(slog.SourceKey, &src))
+ }
+
+ }
+
+ state.buf.WriteByte('\n')
+
+ return s.out.Write(*state.buf, rawComponent, r.Level)
+}
+
+func formatLevel(value slog.Level, enableColors bool) string {
+ var color int
+ var level string
+ switch value {
+ case TraceLevel:
+ level = "TRACE"
+ color = gray
+ case slog.LevelDebug:
+ level = "DEBUG"
+ color = gray
+ case slog.LevelInfo:
+ level = "INFO"
+ color = blue
+ case slog.LevelWarn:
+ level = "WARN"
+ color = yellow
+ case slog.LevelError:
+ level = "ERROR"
+ color = red
+ default:
+ color = blue
+ level = value.String()
+ }
+
+ if !enableColors {
+ color = noColor
+ }
+
+ level = padMax(level, defaultLevelPadding)
+ if color != noColor {
+ level = fmt.Sprintf("\u001B[%dm%s\u001B[0m", color, level)
+ }
+
+ return level
+}
+
+func formatComponent(value slog.Value, padding int) string {
+ component := strings.ToUpper(fmt.Sprintf("[%v]", value))
+ if padding <= 0 {
+ return component
+ }
+
+ component = padMax(component, padding)
+ if component[len(component)-1] != ' ' {
+ component = component[:len(component)-1] + "]"
+ }
+
+ return component
+}
+
+func (s *SlogTextHandler) clone() *SlogTextHandler {
+ return &SlogTextHandler{
+ cfg: s.cfg,
+ withCaller: s.withCaller,
+ withTimestamp: s.withTimestamp,
+ component: s.component,
+ rawComponent: s.rawComponent,
+ preformatted: slices.Clip(s.preformatted),
+ groupPrefix: s.groupPrefix,
+ groups: slices.Clip(s.groups),
+ nOpenGroups: s.nOpenGroups,
+ out: s.out,
+ }
+}
+
+// WithAttrs clones the current handler with the provided attributes
+// added to any existing attributes. The values are preformatted here
+// so that they do not need to be formatted per call to Handle.
+func (s *SlogTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ if len(attrs) == 0 {
+ return s
+ }
+
+ s2 := s.clone()
+ // Pre-format the attributes as an optimization.
+ state := s2.newHandleState((*buffer)(&s2.preformatted), false)
+ defer state.free()
+ state.prefix.WriteString(s.groupPrefix)
+
+ // Remember the position in the buffer, in case all attrs are empty.
+ pos := state.buf.Len()
+ state.openGroups()
+
+ nonEmpty := false
+ for _, a := range attrs {
+ switch a.Key {
+ case logconstants.ComponentKey:
+ component := strings.ToUpper(fmt.Sprintf("[%v]", a.Value.String()))
+ if s.cfg.Padding > 0 {
+ component = padMax(component, s.cfg.Padding)
+ if component[len(component)-1] != ' ' {
+ component = component[:len(component)-1] + "]"
+ }
+ }
+ s2.component = component
+ s2.rawComponent = a.Value.String()
+ case logconstants.ComponentFields:
+ switch fields := a.Value.Any().(type) {
+ case map[string]any:
+ for k, v := range fields {
+ if state.appendAttr(slog.Any(k, v)) {
+ nonEmpty = true
+ }
+ }
+ }
+ default:
+ if state.appendAttr(a) {
+ nonEmpty = true
+ }
+ }
+ }
+
+ if !nonEmpty {
+ state.buf.SetLen(pos)
+ } else {
+ // Remember the new prefix for later keys.
+ s2.groupPrefix = state.prefix.String()
+ // Remember how many opened groups are in preformattedAttrs,
+ // so we don't open them again when we handle a Record.
+ s2.nOpenGroups = len(s2.groups)
+ }
+
+ return s2
+}
+
+// WithGroup opens a new group.
+func (s *SlogTextHandler) WithGroup(name string) slog.Handler {
+ s2 := s.clone()
+ s2.groups = append(s2.groups, name)
+ return s2
+}
+
+type ioWriter struct {
+ mu sync.Mutex
+ out io.Writer
+}
+
+func newIOWriter(w io.Writer) *ioWriter {
+ return &ioWriter{out: w}
+}
+
+func (o *ioWriter) Write(bytes []byte, rawComponent string, level slog.Level) error {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+
+ _, err := o.out.Write(bytes)
+ return err
+}
diff --git a/lib/srv/reexec.go b/session/reexec/reexec.go
similarity index 87%
rename from lib/srv/reexec.go
rename to session/reexec/reexec.go
index 540bad44e30..c92d95267c4 100644
--- a/lib/srv/reexec.go
+++ b/session/reexec/reexec.go
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-package srv
+package reexec
import (
"bytes"
@@ -44,19 +44,18 @@ import (
ocselinux "github.com/opencontainers/selinux/go-selinux"
"golang.org/x/sys/unix"
- "github.com/gravitational/teleport"
- "github.com/gravitational/teleport/api/types"
- "github.com/gravitational/teleport/lib/sshutils"
- "github.com/gravitational/teleport/lib/utils"
- logutils "github.com/gravitational/teleport/lib/utils/log"
+ apiconstants "github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/session/auditd"
"github.com/gravitational/teleport/session/envutils"
"github.com/gravitational/teleport/session/host"
+ "github.com/gravitational/teleport/session/logconstants"
"github.com/gravitational/teleport/session/loginuid"
"github.com/gravitational/teleport/session/networking"
"github.com/gravitational/teleport/session/networking/x11"
"github.com/gravitational/teleport/session/pam"
"github.com/gravitational/teleport/session/pam/pamcfg"
+ "github.com/gravitational/teleport/session/reexec/internal/logutils"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
"github.com/gravitational/teleport/session/selinux"
"github.com/gravitational/teleport/session/shell"
"github.com/gravitational/teleport/session/uacc"
@@ -141,9 +140,9 @@ func fdName(f FileFD) string {
// ExecCommand contains the payload to "teleport exec" which will be used to
// construct and execute a shell.
type ExecCommand struct {
- stdin io.Reader
- stdout io.Writer
- stderr io.Writer
+ Stdin io.Reader `json:"-"`
+ Stdout io.Writer `json:"-"`
+ Stderr io.Writer `json:"-"`
// LogConfig is the log configuration for the child process.
LogConfig ExecLogConfig `json:"log_config"`
@@ -245,7 +244,7 @@ type PAMConfig struct {
// UaccMetadata contains information the child needs from the parent for user accounting.
type UaccMetadata struct {
// RemoteAddr is the address of the remote host.
- RemoteAddr utils.NetAddr `json:"remote_addr"`
+ RemoteAddr NetAddr `json:"remote_addr"`
// UtmpPath is the path of the system utmp database.
UtmpPath string `json:"utmp_path,omitempty"`
@@ -260,6 +259,28 @@ type UaccMetadata struct {
WtmpdbPath string `json:"wtmpdb_path,omitempty"`
}
+// NetAddrFromAddr returns NetAddr from golang standard net.Addr
+func NetAddrFromAddr(a net.Addr) NetAddr {
+ return NetAddr{AddrNetwork: a.Network(), Addr: a.String()}
+}
+
+type NetAddr struct {
+ AddrNetwork string `json:"network"`
+ Addr string `json:"addr"`
+}
+
+var _ net.Addr = (*NetAddr)(nil)
+
+// Network implements [net.Addr].
+func (n *NetAddr) Network() string {
+ return n.AddrNetwork
+}
+
+// String implements [net.Addr].
+func (n *NetAddr) String() string {
+ return n.Addr
+}
+
// RunCommand reads in the command to run from the parent process (over a
// pipe) then constructs and runs the command. This function may change
// system state related to the process and/or thread for PAM and SELinux.
@@ -361,28 +382,28 @@ func RunCommand() (exitErr error, err error) {
if tty == nil {
return nil, trace.BadParameter("tty not found")
}
- c.stdin = tty
- c.stdout = tty
- c.stderr = tty
- } else if c.RequestType == sshutils.SubsystemRequest && c.Command == teleport.SFTPSubsystem {
+ c.Stdin = tty
+ c.Stdout = tty
+ c.Stderr = tty
+ } else if c.RequestType == "subsystem" && c.Command == "sftp" {
// std{in/out} is not used by the SFTP sub process, just collect stderr.
- c.stdin = bytes.NewReader(nil)
- c.stdout = io.Discard
+ c.Stdin = bytes.NewReader(nil)
+ c.Stdout = io.Discard
// Propagate sftp subprocess errors to the parent process.
- c.stderr = os.Stderr
+ c.Stderr = os.Stderr
} else {
// If this is a normal, non-interactive exec session, use the stdio pipes provided as extra files.
- c.stdin = os.NewFile(StdinFile, fdName(StdinFile))
- if c.stdin == nil {
+ c.Stdin = os.NewFile(StdinFile, fdName(StdinFile))
+ if c.Stdin == nil {
return nil, trace.BadParameter("stdin not found")
}
- c.stdout = os.NewFile(StdoutFile, fdName(StdoutFile))
- if c.stdout == nil {
+ c.Stdout = os.NewFile(StdoutFile, fdName(StdoutFile))
+ if c.Stdout == nil {
return nil, trace.BadParameter("stdout not found")
}
- c.stderr = os.NewFile(StderrFile, fdName(StderrFile))
- if c.stderr == nil {
+ c.Stderr = os.NewFile(StderrFile, fdName(StderrFile))
+ if c.Stderr == nil {
return nil, trace.BadParameter("stderr not found")
}
}
@@ -403,9 +424,9 @@ func RunCommand() (exitErr error, err error) {
// account/session.
Env: c.PAMConfig.Environment,
// Connect std{in,out,err} to the TTY if a terminal has been allocated.
- Stdin: c.stdin,
- Stdout: c.stdout,
- Stderr: c.stderr,
+ Stdin: c.Stdin,
+ Stdout: c.Stdout,
+ Stderr: c.Stderr,
}
// Discard std{out,err} for non-interactive requests. Otherwise, things like
@@ -467,7 +488,7 @@ func RunCommand() (exitErr error, err error) {
}
// Build the actual command that will launch the shell.
- cmd, err := buildCommand(&c, localUser, pamEnvironment)
+ cmd, err := BuildCommand(&c, localUser, pamEnvironment)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -475,7 +496,7 @@ func RunCommand() (exitErr error, err error) {
// Wait until the continue signal is received from Teleport signaling that
// Teleport is monitoring this session if Enhanced Session Recording is enabled.
if c.RecordWithBPF {
- err = waitForSignal(ctx, contfd, 10*time.Second)
+ err = WaitForSignal(ctx, contfd, 10*time.Second)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -677,9 +698,9 @@ func (o *osWrapper) startNewParker(ctx context.Context, credential *syscall.Cred
return nil
}
- group, err := o.LookupGroup(types.TeleportDropGroup)
+ group, err := o.LookupGroup(apiconstants.TeleportDropGroup)
if err != nil {
- if isUnknownGroupError(err, types.TeleportDropGroup) {
+ if isUnknownGroupError(err, apiconstants.TeleportDropGroup) {
// The service group doesn't exist. Auto-provision is disabled, do nothing.
return nil
}
@@ -726,21 +747,21 @@ func RunNetworking() (code int, err error) {
// Parent sends the command payload in the third file descriptor.
cmdfd := os.NewFile(CommandFile, fdName(CommandFile))
if cmdfd == nil {
- return teleport.RemoteCommandFailure, trace.BadParameter("command pipe not found")
+ return reexecconstants.RemoteCommandFailure, trace.BadParameter("command pipe not found")
}
logfd := os.NewFile(LogFile, fdName(LogFile))
if logfd == nil {
- return teleport.RemoteCommandFailure, trace.BadParameter("log pipe not found")
+ return reexecconstants.RemoteCommandFailure, trace.BadParameter("log pipe not found")
}
terminatefd := os.NewFile(TerminateFile, fdName(TerminateFile))
if terminatefd == nil {
- return teleport.RemoteCommandFailure, trace.BadParameter("terminate pipe not found")
+ return reexecconstants.RemoteCommandFailure, trace.BadParameter("terminate pipe not found")
}
// Read in the command payload.
var c ExecCommand
if err := json.NewDecoder(cmdfd).Decode(&c); err != nil {
- return teleport.RemoteCommandFailure, trace.Wrap(err)
+ return reexecconstants.RemoteCommandFailure, trace.Wrap(err)
}
initLogger("networking", logfd, c.LogConfig)
@@ -763,7 +784,7 @@ func RunNetworking() (code int, err error) {
Env: c.PAMConfig.Environment,
})
if err != nil {
- return teleport.RemoteCommandFailure, trace.Wrap(err)
+ return reexecconstants.RemoteCommandFailure, trace.Wrap(err)
}
defer pamContext.Close()
@@ -775,12 +796,12 @@ func RunNetworking() (code int, err error) {
// done with the user's permissions.
localUser, err := user.Lookup(c.Login)
if err != nil {
- return teleport.RemoteCommandFailure, trace.NotFound("%s", err)
+ return reexecconstants.RemoteCommandFailure, trace.NotFound("%s", err)
}
cred, err := host.GetHostUserCredential(localUser)
if err != nil {
- return teleport.RemoteCommandFailure, trace.Wrap(err)
+ return reexecconstants.RemoteCommandFailure, trace.Wrap(err)
}
if os.Getuid() != int(cred.Uid) || os.Getgid() != int(cred.Gid) {
@@ -790,14 +811,14 @@ func RunNetworking() (code int, err error) {
groups[i] = int(g)
}
if err := unix.Setgroups(groups); err != nil {
- return teleport.RemoteCommandFailure, trace.Wrap(err, "failed to set groups for networking process")
+ return reexecconstants.RemoteCommandFailure, trace.Wrap(err, "failed to set groups for networking process")
}
}
if err := unix.Setgid(int(cred.Gid)); err != nil {
- return teleport.RemoteCommandFailure, trace.Wrap(err, "failed to set gid for networking process")
+ return reexecconstants.RemoteCommandFailure, trace.Wrap(err, "failed to set gid for networking process")
}
if err := unix.Setuid(int(cred.Uid)); err != nil {
- return teleport.RemoteCommandFailure, trace.Wrap(err, "failed to set uid for networking process")
+ return reexecconstants.RemoteCommandFailure, trace.Wrap(err, "failed to set uid for networking process")
}
}
@@ -816,27 +837,27 @@ func RunNetworking() (code int, err error) {
for _, kv := range pamEnvironment {
key, value, ok := strings.Cut(strings.TrimSpace(kv), "=")
if !ok {
- return teleport.RemoteCommandFailure, trace.BadParameter("bad environment variable from PAM, expected format \"key=value\" but got %q", kv)
+ return reexecconstants.RemoteCommandFailure, trace.BadParameter("bad environment variable from PAM, expected format \"key=value\" but got %q", kv)
}
if err := os.Setenv(key, value); err != nil {
- return teleport.RemoteCommandFailure, trace.Wrap(err)
+ return reexecconstants.RemoteCommandFailure, trace.Wrap(err)
}
}
// Ensure that the working directory is one that the local user has access to.
if err := os.Chdir(workingDir); err != nil {
- return teleport.RemoteCommandFailure, trace.Wrap(err, "failed to set working directory for networking process: %s", workingDir)
+ return reexecconstants.RemoteCommandFailure, trace.Wrap(err, "failed to set working directory for networking process: %s", workingDir)
}
ffd := os.NewFile(ListenerFile, "listener")
if ffd == nil {
- return teleport.RemoteCommandFailure, trace.BadParameter("missing socket fd")
+ return reexecconstants.RemoteCommandFailure, trace.BadParameter("missing socket fd")
}
parentConn, err := uds.FromFile(ffd)
_ = ffd.Close()
if err != nil {
- return teleport.RemoteCommandFailure, trace.Wrap(err)
+ return reexecconstants.RemoteCommandFailure, trace.Wrap(err)
}
ctx, cancel := context.WithCancel(context.Background())
@@ -865,9 +886,9 @@ func RunNetworking() (code int, err error) {
fbuf := make([]*os.File, 1)
n, fn, err := uds.ReadWithFDs(parentConn, buf, fbuf)
if err != nil {
- if utils.IsOKNetworkError(err) {
+ if isOKNetworkError(err) {
// parent connection closed, process should exit.
- return teleport.RemoteCommandSuccess, nil
+ return reexecconstants.RemoteCommandSuccess, nil
}
slog.ErrorContext(ctx, "error reading networking request from parent", "err", err)
continue
@@ -1055,15 +1076,15 @@ func getConnFile(conn net.Conn) (*os.File, error) {
// runCheckHomeDir checks if the active user's $HOME dir exists and is accessible.
func runCheckHomeDir() (code int) {
- code = teleport.RemoteCommandSuccess
+ code = reexecconstants.RemoteCommandSuccess
if err := hasAccessibleHomeDir(); err != nil {
switch {
case trace.IsNotFound(err), trace.IsBadParameter(err):
- code = teleport.HomeDirNotFound
+ code = reexecconstants.HomeDirNotFound
case trace.IsAccessDenied(err):
- code = teleport.HomeDirNotAccessible
+ code = reexecconstants.HomeDirNotAccessible
default:
- code = teleport.RemoteCommandFailure
+ code = reexecconstants.RemoteCommandFailure
}
}
@@ -1087,27 +1108,27 @@ func RunAndExit(commandType string) {
var err error
switch commandType {
- case teleport.ExecSubCommand:
+ case reexecconstants.ExecSubCommand:
var execErr error
execErr, err = RunCommand()
if err != nil {
- code = teleport.RemoteCommandFailure
+ code = reexecconstants.RemoteCommandFailure
} else {
code = exitCode(execErr)
}
- case teleport.NetworkingSubCommand:
+ case reexecconstants.NetworkingSubCommand:
code, err = RunNetworking()
- case teleport.CheckHomeDirSubCommand:
+ case reexecconstants.CheckHomeDirSubCommand:
code = runCheckHomeDir()
- case teleport.ParkSubCommand:
+ case reexecconstants.ParkSubCommand:
code = runPark()
default:
- code, err = teleport.RemoteCommandFailure, fmt.Errorf("unknown command type: %v", commandType)
+ code, err = reexecconstants.RemoteCommandFailure, fmt.Errorf("unknown command type: %v", commandType)
}
if err != nil {
// Write the error to stderr, where it can be seen by the parent teleport process and
// propagated to the client.
- if code == teleport.RemoteCommandFailure {
+ if code == reexecconstants.RemoteCommandFailure {
fmt.Fprintf(os.Stderr, "Failed to launch: %v.\n", err)
}
@@ -1128,9 +1149,9 @@ func RunAndExit(commandType string) {
func IsReexec() bool {
if len(os.Args) >= 2 {
switch os.Args[1] {
- case teleport.ExecSubCommand, teleport.NetworkingSubCommand,
- teleport.CheckHomeDirSubCommand,
- teleport.ParkSubCommand, teleport.SFTPSubCommand:
+ case reexecconstants.ExecSubCommand, reexecconstants.NetworkingSubCommand,
+ reexecconstants.CheckHomeDirSubCommand,
+ reexecconstants.ParkSubCommand, reexecconstants.SFTPSubCommand:
return true
}
}
@@ -1141,7 +1162,7 @@ func IsReexec() bool {
// openFileAsUser opens a file as the given user to ensure proper access checks. This is unsafe and should not be used outside of
// bootstrapping reexec commands.
func openFileAsUser(localUser *user.User, path string) (file *os.File, err error) {
- if os.Args[1] != teleport.ExecSubCommand {
+ if os.Args[1] != reexecconstants.ExecSubCommand {
return nil, trace.Errorf("opening files as a user is only possible in a reexec context")
}
@@ -1164,7 +1185,7 @@ func openFileAsUser(localUser *user.User, path string) (file *os.File, err error
if uidErr != nil || gidErr != nil {
file.Close()
slog.ErrorContext(context.Background(), "cannot proceed with invalid effective credentials", "uid_err", uidErr, "gid_err", gidErr, "error", err)
- os.Exit(teleport.UnexpectedCredentials)
+ os.Exit(reexecconstants.UnexpectedCredentials)
}
}()
@@ -1176,8 +1197,8 @@ func openFileAsUser(localUser *user.User, path string) (file *os.File, err error
return nil, trace.Wrap(err)
}
- file, err = utils.OpenFileNoUnsafeLinks(path)
- return file, trace.Wrap(err)
+ file, err = os.Open(path)
+ return file, trace.ConvertSystemError(err)
}
func readUserEnv(localUser *user.User, path string) ([]string, error) {
@@ -1191,9 +1212,9 @@ func readUserEnv(localUser *user.User, path string) ([]string, error) {
return envs, trace.Wrap(err)
}
-// buildCommand constructs a command that will execute the user's shell. This
+// BuildCommand constructs a command that will execute the user's shell. This
// function is run by Teleport while it's re-executing.
-func buildCommand(c *ExecCommand, localUser *user.User, pamEnvironment []string) (*exec.Cmd, error) {
+func BuildCommand(c *ExecCommand, localUser *user.User, pamEnvironment []string) (*exec.Cmd, error) {
var cmd exec.Cmd
isReexec := false
@@ -1210,15 +1231,15 @@ func buildCommand(c *ExecCommand, localUser *user.User, pamEnvironment []string)
// if it's a normal command execution, and if no command was given,
// configure a shell to run in 'login' mode. Otherwise, execute a command
// through the shell.
- if c.RequestType == sshutils.SubsystemRequest {
+ if c.RequestType == "subsystem" {
switch c.Command {
- case teleport.SFTPSubsystem:
+ case "sftp":
executable, err := os.Executable()
if err != nil {
return nil, trace.Wrap(err)
}
cmd.Path = executable
- cmd.Args = []string{executable, teleport.SFTPSubCommand}
+ cmd.Args = []string{executable, reexecconstants.SFTPSubCommand}
isReexec = true
default:
return nil, trace.BadParameter("unsupported subsystem execution request %q", c.Command)
@@ -1243,7 +1264,7 @@ func buildCommand(c *ExecCommand, localUser *user.User, pamEnvironment []string)
// Create default environment for user.
env := &envutils.SafeEnv{
"LANG=en_US.UTF-8",
- getDefaultEnvPath(localUser.Uid, defaultLoginDefsPath),
+ GetDefaultEnvPath(localUser.Uid),
"HOME=" + localUser.HomeDir,
"USER=" + c.Login,
"SHELL=" + shellPath,
@@ -1274,9 +1295,9 @@ func buildCommand(c *ExecCommand, localUser *user.User, pamEnvironment []string)
cmd.Env = *env
// set stdio. If a terminal was requested, the stdio fields all point to the same tty file.
- cmd.Stdin = c.stdin
- cmd.Stdout = c.stdout
- cmd.Stderr = c.stderr
+ cmd.Stdin = c.Stdin
+ cmd.Stdout = c.Stdout
+ cmd.Stderr = c.Stderr
if c.Terminal {
cmd.SysProcAttr = &syscall.SysProcAttr{
@@ -1292,7 +1313,7 @@ func buildCommand(c *ExecCommand, localUser *user.User, pamEnvironment []string)
}
// Pass extra files for SFTP to grandchild.
- if c.RequestType == sshutils.SubsystemRequest && c.Command == teleport.SFTPSubsystem {
+ if c.RequestType == "subsystem" && c.Command == "sftp" {
out := os.NewFile(FileTransferOutFile, "FileTransferOutFile")
if out == nil {
return nil, trace.NotFound("FileTransferOutFile file not found")
@@ -1344,7 +1365,7 @@ func buildCommand(c *ExecCommand, localUser *user.User, pamEnvironment []string)
// Perform OS-specific tweaks to the command.
if isReexec {
- reexecCommandOSTweaks(&cmd)
+ CommandOSTweaks(&cmd)
} else {
userCommandOSTweaks(&cmd)
}
@@ -1352,98 +1373,6 @@ func buildCommand(c *ExecCommand, localUser *user.User, pamEnvironment []string)
return &cmd, nil
}
-// ConfigureCommand creates a command fully configured to execute. This
-// function is used by Teleport to re-execute itself and pass whatever data
-// is need to the child to actually execute the shell.
-func ConfigureCommand(ctx *ServerContext, extraFiles ...*os.File) (*exec.Cmd, error) {
- // Create a os.Pipe and start copying over the payload to execute. While the
- // pipe buffer is quite large (64k) some users have run into the pipe
- // blocking writes on much smaller buffers (7k) leading to Teleport being
- // unable to run some exec commands.
- //
- // To not depend on the OS implementation of a pipe, instead the copy should
- // be non-blocking. The io.Copy will be closed when either when the child
- // process has fully read in the payload or the process exits with an error
- // (and closes all child file descriptors).
- //
- // See the below for details.
- //
- // https://man7.org/linux/man-pages/man7/pipe.7.html
- cmdmsg, err := ctx.ExecCommand()
- if err != nil {
- return nil, trace.Wrap(err)
- }
-
- go copyCommand(ctx.CancelContext(), ctx.cmdw, cmdmsg)
-
- // Find the Teleport executable and its directory on disk.
- executable, err := os.Executable()
- if err != nil {
- return nil, trace.Wrap(err)
- }
-
- // The channel/request type determines the subcommand to execute.
- var subCommand string
- switch ctx.ExecType {
- case teleport.NetworkingSubCommand:
- subCommand = teleport.NetworkingSubCommand
- default:
- subCommand = teleport.ExecSubCommand
- }
-
- // Build the list of arguments to have Teleport re-exec itself. The "-d" flag
- // is appended if Teleport is running in debug mode.
- args := []string{executable, subCommand}
-
- // build env for `teleport exec`
- env := &envutils.SafeEnv{}
- env.AddExecEnvironment()
-
- // Build the "teleport exec" command.
- cmd := &exec.Cmd{
- Path: executable,
- Args: args,
- Env: *env,
- ExtraFiles: []*os.File{
- ctx.cmdr,
- ctx.logw,
- ctx.contr,
- ctx.readyw,
- ctx.killShellr,
- },
- }
- // Add extra files if applicable.
- if len(extraFiles) > 0 {
- cmd.ExtraFiles = append(cmd.ExtraFiles, extraFiles...)
- }
-
- // Perform OS-specific tweaks to the command.
- reexecCommandOSTweaks(cmd)
-
- return cmd, nil
-}
-
-// copyCommand will copy the provided command to the child process over the
-// pipe attached to the context.
-func copyCommand(ctx context.Context, cmdw *os.File, cmdmsg *ExecCommand) {
- defer func() {
- err := cmdw.Close()
- if err != nil {
- slog.ErrorContext(ctx, "Failed to close command pipe", "error", err)
- }
-
- // Set to nil so the close in the context doesn't attempt to re-close.
- cmdw = nil
- }()
-
- // Write command bytes to pipe. The child process will read the command
- // to execute from this pipe.
- if err := json.NewEncoder(cmdw).Encode(cmdmsg); err != nil {
- slog.ErrorContext(ctx, "Failed to copy command over pipe", "error", err)
- return
- }
-}
-
func coerceHomeDirError(usr *user.User, err error) error {
if os.IsNotExist(err) {
return trace.NotFound("home directory %q not found for user %q", usr.HomeDir, usr.Name)
@@ -1539,7 +1468,7 @@ func CheckHomeDir(localUser *user.User) (bool, error) {
// Build the "teleport exec" command.
cmd := &exec.Cmd{
Path: executable,
- Args: []string{executable, teleport.CheckHomeDirSubCommand},
+ Args: []string{executable, reexecconstants.CheckHomeDirSubCommand},
Env: []string{"HOME=" + localUser.HomeDir},
Dir: rootDirectory,
SysProcAttr: &syscall.SysProcAttr{
@@ -1549,10 +1478,10 @@ func CheckHomeDir(localUser *user.User) (bool, error) {
}
// Perform OS-specific tweaks to the command.
- reexecCommandOSTweaks(cmd)
+ CommandOSTweaks(cmd)
if err := cmd.Run(); err != nil {
- if cmd.ProcessState.ExitCode() == teleport.RemoteCommandFailure {
+ if cmd.ProcessState.ExitCode() == reexecconstants.RemoteCommandFailure {
return false, trace.Wrap(err)
}
@@ -1569,7 +1498,7 @@ func (o *osWrapper) newParker(ctx context.Context, credential syscall.Credential
return trace.Wrap(err)
}
- cmd := o.CommandContext(ctx, executable, teleport.ParkSubCommand)
+ cmd := o.CommandContext(ctx, executable, reexecconstants.ParkSubCommand)
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &credential,
}
@@ -1588,9 +1517,9 @@ func (o *osWrapper) newParker(ctx context.Context, credential syscall.Credential
return nil
}
-// waitForSignal will wait for the other side of the pipe to signal, if not
+// WaitForSignal will wait for the other side of the pipe to signal, if not
// received, it will stop waiting and exit.
-func waitForSignal(ctx context.Context, fd *os.File, timeout time.Duration) error {
+func WaitForSignal(ctx context.Context, fd *os.File, timeout time.Duration) error {
waitCh := make(chan error, 1)
go func() {
// Reading from the file descriptor will block until it's closed.
@@ -1615,6 +1544,9 @@ func waitForSignal(ctx context.Context, fd *os.File, timeout time.Duration) erro
}
}
+// TODO(espadolini): pass slog records in a fixed format to the parent process
+// rather than handling the formatting here, so we can get rid of
+// internal/logutils
func initLogger(name string, logWriter *os.File, cfg ExecLogConfig) {
fields, err := logutils.ValidateFields(cfg.ExtraFields)
if err != nil {
@@ -1629,14 +1561,47 @@ func initLogger(name string, logWriter *os.File, cfg ExecLogConfig) {
ConfiguredFields: fields,
Padding: cfg.Padding,
}))
- slog.SetDefault(logger.With(teleport.ComponentKey, name))
+ slog.SetDefault(logger.With(logconstants.ComponentKey, name))
case "json":
logger := slog.New(logutils.NewSlogJSONHandler(logWriter, logutils.SlogJSONHandlerConfig{
Level: cfg.Level,
ConfiguredFields: fields,
}))
- slog.SetDefault(logger.With(teleport.ComponentKey, name))
+ slog.SetDefault(logger.With(logconstants.ComponentKey, name))
default:
return
}
}
+
+// isUseOfClosedNetworkError is [utils.IsUseOfClosedNetworkError].
+func isUseOfClosedNetworkError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ return errors.Is(err, net.ErrClosed) || strings.Contains(err.Error(), apiconstants.UseOfClosedNetworkConnection)
+}
+
+// isFailedToSendCloseNotifyError is [utils.IsFailedToSendCloseNotifyError].
+func isFailedToSendCloseNotifyError(err error) bool {
+ if err == nil {
+ return false
+ }
+ return strings.Contains(err.Error(), apiconstants.FailedToSendCloseNotify)
+}
+
+// isOKNetworkError is [utils.IsOKNetworkError].
+func isOKNetworkError(err error) bool {
+ // trace.Aggregate contains at least one error and all the errors are
+ // non-nil
+ var a trace.Aggregate
+ if errors.As(trace.Unwrap(err), &a) {
+ for _, err := range a.Errors() {
+ if !isOKNetworkError(err) {
+ return false
+ }
+ }
+ return true
+ }
+ return errors.Is(err, io.EOF) || isUseOfClosedNetworkError(err) || isFailedToSendCloseNotifyError(err)
+}
diff --git a/lib/srv/reexec_linux.go b/session/reexec/reexec_linux.go
similarity index 98%
rename from lib/srv/reexec_linux.go
rename to session/reexec/reexec_linux.go
index 9cb102ebdc7..21b04c1e891 100644
--- a/lib/srv/reexec_linux.go
+++ b/session/reexec/reexec_linux.go
@@ -18,7 +18,7 @@
* along with this program. If not, see .
*/
-package srv
+package reexec
import (
"os"
@@ -64,7 +64,7 @@ func init() {
// passed to reexecCommandOSTweaks, if not empty.
var reexecPath string
-func reexecCommandOSTweaks(cmd *exec.Cmd) {
+func CommandOSTweaks(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = new(syscall.SysProcAttr)
}
@@ -85,7 +85,7 @@ func reexecCommandOSTweaks(cmd *exec.Cmd) {
// we should rework the parker to block on a pipe so it can exit when its parent
// is terminated
func parkerCommandOSTweaks(cmd *exec.Cmd) {
- reexecCommandOSTweaks(cmd)
+ CommandOSTweaks(cmd)
// parker processes can leak if their PDEATHSIG is SIGQUIT, otherwise we
// could just use reexecCommandOSTweaks
diff --git a/lib/srv/reexec_other.go b/session/reexec/reexec_other.go
similarity index 94%
rename from lib/srv/reexec_other.go
rename to session/reexec/reexec_other.go
index 6f90d377ab9..33a1b223f63 100644
--- a/lib/srv/reexec_other.go
+++ b/session/reexec/reexec_other.go
@@ -18,13 +18,13 @@
* along with this program. If not, see .
*/
-package srv
+package reexec
import (
"os/exec"
)
-func reexecCommandOSTweaks(cmd *exec.Cmd) {}
+func CommandOSTweaks(cmd *exec.Cmd) {}
func parkerCommandOSTweaks(cmd *exec.Cmd) {}
diff --git a/session/reexec/reexec_test.go b/session/reexec/reexec_test.go
new file mode 100644
index 00000000000..7a7e1c34a17
--- /dev/null
+++ b/session/reexec/reexec_test.go
@@ -0,0 +1,359 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package reexec
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "syscall"
+ "testing"
+
+ "github.com/gravitational/trace"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ apiconstants "github.com/gravitational/teleport/api/constants"
+ "github.com/gravitational/teleport/session/host"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
+)
+
+type stubUser struct {
+ gid string
+ uid string
+ groupIDS []string
+}
+
+func (s *stubUser) GID() string {
+ return s.gid
+}
+
+func (s *stubUser) UID() string {
+ return s.uid
+}
+
+func (s *stubUser) GroupIds() ([]string, error) {
+ return s.groupIDS, nil
+}
+
+func TestStartNewParker(t *testing.T) {
+ currentUser, err := user.Current()
+ require.NoError(t, err)
+ currentUID, err := strconv.ParseUint(currentUser.Uid, 10, 32)
+ require.NoError(t, err)
+ currentGID, err := strconv.ParseUint(currentUser.Gid, 10, 32)
+ require.NoError(t, err)
+
+ t.Parallel()
+
+ type args struct {
+ credential *syscall.Credential
+ loginAsUser string
+ localUser *stubUser
+ }
+ tests := []struct {
+ name string
+ args args
+ newOsPack func(t *testing.T) (*osWrapper, func())
+ wantErr require.ErrorAssertionFunc
+ }{
+ {
+ name: "empty credentials does nothing",
+ wantErr: require.NoError,
+ newOsPack: func(t *testing.T) (*osWrapper, func()) {
+ return &osWrapper{}, func() {}
+ },
+ },
+ {
+ name: "missing Teleport group returns no error",
+ wantErr: require.NoError,
+ newOsPack: func(t *testing.T) (*osWrapper, func()) {
+ return &osWrapper{
+ LookupGroup: func(name string) (*user.Group, error) {
+ require.Equal(t, apiconstants.TeleportDropGroup, name)
+ return nil, user.UnknownGroupError(apiconstants.TeleportDropGroup)
+ },
+ }, func() {}
+ },
+ },
+ {
+ name: "different group doesn't start parker",
+ wantErr: require.NoError,
+ newOsPack: func(t *testing.T) (*osWrapper, func()) {
+ return &osWrapper{
+ LookupGroup: func(name string) (*user.Group, error) {
+ require.Equal(t, apiconstants.TeleportDropGroup, name)
+ return &user.Group{Gid: "1234"}, nil
+ },
+ CommandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd {
+ require.FailNow(t, "CommandContext should not be called")
+ return nil
+ },
+ }, func() {}
+ },
+ args: args{
+ credential: &syscall.Credential{Gid: 1000},
+ localUser: &stubUser{
+ uid: "1001",
+ gid: "1003",
+ groupIDS: []string{"1003"},
+ },
+ },
+ },
+ {
+ name: "parker is started",
+ wantErr: require.NoError,
+ newOsPack: func(t *testing.T) (*osWrapper, func()) {
+ parkerStarted := false
+
+ return &osWrapper{
+ LookupGroup: func(name string) (*user.Group, error) {
+ require.Equal(t, apiconstants.TeleportDropGroup, name)
+ return &user.Group{Gid: currentUser.Gid}, nil
+ },
+ CommandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd {
+ require.NotNil(t, ctx)
+ require.Len(t, arg, 1)
+ require.Equal(t, reexecconstants.ParkSubCommand, arg[0])
+ parkerStarted = true
+ return exec.CommandContext(ctx, name, arg...)
+ },
+ LookupUser: func(username string) (*user.User, error) {
+ return &user.User{Uid: currentUser.Uid, Gid: currentUser.Gid}, nil
+ },
+ }, func() {
+ require.True(t, parkerStarted, "parker process didn't start")
+ }
+ },
+ args: args{
+ credential: &syscall.Credential{
+ Uid: uint32(currentUID),
+ Gid: uint32(currentGID),
+ // Changing to false causes "fork/exec /proc/self/exe: operation not permitted"
+ // to be returned when creating the park process.
+ NoSetGroups: true,
+ },
+ localUser: &stubUser{
+ uid: currentUser.Uid,
+ gid: currentUser.Gid,
+ groupIDS: []string{currentUser.Gid},
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ osPack, assertExpected := tt.newOsPack(t)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel) // cancel to stop the park process.
+
+ err := osPack.startNewParker(ctx, tt.args.credential, tt.args.loginAsUser, tt.args.localUser)
+ tt.wantErr(t, err, fmt.Sprintf("startNewParker(%v, %+v, %v, %+v)", ctx, tt.args.credential, tt.args.loginAsUser, tt.args.localUser))
+
+ assertExpected()
+ })
+ }
+}
+
+func TestRootCheckHomeDir(t *testing.T) {
+ requireRoot(t)
+
+ // this test manipulates global state, ensure we're not going to run it in
+ // parallel with something else
+ t.Setenv("foo", "bar")
+
+ tmp := t.TempDir()
+ require.NoError(t, os.Chmod(filepath.Dir(tmp), 0777))
+ require.NoError(t, os.Chmod(tmp, 0777))
+
+ home := filepath.Join(tmp, "home")
+ noAccess := filepath.Join(tmp, "no_access")
+ file := filepath.Join(tmp, "file")
+ notFound := filepath.Join(tmp, "not_found")
+
+ require.NoError(t, os.Mkdir(home, 0700))
+ require.NoError(t, os.Mkdir(noAccess, 0700))
+ _, err := os.Create(file)
+ require.NoError(t, err)
+
+ login := generateLocalUsername(t)
+ _, err = host.UserAdd(login, nil, host.UserOpts{Home: home})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ // change back to accessible home so deletion works
+ changeHomeDir(t, login, home)
+ _, err := host.UserDel(login)
+ require.NoError(t, err)
+ })
+
+ testUser, err := user.Lookup(login)
+ require.NoError(t, err)
+
+ uid, err := strconv.Atoi(testUser.Uid)
+ require.NoError(t, err)
+
+ gid, err := strconv.Atoi(testUser.Gid)
+ require.NoError(t, err)
+
+ require.NoError(t, os.Chown(home, uid, gid))
+ require.NoError(t, os.Chown(file, uid, gid))
+
+ hasAccess, err := CheckHomeDir(testUser)
+ require.NoError(t, err)
+ require.True(t, hasAccess)
+
+ changeHomeDir(t, login, file)
+ hasAccess, err = CheckHomeDir(testUser)
+ require.NoError(t, err)
+ require.False(t, hasAccess)
+
+ changeHomeDir(t, login, notFound)
+ hasAccess, err = CheckHomeDir(testUser)
+ require.NoError(t, err)
+ require.False(t, hasAccess)
+
+ changeHomeDir(t, login, noAccess)
+ hasAccess, err = CheckHomeDir(testUser)
+ require.NoError(t, err)
+ require.False(t, hasAccess)
+}
+
+func changeHomeDir(t *testing.T, username, home string) {
+ usermodBin, err := exec.LookPath("usermod")
+ assert.NoError(t, err, "usermod binary must be present")
+
+ cmd := exec.Command(usermodBin, "--home", home, username)
+ _, err = cmd.CombinedOutput()
+ assert.NoError(t, err, "changing home should not error")
+ assert.Equal(t, 0, cmd.ProcessState.ExitCode(), "changing home should exit 0")
+}
+
+func TestRootOpenFileAsUser(t *testing.T) {
+ requireRoot(t)
+ euid := os.Geteuid()
+ egid := os.Getegid()
+
+ username := "processing-user"
+
+ arg := os.Args[1]
+ os.Args[1] = reexecconstants.ExecSubCommand
+ defer func() {
+ os.Args[1] = arg
+ }()
+
+ _, err := host.UserAdd(username, nil, host.UserOpts{})
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ _, err := host.UserDel(username)
+ require.NoError(t, err)
+ })
+
+ tmp := t.TempDir()
+ testFile := filepath.Join(tmp, "testfile")
+ fileContent := "one does not simply open without permission"
+
+ err = os.WriteFile(testFile, []byte(fileContent), 0777)
+ require.NoError(t, err)
+
+ testUser, err := user.Lookup(username)
+ require.NoError(t, err)
+
+ // no access
+ file, err := openFileAsUser(testUser, testFile)
+ require.True(t, trace.IsAccessDenied(err))
+ require.Nil(t, file)
+
+ // ensure we fallback to root after
+ file, err = os.Open(testFile)
+ require.NoError(t, err)
+ require.NotNil(t, file)
+ file.Close()
+
+ // has access
+ uid, err := strconv.Atoi(testUser.Uid)
+ require.NoError(t, err)
+
+ gid, err := strconv.Atoi(testUser.Gid)
+ require.NoError(t, err)
+
+ err = os.Chown(filepath.Dir(tmp), uid, gid)
+ require.NoError(t, err)
+
+ err = os.Chown(tmp, uid, gid)
+ require.NoError(t, err)
+
+ err = os.Chown(testFile, uid, gid)
+ require.NoError(t, err)
+
+ file, err = openFileAsUser(testUser, testFile)
+ require.NoError(t, err)
+ require.NotNil(t, file)
+
+ data, err := io.ReadAll(file)
+ file.Close()
+ require.NoError(t, err)
+ require.Equal(t, fileContent, string(data))
+
+ // not exist
+ file, err = openFileAsUser(testUser, filepath.Join(tmp, "no_exist"))
+ require.ErrorIs(t, err, os.ErrNotExist)
+ require.Nil(t, file)
+
+ require.Equal(t, euid, os.Geteuid())
+ require.Equal(t, egid, os.Getegid())
+}
+
+// requireRoot is [testutils.RequireRoot] but inlined.
+func requireRoot(tb testing.TB) {
+ tb.Helper()
+ if os.Geteuid() != 0 {
+ tb.Skip("This test will be skipped because tests are not being run as root.")
+ }
+}
+
+func generateUsername(tb testing.TB) string {
+ suffix := make([]byte, 8)
+ _, err := rand.Read(suffix)
+ require.NoError(tb, err)
+ return fmt.Sprintf("teleport-%x", suffix)
+}
+
+// generateLocalUsername is [testutils.GenerateLocalUsername] but inlined.
+func generateLocalUsername(tb testing.TB) string {
+ const maxAttempts = 10
+ for range maxAttempts {
+ login := generateUsername(tb)
+ _, err := user.Lookup(login)
+ if errors.Is(err, user.UnknownUserError(login)) {
+ return login
+ }
+ require.NoError(tb, err)
+ }
+ tb.Fatalf("Unable to generate unused username after %v attempts", maxAttempts)
+ return ""
+}
diff --git a/session/reexec/reexecconstants/reexecconstants.go b/session/reexec/reexecconstants/reexecconstants.go
new file mode 100644
index 00000000000..77d145bf1ec
--- /dev/null
+++ b/session/reexec/reexecconstants/reexecconstants.go
@@ -0,0 +1,59 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package reexecconstants
+
+const (
+ // ExecSubCommand is the sub-command Teleport uses to re-exec itself for
+ // command execution (exec and shells).
+ ExecSubCommand = "exec"
+
+ // NetworkingSubCommand is the sub-command Teleport uses to re-exec itself
+ // for networking operations. e.g. local/remote port forwarding, agent forwarding,
+ // or x11 forwarding.
+ NetworkingSubCommand = "networking"
+
+ // CheckHomeDirSubCommand is the sub-command Teleport uses to re-exec itself
+ // to check if the user's home directory exists.
+ CheckHomeDirSubCommand = "checkhomedir"
+
+ // ParkSubCommand is the sub-command Teleport uses to re-exec itself as a
+ // specific UID to prevent the matching user from being deleted before
+ // spawning the intended child process.
+ ParkSubCommand = "park"
+
+ // SFTPSubCommand is the sub-command Teleport uses to re-exec itself to
+ // handle SFTP connections.
+ SFTPSubCommand = "sftp"
+)
+
+const (
+ // RemoteCommandSuccess is returned when a command has successfully executed.
+ RemoteCommandSuccess = 0
+ // RemoteCommandFailure is returned when a command has failed to execute and
+ // we don't have another status code for it.
+ RemoteCommandFailure = 255
+ // HomeDirNotFound is returned when the "teleport checkhomedir" command cannot
+ // find the user's home directory.
+ HomeDirNotFound = 254
+ // HomeDirNotAccessible is returned when the "teleport checkhomedir" command has
+ // found the user's home directory, but the user does NOT have permissions to
+ // access it.
+ HomeDirNotAccessible = 253
+ // UnexpectedCredentials is returned when a command is no longer running with the expected
+ // credentials.
+ UnexpectedCredentials = 252
+)
diff --git a/session/reexec/usermgmt.go b/session/reexec/usermgmt.go
new file mode 100644
index 00000000000..652347bb73b
--- /dev/null
+++ b/session/reexec/usermgmt.go
@@ -0,0 +1,37 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package reexec
+
+import (
+ "errors"
+ "os/user"
+ "strings"
+ "syscall"
+)
+
+// isUnknownGroupError returns whether the error from LookupGroup is an unknown group error.
+//
+// LookupGroup is supposed to return an UnknownGroupError, but due to an existing issue
+// may instead return a generic "no such file or directory" error when sssd is installed
+// or "no such process" as Go std library just forwards errors returned by getgrpnam_r.
+// See github issue - https://github.com/golang/go/issues/40334
+func isUnknownGroupError(err error, groupName string) bool {
+ return errors.Is(err, user.UnknownGroupError(groupName)) ||
+ errors.Is(err, user.UnknownGroupIdError(groupName)) ||
+ strings.HasSuffix(err.Error(), syscall.ENOENT.Error()) ||
+ strings.HasSuffix(err.Error(), syscall.ESRCH.Error())
+}
diff --git a/tool/teleport/common/teleport.go b/tool/teleport/common/teleport.go
index 369b2ea0f3a..a47e4ecc2da 100644
--- a/tool/teleport/common/teleport.go
+++ b/tool/teleport/common/teleport.go
@@ -52,12 +52,13 @@ import (
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/service"
"github.com/gravitational/teleport/lib/service/servicecfg"
- "github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/sshutils/scp"
"github.com/gravitational/teleport/lib/tpm"
"github.com/gravitational/teleport/lib/utils"
logutils "github.com/gravitational/teleport/lib/utils/log"
"github.com/gravitational/teleport/lib/versioncontrol"
+ "github.com/gravitational/teleport/session/reexec"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
"github.com/gravitational/teleport/session/selinux"
)
@@ -110,11 +111,11 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
join := app.Command("join", "Join a Teleport cluster without running the Teleport daemon.")
joinOpenSSH := join.Command("openssh", "Join an SSH server to a Teleport cluster.")
scpc := app.Command("scp", "Server-side implementation of SCP.").Hidden()
- sftp := app.Command(teleport.SFTPSubCommand, "Server-side implementation of SFTP.").Hidden()
- exec := app.Command(teleport.ExecSubCommand, "Used internally by Teleport to re-exec itself to run a command.").Hidden()
- networking := app.Command(teleport.NetworkingSubCommand, "Used internally by Teleport to re-exec itself to handle networking requests.").Hidden()
- checkHomeDir := app.Command(teleport.CheckHomeDirSubCommand, "Used internally by Teleport to re-exec itself to check access to a directory.").Hidden()
- park := app.Command(teleport.ParkSubCommand, "Used internally by Teleport to re-exec itself to do nothing.").Hidden()
+ sftp := app.Command(reexecconstants.SFTPSubCommand, "Server-side implementation of SFTP.").Hidden()
+ exec := app.Command(reexecconstants.ExecSubCommand, "Used internally by Teleport to re-exec itself to run a command.").Hidden()
+ networking := app.Command(reexecconstants.NetworkingSubCommand, "Used internally by Teleport to re-exec itself to handle networking requests.").Hidden()
+ checkHomeDir := app.Command(reexecconstants.CheckHomeDirSubCommand, "Used internally by Teleport to re-exec itself to check access to a directory.").Hidden()
+ park := app.Command(reexecconstants.ParkSubCommand, "Used internally by Teleport to re-exec itself to do nothing.").Hidden()
app.HelpFlag.Short('h')
// define start flags:
@@ -747,13 +748,13 @@ Examples:
dumpFlags.Roles = defaults.RoleNode
err = onConfigDump(dumpFlags)
case exec.FullCommand():
- srv.RunAndExit(teleport.ExecSubCommand)
+ reexec.RunAndExit(reexecconstants.ExecSubCommand)
case networking.FullCommand():
- srv.RunAndExit(teleport.NetworkingSubCommand)
+ reexec.RunAndExit(reexecconstants.NetworkingSubCommand)
case checkHomeDir.FullCommand():
- srv.RunAndExit(teleport.CheckHomeDirSubCommand)
+ reexec.RunAndExit(reexecconstants.CheckHomeDirSubCommand)
case park.FullCommand():
- srv.RunAndExit(teleport.ParkSubCommand)
+ reexec.RunAndExit(reexecconstants.ParkSubCommand)
case waitNoResolveCmd.FullCommand():
err = onWaitNoResolve(waitFlags)
case waitDurationCmd.FullCommand():
diff --git a/tool/teleport/testenv/test_server.go b/tool/teleport/testenv/test_server.go
index ae5ded02304..a48cbf16c97 100644
--- a/tool/teleport/testenv/test_server.go
+++ b/tool/teleport/testenv/test_server.go
@@ -50,10 +50,10 @@ import (
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/service"
"github.com/gravitational/teleport/lib/service/servicecfg"
- "github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/utils/log/logtest"
+ "github.com/gravitational/teleport/session/reexec"
"github.com/gravitational/teleport/tool/teleport/common"
)
@@ -68,7 +68,7 @@ const StaticToken = "test-static-token"
func init() {
// If the test is re-executing itself, execute the command that comes over
// the pipe. Used to test tsh ssh and tsh scp commands.
- if srv.IsReexec() {
+ if reexec.IsReexec() {
common.Run(common.Options{Args: os.Args[1:]})
return
}
diff --git a/tool/tsh/common/tsh_test.go b/tool/tsh/common/tsh_test.go
index 686a55519d4..00d186bb88a 100644
--- a/tool/tsh/common/tsh_test.go
+++ b/tool/tsh/common/tsh_test.go
@@ -94,13 +94,14 @@ import (
"github.com/gravitational/teleport/lib/service"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/services"
- "github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/utils/log/logtest"
"github.com/gravitational/teleport/lib/utils/testutils"
"github.com/gravitational/teleport/lib/utils/testutils/golden"
"github.com/gravitational/teleport/session/networking/x11"
+ "github.com/gravitational/teleport/session/reexec"
+ "github.com/gravitational/teleport/session/reexec/reexecconstants"
"github.com/gravitational/teleport/tool/common"
testserver "github.com/gravitational/teleport/tool/teleport/testenv"
)
@@ -239,8 +240,8 @@ func handleReexec() {
}
// Re-exec teleport commands. Used to test tsh ssh command.
- if srv.IsReexec() {
- srv.RunAndExit(os.Args[1])
+ if reexec.IsReexec() {
+ reexec.RunAndExit(os.Args[1])
}
}
@@ -8452,7 +8453,7 @@ func TestReexecErrorPropagation(t *testing.T) {
var exitCodeErr *common.ExitCodeError
require.ErrorAs(t, err, &exitCodeErr)
- require.Equal(t, teleport.RemoteCommandFailure, exitCodeErr.Code)
+ require.Equal(t, reexecconstants.RemoteCommandFailure, exitCodeErr.Code)
// Check for exact match to catch regressions with new lines.
require.Equal(t, unknownUserReexecError, stdout)
@@ -8499,7 +8500,7 @@ func TestReexecErrorPropagation(t *testing.T) {
var exitCodeErr *common.ExitCodeError
require.ErrorAs(t, err, &exitCodeErr)
- require.Equal(t, teleport.RemoteCommandFailure, exitCodeErr.Code)
+ require.Equal(t, reexecconstants.RemoteCommandFailure, exitCodeErr.Code)
// Check for exact match to catch regressions with new lines.
require.Equal(t, contextualReexecErrorMessage, stdout)