diff --git a/lib/srv/bpf_test.go b/lib/srv/bpf_test.go index d1dde36a302..8d201180b20 100644 --- a/lib/srv/bpf_test.go +++ b/lib/srv/bpf_test.go @@ -1067,9 +1067,10 @@ func runCommand(t *testing.T, srv Server, bpfSrv bpf.BPF, command string, expect var wg sync.WaitGroup cmdDone := make(chan error, 1) + require.IsType(t, (*localExec)(nil), scx.execRequest) + execReq := scx.execRequest.(*localExec) + wg.Go(func() { - execReq, ok := scx.execRequest.(*localExec) - require.True(t, ok) cmdDone <- execReq.Cmd.Wait() }) @@ -1104,7 +1105,7 @@ func runCommand(t *testing.T, srv Server, bpfSrv bpf.BPF, command string, expect case <-ctx.Done(): // We're not interested in the error, we just want to clean up the // process. - _ = scx.killShellw.Close() + _ = execReq.Cmd.Kill() if !errors.Is(ctx.Err(), context.Canceled) { t.Fatal("Timed out waiting for process to finish.") } diff --git a/lib/srv/ctx.go b/lib/srv/ctx.go index 02fe8e1b865..167317f2546 100644 --- a/lib/srv/ctx.go +++ b/lib/srv/ctx.go @@ -21,7 +21,6 @@ package srv import ( "context" "encoding/json" - "errors" "fmt" "io" "log/slog" @@ -412,30 +411,6 @@ type ServerContext struct { // set this field directly, use (Get|Set)SSHRequest instead. sshRequest *ssh.Request - // cmd{r,w} are used to send the command from the parent process to the - // child process. - cmdr *os.File - cmdw *os.File - - // logw is used to send logs from the child process to the parent process. - logw *os.File - - // cont{r,w} is used to send the continue signal from the parent process - // to the child process. - contr *os.File - contw *os.File - - // ready{r,w} is used to send the ready signal from the child process - // to the parent process. If ESR is enabled, the child signals after - // the audit session login ID (auid) is received. - readyr *os.File - readyw *os.File - - // killShell{r,w} are used to send kill signal to the child process - // to terminate the shell. - killShellr *os.File - killShellw *os.File - // ExecType holds the type of the channel or request. For example "session" or // "direct-tcpip". Used to create correct subcommand during re-exec. ExecType string @@ -572,78 +547,21 @@ func NewServerContext(ctx context.Context, parent *sshutils.ConnectionContext, s return nil, trace.NewAggregate(err, childErr) } - // Create pipe used to send command to child process. - child.cmdr, child.cmdw, err = os.Pipe() - if err != nil { - childErr := child.Close() - return nil, trace.NewAggregate(err, childErr) - } - child.AddCloser(child.cmdr) - child.AddCloser(child.cmdw) - - // Create pipe used to signal continue to child process. - child.contr, child.contw, err = os.Pipe() - if err != nil { - childErr := child.Close() - return nil, trace.NewAggregate(err, childErr) - } - child.AddCloser(child.contr) - child.AddCloser(child.contw) - - // Create pipe used to signal continue to parent process. - child.readyr, child.readyw, err = os.Pipe() - if err != nil { - childErr := child.Close() - return nil, trace.NewAggregate(err, childErr) - } - child.AddCloser(child.readyr) - child.AddCloser(child.readyw) - - child.killShellr, child.killShellw, err = os.Pipe() - if err != nil { - childErr := child.Close() - return nil, trace.NewAggregate(err, childErr) - } - child.AddCloser(child.killShellr) - child.AddCloser(child.killShellw) - - // If the log writer is a file, we can pass it directly to the child - // process to write to. Otherwise, we need to create a pipe to the child - // process and stream the logs to the log writer. - logCfg := child.srv.ChildLogConfig() - if fileWriter, ok := logCfg.Writer.(*os.File); ok { - child.logw = fileWriter - } else { - if err := child.streamChildLogs(logCfg.Writer); err != nil { - return nil, trace.Wrap(err) - } - } - return child, nil } -func (c *ServerContext) streamChildLogs(logCfgWriter io.Writer) error { - // Create a pipe so we can pass the writing side as an *os.File to the child process. - // Then we can copy from the reading side to the log writer (e.g. syslog, log file w/ concurrency protection). - r, w, err := os.Pipe() +func (c *ServerContext) ConfigureCommand(extraFiles map[reexec.FileFD]*os.File) (*reexec.CommandExecutor, error) { + command, err := c.ExecCommand() if err != nil { - childErr := c.Close() - return trace.NewAggregate(err, childErr) + return nil, trace.Wrap(err) + } + executor, err := reexec.ConfigureCommand(c.CancelContext(), c.Logger, c.srv.ChildLogConfig().Writer, command, c.ExecType, extraFiles) + if err != nil { + return nil, trace.Wrap(err) } - c.logw = w - c.AddCloser(r) - c.AddCloser(w) - - // Copy logs from the child process to the parent process over - // the pipe until it is closed by the child context. - go func() { - if _, err := io.Copy(logCfgWriter, r); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, os.ErrClosed) { - slog.ErrorContext(c.CancelContext(), "Failed to copy logs over pipe", "error", err) - } - }() - - return nil + c.AddCloser(executor) + return executor, nil } // Parent grants access to the connection-level context of which this @@ -1395,32 +1313,6 @@ func (c *ServerContext) ConsumeApprovedFileTransferRequest() *reexecsftp.FileTra return req } -// The child does not signal until it completes PAM setup, which can take an arbitrary -// amount of time, so we use a reasonably long timeout to avoid dubious lockouts. -const childReadyWaitTimeout = 3 * time.Minute - -// WaitForChild waits for the child process to signal ready through the named pipe. -func (c *ServerContext) WaitForChild(ctx context.Context) error { - bpfService := c.srv.GetBPF() - - // Only wait for the child to be "ready" if BPF is enabled. This is required - // because if BPF is enabled the child process will need to change its audit - // login session ID, and the we (the parent) need to wait for the session ID - // to change on the child so we can read it and use it to correlate Enhanced - // Session Recording events to the SSH session. - var waitErr error - if bpfService.Enabled() { - if waitErr = reexec.WaitForSignal(ctx, c.readyr, childReadyWaitTimeout); waitErr != nil { - c.Logger.ErrorContext(ctx, "Child process never became ready.", "error", waitErr) - } - } - - closeErr := c.readyr.Close() - // Set to nil so the close in the context doesn't attempt to re-close. - c.readyr = nil - return trace.NewAggregate(waitErr, closeErr) -} - // ServerMetadata returns ServerMetadata for this server context. func (c *ServerContext) ServerMetadata() apievents.ServerMetadata { return c.GetServer().EventMetadata() diff --git a/lib/srv/exec.go b/lib/srv/exec.go index ed7e45bf928..603a7daa5d7 100644 --- a/lib/srv/exec.go +++ b/lib/srv/exec.go @@ -20,7 +20,6 @@ package srv import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -43,8 +42,6 @@ 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/session/envutils" - "github.com/gravitational/teleport/session/networking/x11" sessionreexec "github.com/gravitational/teleport/session/reexec" "github.com/gravitational/teleport/session/reexec/reexecconstants" ) @@ -126,7 +123,7 @@ type localExec struct { Command string // Cmd holds an *exec.Cmd which will be used for local execution. - Cmd *exec.Cmd + Cmd *sessionreexec.CommandExecutor // Ctx holds the *ServerContext. Ctx *ServerContext @@ -186,7 +183,11 @@ func (e *localExec) Start(ctx context.Context, channel ssh.Channel) error { e.Ctx.AddCloser(shellStderrR) // Create the command that will actually execute. - e.Cmd, err = ConfigureCommand(e.Ctx, shellStdinR, shellStdoutW, shellStderrW) + e.Cmd, err = e.Ctx.ConfigureCommand(map[sessionreexec.FileFD]*os.File{ + sessionreexec.StdinFile: shellStdinR, + sessionreexec.StdoutFile: shellStdoutW, + sessionreexec.StderrFile: shellStderrW, + }) if err != nil { return trace.Wrap(err) } @@ -234,12 +235,6 @@ func (e *localExec) Start(ctx context.Context, channel ssh.Channel) error { return trace.ConvertSystemError(err) } - // Close our half of the write pipe since it is only to be used by the child process. - // Not closing prevents being signaled when the child closes its half. - if err := e.Ctx.readyw.Close(); err != nil { - logger.WarnContext(ctx, "Failed to close parent process audit session ID signal write fd", "error", err) - } - e.Ctx.readyw = nil // Save off the PID of the Teleport process under which the command is executing. e.pid = e.Cmd.Process.Pid @@ -314,7 +309,7 @@ func (e *localExec) ReadAuditSessionID() (uint32, error) { return 0, nil } - if err := e.Ctx.WaitForChild(e.Ctx.cancelContext); err != nil { + if err := e.Cmd.WaitForChild(); err != nil { return 0, trace.Wrap(err) } @@ -325,10 +320,7 @@ func (e *localExec) ReadAuditSessionID() (uint32, error) { // pre-processing routine if Enhanced Session Recording is enabled. // Otherwise, this method is a no-op. func (e *localExec) Continue() { - e.Ctx.contw.Close() - - // Set to nil so the close in the context doesn't attempt to re-close. - e.Ctx.contw = nil + e.Cmd.Continue() } // PID returns the PID of the Teleport process that was re-execed. @@ -671,100 +663,3 @@ func exitCode(err error) int { 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) - } - - // Build env for `teleport exec`. - env := &envutils.SafeEnv{} - env.AddExecEnvironment() - - // The channel/request type determines the subcommand to execute. - var subCommand string - switch ctx.ExecType { - case reexecconstants.NetworkingSubCommand: - subCommand = reexecconstants.NetworkingSubCommand - - // Unset XAUTHORITY for the networking command as the SSH session - // process given to the user will not have it set which can cause - // issues with the X11 forwarding. - env.Remove(x11.XAuthFileEnvVar) - 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 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 323854497af..945df40fb97 100644 --- a/lib/srv/exec_linux_test.go +++ b/lib/srv/exec_linux_test.go @@ -165,7 +165,7 @@ func TestConfigureCommand(t *testing.T) { // environment values in the server context should not be forwarded scx.SetEnv(unexpectedKey, unexpectedValue) - cmd, err := ConfigureCommand(scx) + cmd, err := scx.ConfigureCommand(nil) require.NoError(t, err) require.NotNil(t, cmd) @@ -187,8 +187,18 @@ func TestContinue(t *testing.T) { require.NoError(t, err) scx.execRequest.SetCommand(lsPath) + r, w, err := os.Pipe() + require.NoError(t, err) + + defer r.Close() + defer w.Close() + // Create an exec.Cmd to execute through Teleport. - cmd, err := ConfigureCommand(scx) + cmd, err := scx.ConfigureCommand(map[reexec.FileFD]*os.File{ + reexec.StdinFile: r, + reexec.StdoutFile: w, + reexec.StderrFile: w, + }) require.NoError(t, err) // Create a channel that will be used to signal that execution is complete. @@ -205,7 +215,8 @@ func TestContinue(t *testing.T) { }() // Signal to child that it may execute the requested program. - scx.execRequest.Continue() + err = cmd.Continue() + require.NoError(t, err) // Program should have executed now. If the complete signal has not come // over the context, something failed. diff --git a/lib/srv/mock_test.go b/lib/srv/mock_test.go index bc9ba9a2d3e..ca3d7e7dfe7 100644 --- a/lib/srv/mock_test.go +++ b/lib/srv/mock_test.go @@ -106,26 +106,6 @@ func newTestServerContext(t *testing.T, srv Server, sessionJoiningRoleSet servic err = scx.SetExecRequest(&localExec{Ctx: scx}) require.NoError(t, err) - scx.cmdr, scx.cmdw, err = os.Pipe() - require.NoError(t, err) - - logCfgWriter := srv.ChildLogConfig().Writer - if fileWriter, ok := logCfgWriter.(*os.File); ok { - scx.logw = fileWriter - } else { - require.NoError(t, scx.streamChildLogs(logCfgWriter)) - } - - scx.contr, scx.contw, err = os.Pipe() - require.NoError(t, err) - - scx.readyr, scx.readyw, err = os.Pipe() - require.NoError(t, err) - - scx.killShellr, scx.killShellw, err = os.Pipe() - require.NoError(t, err) - scx.AddCloser(scx.killShellw) - // TODO (joerger): check the error coming from Close once the logic around // closing open files has been fixed to fail with "close |1: file already closed". // Note that outside of tests, we never check the error form scx.Close because this diff --git a/lib/srv/reexec_test.go b/lib/srv/reexec_test.go index a5b2f3ceea7..6a612e277f9 100644 --- a/lib/srv/reexec_test.go +++ b/lib/srv/reexec_test.go @@ -96,9 +96,9 @@ func testNetworkingCommand(t *testing.T, login string) { } // Start networking subprocess. - command, err := ConfigureCommand(scx) + command, err := scx.ConfigureCommand(nil) require.NoError(t, err) - proc, err := networking.NewProcess(ctx, command) + proc, err := networking.NewProcess(ctx, command.Cmd) require.NoError(t, err) t.Cleanup(func() { proc.Close() }) diff --git a/lib/srv/regular/sftp.go b/lib/srv/regular/sftp.go index 9abc8e4847f..ee2ec1afd09 100644 --- a/lib/srv/regular/sftp.go +++ b/lib/srv/regular/sftp.go @@ -25,7 +25,6 @@ import ( "io" "log/slog" "os" - "os/exec" "sync" "time" @@ -39,6 +38,7 @@ import ( "github.com/gravitational/teleport/lib/sshutils/reexec" sftputils "github.com/gravitational/teleport/lib/sshutils/sftp" "github.com/gravitational/teleport/lib/utils" + sessionreexec "github.com/gravitational/teleport/session/reexec" "github.com/gravitational/teleport/session/reexec/reexecconstants" "github.com/gravitational/teleport/session/reexec/reexecsftp" sessionsftputils "github.com/gravitational/teleport/session/sftputils" @@ -48,7 +48,7 @@ type sftpSubsys struct { logger *slog.Logger fileTransferReq *reexecsftp.FileTransferRequest - sftpCmd *exec.Cmd + sftpCmd *sessionreexec.CommandExecutor serverCtx *srv.ServerContext // waitForOutputStreams tracks goroutines that copy stderr/stdout from child @@ -119,8 +119,11 @@ func (s *sftpSubsys) Start(ctx context.Context, if err := serverCtx.SetSSHRequest(req); err != nil { return trace.Wrap(err) } - - s.sftpCmd, err = srv.ConfigureCommand(serverCtx, chReadPipeOut, chWritePipeIn, auditPipeIn) + s.sftpCmd, err = serverCtx.ConfigureCommand(map[sessionreexec.FileFD]*os.File{ + sessionreexec.StdinFile: chReadPipeOut, + sessionreexec.StdoutFile: chWritePipeIn, + sessionreexec.StderrFile: auditPipeIn, + }) if err != nil { return trace.Wrap(err) } @@ -158,7 +161,9 @@ func (s *sftpSubsys) Start(ctx context.Context, if err != nil { return trace.Wrap(err) } - execRequest.Continue() + if err := s.sftpCmd.Continue(); err != nil { + return trace.Wrap(err) + } // Send the file transfer request if applicable. The SFTP process // expects the file transfer request data will end with a null byte, diff --git a/lib/srv/regular/sshserver.go b/lib/srv/regular/sshserver.go index eb91290c2f0..d067d8d20af 100644 --- a/lib/srv/regular/sshserver.go +++ b/lib/srv/regular/sshserver.go @@ -1314,12 +1314,12 @@ func (s *Server) startNetworkingProcess(scx *srv.ServerContext) (*networking.Pro // Create command to re-exec Teleport which will handle networking requests. The // reason it's not done directly is because the PAM stack needs to be called // from the child process. - cmd, err := srv.ConfigureCommand(nsctx) + cmd, err := nsctx.ConfigureCommand(nil) if err != nil { return nil, trace.Wrap(err) } - proc, err := networking.NewProcess(nsctx.CancelContext(), cmd) + proc, err := networking.NewProcess(nsctx.CancelContext(), cmd.Cmd) return proc, trace.Wrap(err) } diff --git a/lib/srv/term.go b/lib/srv/term.go index 94a9a26884f..4c1a730d1f5 100644 --- a/lib/srv/term.go +++ b/lib/srv/term.go @@ -41,6 +41,7 @@ import ( tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh" rsession "github.com/gravitational/teleport/lib/session" "github.com/gravitational/teleport/lib/sshutils/reexec" + sessionreexec "github.com/gravitational/teleport/session/reexec" "github.com/gravitational/teleport/session/reexec/reexecconstants" ) @@ -141,17 +142,13 @@ type terminal struct { log *slog.Logger - cmd *exec.Cmd + cmd *sessionreexec.CommandExecutor serverContext *ServerContext pty *os.File tty *os.File ttyName string - // terminateFD when closed informs the terminal that - // the process running in the shell should be killed. - terminateFD *os.File - // waitForOutputStreams tracks goroutines that copy stderr/stdout from child // reexec and shell processes. This is necessary due to the use of custom pipes, // which exec.Cmd does not wait for closure of in cmd.Wait(). @@ -180,7 +177,6 @@ func newLocalTerminal(ctx *ServerContext) (*terminal, error) { t := &terminal{ log: logger, serverContext: ctx, - terminateFD: ctx.killShellw, pty: pty, tty: tty, ttyName: tty.Name(), @@ -223,7 +219,9 @@ func (t *terminal) Run(ctx context.Context, errorWriter io.Writer) error { var err error // Create the command that will actually execute. - t.cmd, err = ConfigureCommand(t.serverContext, tty) + t.cmd, err = t.serverContext.ConfigureCommand(map[sessionreexec.FileFD]*os.File{ + sessionreexec.TTYFile: tty, + }) if err != nil { return trace.Wrap(err) } @@ -266,12 +264,6 @@ func (t *terminal) Run(ctx context.Context, errorWriter io.Writer) error { if err := t.cmd.Start(); err != nil { return trace.Wrap(err) } - // Close our half of the write pipe since it is only to be used by the child process. - // Not closing prevents being signaled when the child closes its half. - if err := t.serverContext.readyw.Close(); err != nil { - t.log.WarnContext(ctx, "Failed to close parent process audit session ID signal write fd", "error", err) - } - t.serverContext.readyw = nil // Save off the PID of the Teleport process under which the shell is executing. t.pid = t.cmd.Process.Pid @@ -318,7 +310,7 @@ func (t *terminal) ReadAuditSessionID() (uint32, error) { return 0, nil } - if err := t.serverContext.WaitForChild(t.serverContext.cancelContext); err != nil { + if err := t.cmd.WaitForChild(); err != nil { return 0, trace.Wrap(err) } @@ -329,16 +321,18 @@ func (t *terminal) ReadAuditSessionID() (uint32, error) { // pre-processing routine if Enhanced Session Recording is enabled. // Otherwise, this method is a no-op. func (t *terminal) Continue() { - if err := t.serverContext.contw.Close(); err != nil { + if err := t.cmd.Continue(); err != nil { t.log.WarnContext(t.serverContext.CancelContext(), "failed to close server context") } } // KillUnderlyingShell tries to kill the shell/bash process and waits for the process PID to be released. func (t *terminal) KillUnderlyingShell(ctx context.Context) error { - if err := t.terminateFD.Close(); err != nil { - if !errors.Is(err, os.ErrClosed) { - t.log.DebugContext(t.serverContext.CancelContext(), "Failed to close the shell file descriptor", "error", err) + if t.cmd != nil { + if err := t.cmd.Kill(); err != nil { + if !errors.Is(err, os.ErrClosed) { + t.log.DebugContext(t.serverContext.CancelContext(), "Failed to close the shell file descriptor", "error", err) + } } } diff --git a/lib/srv/term_test.go b/lib/srv/term_test.go index bd8df0b6857..0f065f4ad98 100644 --- a/lib/srv/term_test.go +++ b/lib/srv/term_test.go @@ -120,7 +120,8 @@ func TestTerminal_KillUnderlyingShell(t *testing.T) { }() // Continue execution - scx.execRequest.Continue() + err = term.cmd.Continue() + require.NoError(t, err) ctx, cancel := context.WithTimeout(ctx, 5*time.Second) t.Cleanup(cancel) diff --git a/session/reexec/reexec.go b/session/reexec/reexec.go index 12d75b72c80..792635158d3 100644 --- a/session/reexec/reexec.go +++ b/session/reexec/reexec.go @@ -1632,3 +1632,297 @@ func isOKNetworkError(err error) bool { } return errors.Is(err, io.EOF) || isUseOfClosedNetworkError(err) || isFailedToSendCloseNotifyError(err) } + +// CommandExecutor is wrapper around *exec.Cmd that handles creating and closing pipes +// used to communicate with child process when reexecuting teleport +type CommandExecutor struct { + *exec.Cmd + + ctx context.Context + + // cont is used to send the continue signal from the parent process + // to the child process. + cont *os.File + + // ready is used to send the ready signal from the child process + // to the parent process. If ESR is enabled, the child signals after + // the audit session login ID (auid) is received. + ready *os.File + + // killShell is used to send kill signal to the child process + // to terminate the shell. + killShell *os.File + + childFiles []*os.File + parentFiles []io.Closer + + bpfEnabled bool + logger *slog.Logger +} + +func (e *CommandExecutor) childToParentPipe(fd FileFD) (*os.File, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, trace.Wrap(err) + } + if e.childFiles, err = addFile(e.childFiles, w, fd); err != nil { + r.Close() + w.Close() + return nil, trace.Wrap(err) + } + e.parentFiles = append(e.parentFiles, r) + return r, nil +} + +func (e *CommandExecutor) parentToChildPipe(fd FileFD) (*os.File, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, trace.Wrap(err) + } + if e.childFiles, err = addFile(e.childFiles, r, fd); err != nil { + r.Close() + w.Close() + return nil, trace.Wrap(err) + } + e.parentFiles = append(e.parentFiles, w) + return w, nil +} + +func addFile(slice []*os.File, file *os.File, fd FileFD) ([]*os.File, error) { + idx := int(fd) + if idx >= len(slice) { + slice = slices.Grow(slice, idx+1-len(slice)) + clear(slice[len(slice) : idx+1]) + slice = slice[:idx+1] + } + if slice[idx] != nil { + return nil, trace.BadParameter("file already exists") + } + slice[idx] = file + return slice, nil +} + +func (e *CommandExecutor) Close() error { + var errs []error + for _, closer := range e.parentFiles { + if err := closer.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + errs = append(errs, err) + } + } + for _, closer := range e.childFiles { + if closer == nil { + continue + } + if err := closer.Close(); err != nil { + errs = append(errs, err) + } + } + return trace.NewAggregate(errs...) +} + +func (e *CommandExecutor) Start() error { + if err := e.Cmd.Start(); err != nil { + return trace.Wrap(err) + } + + for i, file := range e.childFiles { + if file == nil { + continue + } + if err := file.Close(); err != nil { + e.logger.WarnContext(e.ctx, "Failed to close child fd", "error", err, "fd", i) + } + } + e.childFiles = nil + return nil +} + +// The child does not signal until it completes PAM setup, which can take an arbitrary +// amount of time, so we use a reasonably long timeout to avoid dubious lockouts. +const childReadyWaitTimeout = 3 * time.Minute + +func (e *CommandExecutor) WaitForChild() error { + if e.ready == nil { + return nil + } + var waitErr error + if e.bpfEnabled { + if waitErr = WaitForSignal(e.ctx, e.ready, childReadyWaitTimeout); waitErr != nil { + e.logger.ErrorContext(e.ctx, "Child process never became ready.", "error", waitErr) + } + } + + closeErr := e.ready.Close() + e.ready = nil + + return trace.NewAggregate(waitErr, closeErr) +} + +// Continue will resume execution of the process after it completes its +// pre-processing routine if Enhanced Session Recording is enabled. +// Otherwise, this method is a no-op. +func (e *CommandExecutor) Continue() error { + if e.cont == nil { + return nil + } + err := e.cont.Close() + e.cont = nil + return trace.Wrap(err) +} + +// Kill will send signal to the child process that it should terminate the command +func (e *CommandExecutor) Kill() error { + if e.killShell == nil { + return nil + } + err := e.killShell.Close() + e.killShell = nil + return trace.Wrap(err) +} + +// 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. +// Context passed to this function is used only for logging and waiting for +// the ready signal from child, the returned command will not be terminated +// when it's done +func ConfigureCommand(ctx context.Context, logger *slog.Logger, childLogWriter io.Writer, command *ExecCommand, execType string, extraFiles map[FileFD]*os.File) (_ *CommandExecutor, err error) { + executor := &CommandExecutor{ + ctx: ctx, + logger: logger, + } + defer func() { + if err != nil { + if closeErr := executor.Close(); closeErr != nil { + err = trace.NewAggregate(err, closeErr) + } + executor = nil + } + }() + + logFileWriter, canReuseLogWriter := childLogWriter.(*os.File) + if !canReuseLogWriter { + // Create a pipe so we can pass the writing side as an *os.File to the child process. + // Then we can copy from the reading side to the log writer (e.g. syslog, log file w/ concurrency protection). + r, err := executor.childToParentPipe(LogFile) + if err != nil { + return nil, trace.Wrap(err) + } + + // Copy logs from the child process to the parent process over + // the pipe until it is closed by the child context. + go func() { + if _, err := io.Copy(childLogWriter, r); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, os.ErrClosed) { + slog.ErrorContext(ctx, "Failed to copy logs over pipe", "error", err) + } + }() + } + cmd, err := executor.parentToChildPipe(CommandFile) + if err != nil { + return nil, trace.Wrap(err) + } + if executor.cont, err = executor.parentToChildPipe(ContinueFile); err != nil { + return nil, trace.Wrap(err) + } + if executor.killShell, err = executor.parentToChildPipe(TerminateFile); err != nil { + return nil, trace.Wrap(err) + } + if executor.ready, err = executor.childToParentPipe(ReadyFile); err != nil { + return nil, trace.Wrap(err) + } + + // 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 + buffer := &bytes.Buffer{} + if err := json.NewEncoder(buffer).Encode(command); err != nil { + return nil, trace.Wrap(err) + } + go copyCommand(ctx, cmd, buffer) + + // Find the Teleport executable and its directory on disk. + executable, err := os.Executable() + if err != nil { + return nil, trace.Wrap(err) + } + + // Build env for `teleport exec`. + env := &envutils.SafeEnv{} + env.AddExecEnvironment() + + // The channel/request type determines the subcommand to execute. + var subCommand string + switch execType { + case reexecconstants.NetworkingSubCommand: + subCommand = reexecconstants.NetworkingSubCommand + + // Unset XAUTHORITY for the networking command as the SSH session + // process given to the user will not have it set which can cause + // issues with the X11 forwarding. + env.Remove(x11.XAuthFileEnvVar) + 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} + + executor.bpfEnabled = command.RecordWithBPF + + childFiles := slices.Clone(executor.childFiles) + + if canReuseLogWriter { + childFiles, err = addFile(childFiles, logFileWriter, LogFile) + if err != nil { + return nil, trace.Wrap(err) + } + } + + for fd, file := range extraFiles { + childFiles, err = addFile(childFiles, file, fd) + if err != nil { + return nil, trace.Wrap(err) + } + } + + // Build the "teleport exec" command. + executor.Cmd = &exec.Cmd{ + Stdin: childFiles[0], + Stdout: childFiles[1], + Stderr: childFiles[2], + Path: executable, + Args: args, + Env: *env, + ExtraFiles: childFiles[3:], + } + + // Perform OS-specific tweaks to the command. + CommandOSTweaks(executor.Cmd) + + return executor, 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, buffer *bytes.Buffer) { + // Write command bytes to pipe. The child process will read the command + // to execute from this pipe. + if _, err := io.Copy(cmdw, buffer); err != nil { + slog.ErrorContext(ctx, "Failed to copy command over pipe", "error", err) + } + + if err := cmdw.Close(); err != nil { + slog.ErrorContext(ctx, "Failed to close command pipe", "error", err) + } +}