From 3da0f44c805c356b74aac24f935cfe9e6cc7628a Mon Sep 17 00:00:00 2001 From: Edoardo Spadolini Date: Fri, 17 Apr 2026 16:16:53 +0200 Subject: [PATCH] session-helper: embeddable binary and embedding logic (#65784) * Clean up unneeded forwarder for reexec.IsReexec * Disallow reexec through common.Run * Remove the checkhomedir SSH subsystem * Add true reexec subcommand * Assume /proc/self/exe is always usable * Add sessionhelper embed logic --- constants.go | 4 - go.mod | 2 +- lib/service/service.go | 27 +++++ lib/srv/regular/get_home_dir.go | 70 ----------- lib/srv/regular/sshserver.go | 3 - lib/srv/regular/sshserver_test.go | 4 +- session/cmd/sessionhelper/sessionhelper.go | 36 ++++++ session/reexec/.gitignore | 1 + session/reexec/embed.go | 28 +++++ session/reexec/embed_embed_linux.go | 78 ++++++++++++ session/reexec/embed_embed_linux_386.go | 24 ++++ session/reexec/embed_embed_linux_amd64.go | 24 ++++ session/reexec/embed_embed_linux_arm.go | 24 ++++ session/reexec/embed_embed_linux_arm64.go | 24 ++++ session/reexec/embed_linux.go | 112 ++++++++++++++++++ session/reexec/embed_linux_test.go | 73 ++++++++++++ .../reexec/embed_noembed.go | 13 +- session/reexec/embed_noembed_linux.go | 25 ++++ session/reexec/reexec.go | 11 +- session/reexec/reexec_linux.go | 57 ++------- session/reexec/reexec_test.go | 8 +- .../reexec/reexecconstants/reexecconstants.go | 3 + tool/teleport/common/teleport.go | 25 ++-- 23 files changed, 517 insertions(+), 159 deletions(-) delete mode 100644 lib/srv/regular/get_home_dir.go create mode 100644 session/cmd/sessionhelper/sessionhelper.go create mode 100644 session/reexec/.gitignore create mode 100644 session/reexec/embed.go create mode 100644 session/reexec/embed_embed_linux.go create mode 100644 session/reexec/embed_embed_linux_386.go create mode 100644 session/reexec/embed_embed_linux_amd64.go create mode 100644 session/reexec/embed_embed_linux_arm.go create mode 100644 session/reexec/embed_embed_linux_arm64.go create mode 100644 session/reexec/embed_linux.go create mode 100644 session/reexec/embed_linux_test.go rename lib/srv/reexec_inline.go => session/reexec/embed_noembed.go (83%) create mode 100644 session/reexec/embed_noembed_linux.go diff --git a/constants.go b/constants.go index 8c89395e136..8b19cfeacc1 100644 --- a/constants.go +++ b/constants.go @@ -962,10 +962,6 @@ const ( ) const ( - // GetHomeDirSubsystem is an SSH subsystem request that Teleport - // uses to get the home directory of a remote user. - GetHomeDirSubsystem = "gethomedir" - // SFTPSubsystem is the SFTP SSH subsystem. SFTPSubsystem = "sftp" ) diff --git a/go.mod b/go.mod index 89e16e32c74..3f7b8b2a6d2 100644 --- a/go.mod +++ b/go.mod @@ -176,6 +176,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/julienschmidt/httprouter v1.3.0 github.com/keys-pub/go-libfido2 v1.5.3-0.20220306005615-8ab03fb1ec27 + github.com/klauspost/compress v1.18.5 github.com/mailgun/mailgun-go/v4 v4.23.0 github.com/mark3labs/mcp-go v0.43.0 github.com/mattn/go-shellwords v1.0.12 @@ -468,7 +469,6 @@ require ( github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/klauspost/asmfmt v1.3.2 // indirect - github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/kr/fs v0.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect diff --git a/lib/service/service.go b/lib/service/service.go index 537518a23d2..8178a7b2456 100644 --- a/lib/service/service.go +++ b/lib/service/service.go @@ -195,6 +195,7 @@ import ( webapp "github.com/gravitational/teleport/lib/web/app" "github.com/gravitational/teleport/session/auditd" "github.com/gravitational/teleport/session/pam" + "github.com/gravitational/teleport/session/reexec" "github.com/gravitational/teleport/session/selinux" ) @@ -3536,6 +3537,15 @@ func (process *TeleportProcess) initSSH() error { logger.WarnContext(process.ExitContext(), warn) } + // TODO(espadolini): relax this once the selinux module is updated to support the potentially embedded reexec helper + if !cfg.SSH.EnableSELinux { + checkEmbeddedReexecAndLog(process.ExitContext(), logger) + } else { + logger.DebugContext(process.ExitContext(), + "The embedded session helper is not supported when SELinux support is enabled.", + ) + } + useLocalListener := cfg.SSH.ForceListen || !conn.UseTunnel() // Provide helpful log message if listen_addr or public_addr are not being @@ -3828,6 +3838,23 @@ func (process *TeleportProcess) initSSH() error { return nil } +func checkEmbeddedReexecAndLog(ctx context.Context, logger *slog.Logger) { + if ok, err := reexec.InitEmbeddedReexec(); err != nil { + logger.WarnContext(ctx, + "This Teleport build supports the embedded session helper but it is not available in this environment, performance of user sessions might be impacted.", + "error", err, + ) + } else if ok { + logger.DebugContext(ctx, + "The embedded session helper is available and will be used for user sessions.", + ) + } else { + logger.DebugContext(ctx, + "This Teleport build does not support the embedded session helper for user sessions.", + ) + } +} + // RegisterWithAuthServer uses one time provisioning token obtained earlier // from the server to get a pair of SSH keys signed by Auth server host // certificate authority diff --git a/lib/srv/regular/get_home_dir.go b/lib/srv/regular/get_home_dir.go deleted file mode 100644 index a2abbeaea27..00000000000 --- a/lib/srv/regular/get_home_dir.go +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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 regular - -import ( - "context" - "io" - "os" - "os/user" - "strings" - - "github.com/gravitational/trace" - "golang.org/x/crypto/ssh" - - "github.com/gravitational/teleport/lib/srv" - "github.com/gravitational/teleport/session/reexec" -) - -type homeDirSubsys struct { - done chan struct{} -} - -func newHomeDirSubsys() *homeDirSubsys { - return &homeDirSubsys{ - done: make(chan struct{}), - } -} - -func (h *homeDirSubsys) Start(_ context.Context, serverConn *ssh.ServerConn, ch ssh.Channel, _ *ssh.Request, _ *srv.ServerContext) error { - defer close(h.done) - - connUser := serverConn.User() - localUser, err := user.Lookup(connUser) - if err != nil { - return trace.Wrap(err) - } - - exists, err := reexec.CheckHomeDir(localUser) - if err != nil { - return trace.Wrap(err) - } - homeDir := localUser.HomeDir - if !exists { - homeDir = string(os.PathSeparator) - } - _, err = io.Copy(ch, strings.NewReader(homeDir)) - - return trace.Wrap(err) -} - -func (h *homeDirSubsys) Wait() error { - <-h.done - return nil -} diff --git a/lib/srv/regular/sshserver.go b/lib/srv/regular/sshserver.go index 9b10dcdb33b..eb91290c2f0 100644 --- a/lib/srv/regular/sshserver.go +++ b/lib/srv/regular/sshserver.go @@ -2545,9 +2545,6 @@ func (s *Server) parseSubsystemRequest(ctx context.Context, req *ssh.Request, se } switch r.Name { - // DELETE IN 15.0.0 (deprecated, tsh will not be using this anymore) - case teleport.GetHomeDirSubsystem: - return newHomeDirSubsys(), nil case teleport.SFTPSubsystem: err := serverContext.CheckSFTPAllowed(s.reg) if err != nil { diff --git a/lib/srv/regular/sshserver_test.go b/lib/srv/regular/sshserver_test.go index cfa83c41415..2958742beef 100644 --- a/lib/srv/regular/sshserver_test.go +++ b/lib/srv/regular/sshserver_test.go @@ -2757,9 +2757,9 @@ func TestParseSubsystemRequest(t *testing.T) { wantErrInRegularMode: false, }, { - name: teleport.GetHomeDirSubsystem, + name: "gethomedir", wantErrInProxyMode: true, - wantErrInRegularMode: false, + wantErrInRegularMode: true, }, { name: "proxysites", diff --git a/session/cmd/sessionhelper/sessionhelper.go b/session/cmd/sessionhelper/sessionhelper.go new file mode 100644 index 00000000000..fbba09ed2f6 --- /dev/null +++ b/session/cmd/sessionhelper/sessionhelper.go @@ -0,0 +1,36 @@ +//go:build !sessionhelper_embed + +// 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 main + +import ( + "fmt" + "os" + + "github.com/gravitational/teleport/session/reexec" + "github.com/gravitational/teleport/session/reexec/reexecconstants" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Failed to launch: missing subcommand.\n") + os.Exit(reexecconstants.RemoteCommandFailure) + } + + reexec.RunAndExit(os.Args[1]) +} diff --git a/session/reexec/.gitignore b/session/reexec/.gitignore new file mode 100644 index 00000000000..ca0c0a68c6c --- /dev/null +++ b/session/reexec/.gitignore @@ -0,0 +1 @@ +/sessionhelper_*.gz diff --git a/session/reexec/embed.go b/session/reexec/embed.go new file mode 100644 index 00000000000..8cdb48977a9 --- /dev/null +++ b/session/reexec/embed.go @@ -0,0 +1,28 @@ +// 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 + +// InitEmbeddedReexec tries to set up the embedded session helper for execution, +// returning true if the helper is available and was tested successfully, false +// if there is no embedded session helper in the build, or an error if the +// helper is present in the build but the setup failed. This function can be +// called multiple times with no adverse effects, and, if successful, any +// subsequent call to [CommandOSTweaks] will result in a reexecution using the +// embedded helper rather than the current process. +func InitEmbeddedReexec() (bool, error) { + return initEmbeddedReexec() +} diff --git a/session/reexec/embed_embed_linux.go b/session/reexec/embed_embed_linux.go new file mode 100644 index 00000000000..f8fc8d79e9d --- /dev/null +++ b/session/reexec/embed_embed_linux.go @@ -0,0 +1,78 @@ +//go:build sessionhelper_embed + +// 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 ( + _ "embed" + "os" + "os/exec" + "sync" + "sync/atomic" + + "github.com/gravitational/trace" +) + +var embeddedReexecMemfd atomic.Pointer[os.File] + +var embeddedReexecOnce sync.Once +var embeddedReexecError error + +func initEmbeddedReexec() (bool, error) { + embeddedReexecOnce.Do(func() { + if os.Getenv("TELEPORT_UNSTABLE_DISABLE_EMBEDDED_REEXEC") == "yes" { + embeddedReexecError = trace.Errorf("embedded binary disabled by TELEPORT_UNSTABLE_DISABLE_EMBEDDED_REEXEC") + return + } + + // this name is only displayed as a link in /proc//exe or /proc//fd/ + mf, err := loadEmbeddedReexec("teleport-sessionhelper", sessionHelperGZ) + if err != nil { + embeddedReexecError = trace.Wrap(err) + return + } + + embeddedReexecMemfd.Store(mf) + }) + + return true, embeddedReexecError +} + +func setLinuxReexecPath(cmd *exec.Cmd) { + if cmd.SysProcAttr != nil && cmd.SysProcAttr.Credential != nil { + // this should never happen because the main process always launches the + // "exec" or "networking" subcommands as itself and there's no other + // direct launches with unprivileged credentials anymore, but if we + // accidentally launched the session helper directly as an unprivileged + // child it just wouldn't work due to permissions, because the child + // wouldn't be able to launch "/proc//fd/" and there's + // no other way to pass a file descriptor such that it's available in + // the post-fork pre-exec environment but not inherited by the child in + // go + cmd.Path = "/proc/self/exe" + return + } + if mf := embeddedReexecMemfd.Load(); mf != nil { + cmd.Path = mf.Name() + return + } + // we failed to initialize the reexec memfd or we haven't bothered to call + // [initEmbeddedReexec] in this process (maybe because we're reexecuting a + // subcommand from a subcommand in the main binary) + cmd.Path = "/proc/self/exe" +} diff --git a/session/reexec/embed_embed_linux_386.go b/session/reexec/embed_embed_linux_386.go new file mode 100644 index 00000000000..bc8582e99ae --- /dev/null +++ b/session/reexec/embed_embed_linux_386.go @@ -0,0 +1,24 @@ +//go:build sessionhelper_embed + +// 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 _ "embed" + +//go:embed sessionhelper_linux_386.gz +var sessionHelperGZ string diff --git a/session/reexec/embed_embed_linux_amd64.go b/session/reexec/embed_embed_linux_amd64.go new file mode 100644 index 00000000000..7668b177376 --- /dev/null +++ b/session/reexec/embed_embed_linux_amd64.go @@ -0,0 +1,24 @@ +//go:build sessionhelper_embed + +// 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 _ "embed" + +//go:embed sessionhelper_linux_amd64.gz +var sessionHelperGZ string diff --git a/session/reexec/embed_embed_linux_arm.go b/session/reexec/embed_embed_linux_arm.go new file mode 100644 index 00000000000..57a0126c855 --- /dev/null +++ b/session/reexec/embed_embed_linux_arm.go @@ -0,0 +1,24 @@ +//go:build sessionhelper_embed + +// 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 _ "embed" + +//go:embed sessionhelper_linux_arm.gz +var sessionHelperGZ string diff --git a/session/reexec/embed_embed_linux_arm64.go b/session/reexec/embed_embed_linux_arm64.go new file mode 100644 index 00000000000..1c655cd9195 --- /dev/null +++ b/session/reexec/embed_embed_linux_arm64.go @@ -0,0 +1,24 @@ +//go:build sessionhelper_embed + +// 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 _ "embed" + +//go:embed sessionhelper_linux_arm64.gz +var sessionHelperGZ string diff --git a/session/reexec/embed_linux.go b/session/reexec/embed_linux.go new file mode 100644 index 00000000000..a29ef901e2f --- /dev/null +++ b/session/reexec/embed_linux.go @@ -0,0 +1,112 @@ +// 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" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/gravitational/trace" + "github.com/klauspost/compress/gzip" + "golang.org/x/sys/unix" + + "github.com/gravitational/teleport/session/reexec/reexecconstants" +) + +// loadEmbeddedReexec loads the given gzipped session helper binary into a +// sealed, executable memfd and tests it to confirm that it can be executed, +// then returns the memfd. +func loadEmbeddedReexec(name string, dataGZ string) (*os.File, error) { + mf, err := createExecutableMemfd(name, dataGZ) + if err != nil { + return nil, trace.Wrap(err) + } + + // TODO(espadolini): set the selinux file label on mf, figure out if it + // should be the same as the current executable or a fixed + // teleport_ssh_exec_t + + // run a self test with a noop command, just to be sure + c := &exec.Cmd{ + Path: mf.Name(), + Args: []string{"teleport", reexecconstants.TrueSubCommand}, + } + if err := c.Run(); err != nil { + _ = mf.Close() + return nil, trace.Wrap(trace.ConvertSystemError(err), "failed to launch embedded binary") + } + + return mf, nil +} + +// createExecutableMemfd returns a sealed memfd with the given name, containing +// the uncompressed gzipped binary. +func createExecutableMemfd(name string, dataGZ string) (_ *os.File, err error) { + // we follow the logic from runc here + // https://github.com/opencontainers/runc/blob/edbed618bff99edaaa9d622f9f8687004fe5e50a/libcontainer/system/linux.go#L89 + mfd, err := unix.MemfdCreate(name, unix.MFD_ALLOW_SEALING|unix.MFD_CLOEXEC|unix.MFD_EXEC) + if errors.Is(err, unix.EINVAL) { + mfd, err = unix.MemfdCreate(name, unix.MFD_ALLOW_SEALING|unix.MFD_CLOEXEC) + } + if err != nil { + if errors.Is(err, unix.EACCES) { + return nil, trace.AccessDenied("failed to create executable memfd for embedded binary (possibly due to vm.memfd_noexec=2)") + + } + return nil, trace.Wrap(trace.ConvertSystemError(err), "failed to create executable memfd for embedded binary") + } + + // This must not be "/proc/self/fd/" because in the post-fork, pre-exec + // environment of the child the file descriptors inherited from the parent + // are not guaranteed to be available - the fd will likely end up being + // small if this runs early, so it will likely get overridden as part of the + // exec machinery with one of the files in [exec.Cmd.ExtraFiles]. This does + // however mean that an unprivileged child will not be able to launch the + // helper, so this is only usable when launching it as the same user that + // the Teleport process is running as. [setLinuxReexecPath] will ensure that + // that's the case before attempting to use the memfd, and it will fall back + // to a regular reexecution of "/proc/self/exe" if need be. + mf := os.NewFile(uintptr(mfd), fmt.Sprintf("/proc/%d/fd/%d", os.Getpid(), mfd)) + defer func() { + if err != nil { + _ = mf.Close() + } + }() + + gzreader, err := gzip.NewReader(strings.NewReader(dataGZ)) + if err != nil { + return nil, trace.Wrap(err, "failed to decompress embedded binary (this is a bug)") + } + if _, err := gzreader.WriteTo(mf); err != nil { + return nil, trace.Wrap(err, "failed to write embedded binary in memfd") + } + if err := mf.Chmod(0o555); err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "failed to chmod memfd for embedded binary") + } + + // ignore errors here since F_SEAL_EXEC is newer than the rest of the memfd interface + _, _ = unix.FcntlInt(uintptr(mfd), unix.F_ADD_SEALS, unix.F_SEAL_EXEC) + + if _, err := unix.FcntlInt(uintptr(mfd), unix.F_ADD_SEALS, unix.F_SEAL_SEAL|unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE); err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "failed to seal memfd for embedded binary") + } + + return mf, nil +} diff --git a/session/reexec/embed_linux_test.go b/session/reexec/embed_linux_test.go new file mode 100644 index 00000000000..6c55a314694 --- /dev/null +++ b/session/reexec/embed_linux_test.go @@ -0,0 +1,73 @@ +// 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 ( + "io/fs" + "os" + "os/exec" + "strings" + "testing" + + "github.com/klauspost/compress/gzip" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestLoadEmbeddedReexec(t *testing.T) { + t.Parallel() + + truePath, err := exec.LookPath("true") + require.NoError(t, err) + + trueBin, err := os.ReadFile(truePath) + require.NoError(t, err) + + b := new(strings.Builder) + gzwriter := gzip.NewWriter(b) + _, err = gzwriter.Write(trueBin) + require.NoError(t, err) + require.NoError(t, gzwriter.Close()) + + trueGZ := b.String() + + mf, err := loadEmbeddedReexec("true", trueGZ) + require.NoError(t, err) + defer mf.Close() + + r, err := unix.FcntlInt(mf.Fd(), unix.F_GET_SEALS, 0) + require.NoError(t, err) + require.NotZero(t, r&unix.F_SEAL_SEAL) + require.NotZero(t, r&unix.F_SEAL_SHRINK) + require.NotZero(t, r&unix.F_SEAL_GROW) + require.NotZero(t, r&unix.F_SEAL_WRITE) + + if r&unix.F_SEAL_EXEC == 0 { + t.Log("didn't get F_SEAL_EXEC on memfd, ignoring since the kernel might be old") + } + + fi, err := mf.Stat() + require.NoError(t, err) + require.Equal(t, fs.FileMode(0o555), fi.Mode()) + + // loadEmbeddedReexec should've already tested it, but let's be thorough + err = (&exec.Cmd{ + Path: mf.Name(), + Args: []string{"true"}, + }).Run() + require.NoError(t, err) +} diff --git a/lib/srv/reexec_inline.go b/session/reexec/embed_noembed.go similarity index 83% rename from lib/srv/reexec_inline.go rename to session/reexec/embed_noembed.go index 1f2e0e3d0e1..6fbad08b84c 100644 --- a/lib/srv/reexec_inline.go +++ b/session/reexec/embed_noembed.go @@ -1,3 +1,5 @@ +//go:build !sessionhelper_embed + // Teleport // Copyright (C) 2026 Gravitational, Inc. // @@ -14,13 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package srv +package reexec -import ( - "github.com/gravitational/teleport/session/reexec" -) - -//go:fix inline -func IsReexec() bool { - return reexec.IsReexec() +func initEmbeddedReexec() (bool, error) { + return false, nil } diff --git a/session/reexec/embed_noembed_linux.go b/session/reexec/embed_noembed_linux.go new file mode 100644 index 00000000000..05052709242 --- /dev/null +++ b/session/reexec/embed_noembed_linux.go @@ -0,0 +1,25 @@ +//go:build !sessionhelper_embed + +// 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 "os/exec" + +func setLinuxReexecPath(cmd *exec.Cmd) { + cmd.Path = "/proc/self/exe" +} diff --git a/session/reexec/reexec.go b/session/reexec/reexec.go index 3711f00ba61..12d75b72c80 100644 --- a/session/reexec/reexec.go +++ b/session/reexec/reexec.go @@ -826,7 +826,7 @@ func RunNetworking() (code int, err error) { // Create a minimal default environment for the user. workingDir := rootDirectory - hasAccess, err := CheckHomeDir(localUser) + hasAccess, err := checkHomeDir(localUser) if hasAccess && err == nil { workingDir = localUser.HomeDir } @@ -1123,6 +1123,8 @@ func RunAndExit(commandType string) { code = runCheckHomeDir() case reexecconstants.ParkSubCommand: code = runPark() + case reexecconstants.TrueSubCommand: + // nothing to do case reexecconstants.SFTPSubCommand: initLogger("sftp", os.Stderr, ExecLogConfig{}) err = reexecsftp.RunSFTP(slog.Default()) @@ -1176,6 +1178,7 @@ func IsReexec() bool { reexecconstants.NetworkingSubCommand, reexecconstants.CheckHomeDirSubCommand, reexecconstants.ParkSubCommand, + reexecconstants.TrueSubCommand, reexecconstants.SFTPSubCommand: return true default: @@ -1356,7 +1359,7 @@ func BuildCommand(c *ExecCommand, localUser *user.User, pamEnvironment []string) // Set the command's cwd to the user's $HOME, or "/" if // they don't have an existing home dir. // TODO (atburke): Generalize this to support Windows. - hasAccess, err := CheckHomeDir(localUser) + hasAccess, err := checkHomeDir(localUser) if err != nil { return nil, trace.Wrap(err) } @@ -1457,10 +1460,10 @@ func hasAccessibleHomeDir() error { return nil } -// CheckHomeDir checks if the user's home directory exists and is accessible to the user. Only catastrophic +// checkHomeDir checks if the user's home directory exists and is accessible to the user. Only catastrophic // errors will be returned, which means a missing, inaccessible, or otherwise invalid home directory will result // in a return of (false, nil) -func CheckHomeDir(localUser *user.User) (bool, error) { +func checkHomeDir(localUser *user.User) (bool, error) { currentUser, err := user.Current() if err != nil { return false, trace.Wrap(err) diff --git a/session/reexec/reexec_linux.go b/session/reexec/reexec_linux.go index 21b04c1e891..57a5d6904bc 100644 --- a/session/reexec/reexec_linux.go +++ b/session/reexec/reexec_linux.go @@ -1,5 +1,3 @@ -//go:build linux - /* * Teleport * Copyright (C) 2023 Gravitational, Inc. @@ -26,69 +24,33 @@ import ( "syscall" "github.com/gravitational/trace" - "golang.org/x/sys/unix" ) -func init() { - // errors in open/openat are signaled by returning -1, we don't really care - // about the specifics anyway, so we can just ignore the error value - // - // we're opening with O_PATH rather than O_RDONLY because the binary might - // not actually be readable (but only executable) - fd1, _ := syscall.Open("/proc/self/exe", unix.O_PATH|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) - fd2, _ := syscall.Open("/proc/self/exe", unix.O_PATH|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) - - // this can happen if both calls failed (returning -1) or if we're - // running in a version of qemu-user that's affected by this bug: - // https://gitlab.com/qemu-project/qemu/-/issues/927 - // (hopefully they'll also add special handling for execve on /proc/self/exe - // if they ever fix that bug) - if fd1 == fd2 { - return - } - - // closing -1 is harmless, no need to check here - syscall.Close(fd1) - syscall.Close(fd2) - - // if one Open has failed but not the other we can't really - // trust the availability of "/proc/self/exe" - if fd1 == -1 || fd2 == -1 { - return - } - - reexecPath = "/proc/self/exe" -} - -// reexecPath specifies a path to execute on reexec, overriding Path in the cmd -// passed to reexecCommandOSTweaks, if not empty. -var reexecPath string - func CommandOSTweaks(cmd *exec.Cmd) { + setLinuxReexecPath(cmd) + if cmd.SysProcAttr == nil { cmd.SysProcAttr = new(syscall.SysProcAttr) } + // Linux only: when parent process (node) dies unexpectedly without // cleaning up child processes, send a signal for graceful shutdown // to children. cmd.SysProcAttr.Pdeathsig = syscall.SIGQUIT - - // replace the path on disk (which might not exist, or refer to an - // upgraded version of teleport) with reexecPath, which contains - // some path that refers to the specific binary we're running - if reexecPath != "" { - cmd.Path = reexecPath - } } // if we ever need to run parkers on macOS or other platforms with no PDEATHSIG // we should rework the parker to block on a pipe so it can exit when its parent // is terminated func parkerCommandOSTweaks(cmd *exec.Cmd) { - CommandOSTweaks(cmd) + setLinuxReexecPath(cmd) + + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = new(syscall.SysProcAttr) + } // parker processes can leak if their PDEATHSIG is SIGQUIT, otherwise we - // could just use reexecCommandOSTweaks + // could just use [commandOSTweaks] cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL } @@ -96,6 +58,7 @@ func userCommandOSTweaks(cmd *exec.Cmd) { if cmd.SysProcAttr == nil { cmd.SysProcAttr = new(syscall.SysProcAttr) } + // Linux only: when parent process (this process) dies unexpectedly, kill // the child process instead of orphaning it. // SIGKILL because we don't control the child process, and it could choose diff --git a/session/reexec/reexec_test.go b/session/reexec/reexec_test.go index 7a7e1c34a17..d8f3ad10358 100644 --- a/session/reexec/reexec_test.go +++ b/session/reexec/reexec_test.go @@ -222,22 +222,22 @@ func TestRootCheckHomeDir(t *testing.T) { require.NoError(t, os.Chown(home, uid, gid)) require.NoError(t, os.Chown(file, uid, gid)) - hasAccess, err := CheckHomeDir(testUser) + hasAccess, err := checkHomeDir(testUser) require.NoError(t, err) require.True(t, hasAccess) changeHomeDir(t, login, file) - hasAccess, err = CheckHomeDir(testUser) + hasAccess, err = checkHomeDir(testUser) require.NoError(t, err) require.False(t, hasAccess) changeHomeDir(t, login, notFound) - hasAccess, err = CheckHomeDir(testUser) + hasAccess, err = checkHomeDir(testUser) require.NoError(t, err) require.False(t, hasAccess) changeHomeDir(t, login, noAccess) - hasAccess, err = CheckHomeDir(testUser) + hasAccess, err = checkHomeDir(testUser) require.NoError(t, err) require.False(t, hasAccess) } diff --git a/session/reexec/reexecconstants/reexecconstants.go b/session/reexec/reexecconstants/reexecconstants.go index 77d145bf1ec..c44150caa4f 100644 --- a/session/reexec/reexecconstants/reexecconstants.go +++ b/session/reexec/reexecconstants/reexecconstants.go @@ -35,6 +35,9 @@ const ( // spawning the intended child process. ParkSubCommand = "park" + // TrueSubCommand is a sub-command that exits with a success exit code. + TrueSubCommand = "true" + // SFTPSubCommand is the sub-command Teleport uses to re-exec itself to // handle SFTP connections. SFTPSubCommand = "sftp" diff --git a/tool/teleport/common/teleport.go b/tool/teleport/common/teleport.go index d16c71b16e3..54cf41d4c2d 100644 --- a/tool/teleport/common/teleport.go +++ b/tool/teleport/common/teleport.go @@ -57,7 +57,6 @@ import ( "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" ) @@ -115,7 +114,8 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con 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() + park := app.Command(reexecconstants.ParkSubCommand, "Used internally by Teleport to re-exec itself to do nothing, forever.").Hidden() + trueCmd := app.Command(reexecconstants.TrueSubCommand, "Used internally by Teleport to re-exec itself to do nothing, successfully.").Hidden() app.HelpFlag.Short('h') // define start flags: @@ -746,20 +746,13 @@ Examples: dumpFlags.Roles = defaults.RoleNode err = onConfigDump(dumpFlags) - // TODO(espadolini): replace these after enterprise calls reexec.MaybeReexec - // in main with an error message ("invalid format for reexec subcommand") - // because if we got here it's because MaybeReexec didn't find the correct - // first argument - case exec.FullCommand(): - reexec.RunAndExit(reexecconstants.ExecSubCommand) - case networking.FullCommand(): - reexec.RunAndExit(reexecconstants.NetworkingSubCommand) - case checkHomeDir.FullCommand(): - reexec.RunAndExit(reexecconstants.CheckHomeDirSubCommand) - case park.FullCommand(): - reexec.RunAndExit(reexecconstants.ParkSubCommand) - case sftp.FullCommand(): - reexec.RunAndExit(reexecconstants.SFTPSubCommand) + case exec.FullCommand(), + networking.FullCommand(), + checkHomeDir.FullCommand(), + park.FullCommand(), + trueCmd.FullCommand(), + sftp.FullCommand(): + err = trace.BadParameter("invalid command line format for internal reexecution command (this is a bug)") case waitNoResolveCmd.FullCommand(): err = onWaitNoResolve(waitFlags)