session-helper: move the sftp subcommand to reexec.RunAndExit (#65392)

* Split the approver map away from FileTransferRequest

* Move or copy files in session/reexec/sftputils and session/reexec/reexecsftp

* Split up copied files and clean up the new session packages

* Use custom types for SFTP audit log events

* Clean up imports and run sftp in RunAndExit

* Finish renaming types and functions

* Streamline reexec in main and tests

* Add test with the legacy SFTP event implementation
This commit is contained in:
Edoardo Spadolini
2026-04-15 13:28:27 +00:00
committed by GitHub
parent b2c7a20e20
commit bcffdcbfd6
34 changed files with 1625 additions and 1144 deletions
-3
View File
@@ -171,9 +171,6 @@ const (
// ComponentSubsystemProxy is the proxy subsystem.
ComponentSubsystemProxy = "subsystem:proxy"
// ComponentSubsystemSFTP is the SFTP subsystem.
ComponentSubsystemSFTP = "subsystem:sftp"
// ComponentLocalTerm is a terminal on a regular SSH node.
ComponentLocalTerm = "term:local"
+2 -8
View File
@@ -28,25 +28,19 @@ import (
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/utils/log/logtest"
"github.com/gravitational/teleport/session/reexec"
"github.com/gravitational/teleport/tool/teleport/common"
)
// TestMainImplementation will re-execute Teleport to run a command if "exec" is passed to
// it as an argument. Otherwise, it will run tests as normal.
func TestMainImplementation(m *testing.M) {
reexec.MaybeReexec()
logtest.InitLogger(testing.Verbose)
ctx, cancel := context.WithCancel(context.Background())
cryptosuitestest.PrecomputeRSAKeys(ctx)
SetTestTimeouts(3 * time.Second)
modules.SetInsecureTestMode(true)
// If the test is re-executing itself, execute the command that comes over
// the pipe.
if reexec.IsReexec() {
defer cancel()
common.Run(common.Options{Args: os.Args[1:]})
return
}
// Otherwise run tests as normal.
exitCode := m.Run()
+4 -3
View File
@@ -62,6 +62,7 @@ import (
"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/reexecsftp"
)
var ctxID int32
@@ -461,7 +462,7 @@ type ServerContext struct {
// approvedFileReq is an approved file transfer request that will only be
// set when the session's pending file transfer request is approved.
approvedFileReq *FileTransferRequest
approvedFileReq *reexecsftp.FileTransferRequest
}
// NewServerContext creates a new *ServerContext which is used to pass and
@@ -1374,7 +1375,7 @@ func (c *ServerContext) GetPortForwardEvent(evType, code, addr string) apievents
}
}
func (c *ServerContext) setApprovedFileTransferRequest(req *FileTransferRequest) {
func (c *ServerContext) setApprovedFileTransferRequest(req *reexecsftp.FileTransferRequest) {
c.mu.Lock()
c.approvedFileReq = req
c.mu.Unlock()
@@ -1384,7 +1385,7 @@ func (c *ServerContext) setApprovedFileTransferRequest(req *FileTransferRequest)
// request for this session if there is one present. Note that if an
// approved request is returned future calls to this method will return
// nil to prevent an approved request getting reused incorrectly.
func (c *ServerContext) ConsumeApprovedFileTransferRequest() *FileTransferRequest {
func (c *ServerContext) ConsumeApprovedFileTransferRequest() *reexecsftp.FileTransferRequest {
c.mu.Lock()
defer c.mu.Unlock()
+1 -6
View File
@@ -40,14 +40,9 @@ import (
// TestMain will re-execute Teleport to run a command if "exec" is passed to
// it as an argument. Otherwise, it will run tests as normal.
func TestMain(m *testing.M) {
reexec.MaybeReexec()
logtest.InitLogger(testing.Verbose)
modules.SetInsecureTestMode(true)
// If the test is re-executing itself, execute the command that comes over
// the pipe.
if reexec.IsReexec() {
reexec.RunAndExit(os.Args[1])
return
}
// Otherwise run tests as normal.
code := m.Run()
+18 -11
View File
@@ -33,6 +33,7 @@ import (
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/srv"
sftputils "github.com/gravitational/teleport/lib/sshutils/sftp"
sessionsftputils "github.com/gravitational/teleport/session/sftputils"
)
// SFTPProxy proxies an SFTP session and emits audit events for the handled
@@ -127,11 +128,11 @@ func (p *SFTPProxy) Close() error {
type proxyHandlers struct {
auditContext sftpAuditContext
remoteFS sftputils.FileSystem
remoteFS sessionsftputils.FileSystem
logger *slog.Logger
fileMtx sync.Mutex
files []*sftputils.TrackedFile
files []*sessionsftputils.TrackedFile
}
type sftpAuditContext interface {
@@ -190,15 +191,15 @@ func (h *proxyHandlers) OpenFile(req *sftp.Request) (_ sftp.WriterAtReaderAt, re
return nil, os.ErrInvalid
}
f, err := h.remoteFS.OpenFile(req.Filepath, sftputils.ParseFlags(req))
f, err := h.remoteFS.OpenFile(req.Filepath, sessionsftputils.ParseFlags(req))
if err != nil {
return nil, err
}
return h.trackFile(f), nil
}
func (h *proxyHandlers) trackFile(f sftputils.File) sftp.WriterAtReaderAt {
trackFile := &sftputils.TrackedFile{File: f}
func (h *proxyHandlers) trackFile(f sessionsftputils.File) sftp.WriterAtReaderAt {
trackFile := &sessionsftputils.TrackedFile{File: f}
h.fileMtx.Lock()
defer h.fileMtx.Unlock()
h.files = append(h.files, trackFile)
@@ -212,17 +213,17 @@ func (h *proxyHandlers) Filecmd(req *sftp.Request) (err error) {
h.sendSFTPEvent(req, err)
}
}()
return sftputils.HandleFilecmd(req, h.remoteFS)
return sessionsftputils.HandleFilecmd(req, h.remoteFS)
}
// Filelist handles listing info about files.
func (h *proxyHandlers) Filelist(req *sftp.Request) (_ sftp.ListerAt, err error) {
defer func() {
if req.Method == sftputils.MethodList {
if req.Method == sessionsftputils.MethodList {
h.sendSFTPEvent(req, err)
}
}()
lister, err := sftputils.HandleFilelist(req, h.remoteFS)
lister, err := sessionsftputils.HandleFilelist(req, h.remoteFS)
if err != nil {
return nil, err
}
@@ -241,11 +242,17 @@ func (h *proxyHandlers) sendSFTPEvent(req *sftp.Request, reqErr error) {
h.logger.WarnContext(req.Context(), "Unable to get working directory", "error", err)
// Emit event without working directory.
}
event, err := sftputils.ParseSFTPEvent(req, wd, reqErr)
sftpEvent, err := sessionsftputils.ParseSFTPEvent(req, wd, reqErr)
if err != nil {
h.logger.WarnContext(req.Context(), "Unknown SFTP request", "request", req.Method)
h.logger.WarnContext(req.Context(), "Failed to convert SFTP event into an audit log event", "request", req.Method, "error", err)
return
} else if reqErr != nil {
}
event, err := sftputils.SFTPEventToProto(sftpEvent)
if err != nil {
h.logger.WarnContext(req.Context(), "Failed to convert SFTP event into an audit log event", "request", req.Method, "error", err)
return
}
if reqErr != nil {
h.logger.DebugContext(req.Context(), "failed handling SFTP request", "request", req.Method, "error", reqErr)
}
event.ServerMetadata = h.auditContext.ServerMetadata()
+1 -1
View File
@@ -29,7 +29,7 @@ import (
"github.com/stretchr/testify/require"
apievents "github.com/gravitational/teleport/api/types/events"
sftputils "github.com/gravitational/teleport/lib/sshutils/sftp"
"github.com/gravitational/teleport/session/sftputils"
)
func TestSFTPProxyServeClosesRemoteFilesystem(t *testing.T) {
+25 -29
View File
@@ -19,7 +19,6 @@
package regular
import (
"bufio"
"context"
"encoding/json"
"errors"
@@ -27,11 +26,9 @@ import (
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/gogo/protobuf/jsonpb" //nolint:depguard // needed for backwards compatibility
"github.com/gravitational/trace"
"golang.org/x/crypto/ssh"
@@ -40,14 +37,17 @@ import (
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/sshutils/reexec"
sftputils "github.com/gravitational/teleport/lib/sshutils/sftp"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/session/reexec/reexecconstants"
"github.com/gravitational/teleport/session/reexec/reexecsftp"
sessionsftputils "github.com/gravitational/teleport/session/sftputils"
)
type sftpSubsys struct {
logger *slog.Logger
fileTransferReq *srv.FileTransferRequest
fileTransferReq *reexecsftp.FileTransferRequest
sftpCmd *exec.Cmd
serverCtx *srv.ServerContext
@@ -57,9 +57,9 @@ type sftpSubsys struct {
waitForOutputStreams sync.WaitGroup
}
func newSFTPSubsys(fileTransferReq *srv.FileTransferRequest) (*sftpSubsys, error) {
func newSFTPSubsys(fileTransferReq *reexecsftp.FileTransferRequest) (*sftpSubsys, error) {
return &sftpSubsys{
logger: slog.With(teleport.ComponentKey, teleport.ComponentSubsystemSFTP),
logger: slog.With(teleport.ComponentKey, "subsystem:sftp"),
fileTransferReq: fileTransferReq,
}, nil
}
@@ -207,44 +207,40 @@ func (s *sftpSubsys) Start(ctx context.Context,
LocalAddr: serverConn.LocalAddr().String(),
}
r := bufio.NewReader(auditPipeOut)
dec := json.NewDecoder(auditPipeOut)
for {
// Read up to a NULL byte, the child process uses this to
// delimit audit events
eventStr, err := r.ReadString(0x0)
if err != nil {
var ev sessionsftputils.Event
if err := dec.Decode(&ev); err != nil {
if !errors.Is(err, io.EOF) {
s.logger.WarnContext(ctx, "Failed to read SFTP event", "error", err)
}
return
}
var oneOfEvent apievents.OneOf
err = (&jsonpb.Unmarshaler{}).Unmarshal(strings.NewReader(eventStr[:len(eventStr)-1]), &oneOfEvent)
if err != nil {
s.logger.WarnContext(ctx, "Failed to unmarshal SFTP event", "error", err)
continue
}
event, err := apievents.FromOneOf(oneOfEvent)
if err != nil {
s.logger.WarnContext(ctx, "Failed to convert SFTP event from OneOf", "error", err)
continue
}
event.SetClusterName(serverCtx.ClusterName)
switch e := event.(type) {
case *apievents.SFTP:
var event apievents.AuditEvent
if ev.SFTP != nil {
e, err := sftputils.SFTPEventToProto(ev.SFTP)
if err != nil {
s.logger.WarnContext(ctx, "Failed to convert SFTP event", "error", err)
continue
}
e.SetClusterName(serverCtx.ClusterName)
e.ServerMetadata = serverMeta
e.SessionMetadata = sessionMeta
e.UserMetadata = userMeta
e.ConnectionMetadata = connectionMeta
case *apievents.SFTPSummary:
event = e
} else if ev.Summary != nil {
e := sftputils.SFTPSummaryEventToProto(ev.Summary)
e.SetClusterName(serverCtx.ClusterName)
e.ServerMetadata = serverMeta
e.SessionMetadata = sessionMeta
e.UserMetadata = userMeta
e.ConnectionMetadata = connectionMeta
default:
s.logger.WarnContext(ctx, "Unknown event type received from SFTP server process", "error", err, "event_type", event.GetType())
event = e
} else {
s.logger.WarnContext(ctx, "Unknown event type received from SFTP server process")
continue
}
if err := serverCtx.GetServer().EmitAuditEvent(ctx, event); err != nil {
+1 -4
View File
@@ -100,12 +100,9 @@ var wildcardAllow = types.Labels{
// TestMain will re-execute Teleport to run a command if "exec" is passed to
// it as an argument. Otherwise it will run tests as normal.
func TestMain(m *testing.M) {
reexec.MaybeReexec()
logtest.InitLogger(testing.Verbose)
modules.SetInsecureTestMode(true)
if reexec.IsReexec() {
reexec.RunAndExit(os.Args[1])
return
}
code := m.Run()
os.Exit(code)
+15 -23
View File
@@ -56,6 +56,7 @@ import (
"github.com/gravitational/teleport/lib/services"
rsession "github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/session/reexec/reexecsftp"
)
const sessionRecorderID = "session-recorder"
@@ -520,7 +521,7 @@ func (s *SessionRegistry) isApprovedFileTransfer(scx *ServerContext) (bool, erro
return false, trace.Wrap(err)
}
if approved {
scx.setApprovedFileTransferRequest(sess.fileTransferReq)
scx.setApprovedFileTransferRequest(&sess.fileTransferReq.FileTransferRequest)
sess.fileTransferReq = nil
}
@@ -545,7 +546,7 @@ const (
// notifyFileTransferRequestUnderLock is called to notify all members of a party that a file transfer request has been created/approved/denied.
// The notification is a global ssh request and requires the client to update its UI state accordingly.
func (s *SessionRegistry) notifyFileTransferRequestUnderLock(req *FileTransferRequest, res FileTransferRequestEvent, scx *ServerContext) error {
func (s *SessionRegistry) notifyFileTransferRequestUnderLock(req *fileTransferRequestWithApprovers, res FileTransferRequestEvent, scx *ServerContext) error {
session := scx.getSession()
if session == nil {
s.logger.DebugContext(
@@ -744,7 +745,7 @@ type session struct {
// fileTransferReq a pending file transfer request for this session.
// If the request is denied or approved it should be set to nil to
// prevent its reuse.
fileTransferReq *FileTransferRequest
fileTransferReq *fileTransferRequestWithApprovers
io *TermManager
inWriter io.WriteCloser
@@ -1840,24 +1841,13 @@ func (s *session) checkPresence(ctx context.Context) error {
return nil
}
// FileTransferRequest is a request to upload or download a file from a node.
type FileTransferRequest struct {
// ID is a UUID that uniquely identifies a file transfer request
// and is unlikely to collide with another file transfer request
ID string
// Requester is the Teleport User that requested the file transfer
Requester string
// Download is true if the request is a download, false if its an upload
Download bool
// Filename is the name of the file to upload.
Filename string
// Location of the requested download or where a file will be uploaded
Location string
type fileTransferRequestWithApprovers struct {
reexecsftp.FileTransferRequest
// approvers is a list of participants of moderator or peer type that have approved the request
approvers map[string]*party
}
func (s *session) checkIfFileTransferApproved(req *FileTransferRequest) (bool, error) {
func (s *session) checkIfFileTransferApproved(req *fileTransferRequestWithApprovers) (bool, error) {
var participants []moderation.SessionAccessContext
for _, party := range req.approvers {
@@ -1898,12 +1888,14 @@ func (s *session) addFileTransferRequest(params *rsession.FileTransferRequestPar
return trace.BadParameter("no source file is set for the upload")
}
s.fileTransferReq = &FileTransferRequest{
ID: uuid.New().String(),
Requester: params.Requester,
Location: params.Location,
Filename: params.Filename,
Download: params.Download,
s.fileTransferReq = &fileTransferRequestWithApprovers{
FileTransferRequest: reexecsftp.FileTransferRequest{
ID: uuid.New().String(),
Requester: params.Requester,
Location: params.Location,
Filename: params.Filename,
Download: params.Download,
},
approvers: make(map[string]*party),
}
+19 -12
View File
@@ -53,6 +53,7 @@ import (
rsession "github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/sshutils/sftp"
"github.com/gravitational/teleport/lib/utils/log/logtest"
"github.com/gravitational/teleport/session/reexec/reexecsftp"
)
func TestIsApprovedFileTransfer(t *testing.T) {
@@ -106,7 +107,7 @@ func TestIsApprovedFileTransfer(t *testing.T) {
name string
expectedResult bool
expectedError string
req *FileTransferRequest
req *fileTransferRequestWithApprovers
reqID string
location string
}{
@@ -122,9 +123,11 @@ func TestIsApprovedFileTransfer(t *testing.T) {
expectedResult: false,
expectedError: "Teleport user does not match original requester",
reqID: "123",
req: &FileTransferRequest{
ID: "123",
Requester: "michael",
req: &fileTransferRequestWithApprovers{
FileTransferRequest: reexecsftp.FileTransferRequest{
ID: "123",
Requester: "michael",
},
approvers: make(map[string]*party),
},
},
@@ -134,11 +137,13 @@ func TestIsApprovedFileTransfer(t *testing.T) {
expectedError: "requested destination path does not match the current request",
reqID: "123",
location: "~/Downloads",
req: &FileTransferRequest{
ID: "123",
Requester: "teleportUser",
req: &fileTransferRequestWithApprovers{
FileTransferRequest: reexecsftp.FileTransferRequest{
ID: "123",
Requester: "teleportUser",
Location: "~/badlocation",
},
approvers: make(map[string]*party),
Location: "~/badlocation",
},
},
{
@@ -147,11 +152,13 @@ func TestIsApprovedFileTransfer(t *testing.T) {
expectedError: "",
reqID: "123",
location: "~/Downloads",
req: &FileTransferRequest{
ID: "123",
Requester: "teleportUser",
req: &fileTransferRequestWithApprovers{
FileTransferRequest: reexecsftp.FileTransferRequest{
ID: "123",
Requester: "teleportUser",
Location: "~/Downloads",
},
approvers: approvers,
Location: "~/Downloads",
},
},
}
+152
View File
@@ -0,0 +1,152 @@
// 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 <http://www.gnu.org/licenses/>.
package sftp
import (
"time"
"github.com/gravitational/trace"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/session/sftputils"
)
// SFTPSummaryEventToProto converts a sftp event from the format generated by
// the SFTP server process to its matching audit log struct.
func SFTPEventToProto(ev *sftputils.SFTPEvent) (*apievents.SFTP, error) {
event := &apievents.SFTP{
Metadata: apievents.Metadata{
Type: events.SFTPEvent,
Time: time.Unix(0, ev.Time),
},
}
switch ev.Method {
case sftputils.MethodOpen, sftputils.MethodGet, sftputils.MethodPut:
if ev.Error == "" {
event.Code = events.SFTPOpenCode
} else {
event.Code = events.SFTPOpenFailureCode
}
event.Action = apievents.SFTPAction_OPEN
case sftputils.MethodSetStat:
if ev.Error == "" {
event.Code = events.SFTPSetstatCode
} else {
event.Code = events.SFTPSetstatFailureCode
}
event.Action = apievents.SFTPAction_SETSTAT
case sftputils.MethodList:
if ev.Error == "" {
event.Code = events.SFTPReaddirCode
} else {
event.Code = events.SFTPReaddirFailureCode
}
event.Action = apievents.SFTPAction_READDIR
case sftputils.MethodRemove:
if ev.Error == "" {
event.Code = events.SFTPRemoveCode
} else {
event.Code = events.SFTPRemoveFailureCode
}
event.Action = apievents.SFTPAction_REMOVE
case sftputils.MethodMkdir:
if ev.Error == "" {
event.Code = events.SFTPMkdirCode
} else {
event.Code = events.SFTPMkdirFailureCode
}
event.Action = apievents.SFTPAction_MKDIR
case sftputils.MethodRmdir:
if ev.Error == "" {
event.Code = events.SFTPRmdirCode
} else {
event.Code = events.SFTPRmdirFailureCode
}
event.Action = apievents.SFTPAction_RMDIR
case sftputils.MethodRename:
if ev.Error == "" {
event.Code = events.SFTPRenameCode
} else {
event.Code = events.SFTPRenameFailureCode
}
event.Action = apievents.SFTPAction_RENAME
case sftputils.MethodSymlink:
if ev.Error == "" {
event.Code = events.SFTPSymlinkCode
} else {
event.Code = events.SFTPSymlinkFailureCode
}
event.Action = apievents.SFTPAction_SYMLINK
case sftputils.MethodLink:
if ev.Error == "" {
event.Code = events.SFTPLinkCode
} else {
event.Code = events.SFTPLinkFailureCode
}
event.Action = apievents.SFTPAction_LINK
default:
return nil, trace.BadParameter("unknown SFTP request %+q", ev.Method)
}
event.Path = ev.Path
event.TargetPath = ev.Target
event.Flags = ev.Flags
event.WorkingDirectory = ev.WorkDir
if ev.Attrs != nil {
event.Attributes = new(apievents.SFTPAttributes)
if ev.Attrs.Atime != nil {
t := time.Unix(int64(*ev.Attrs.Atime), 0)
event.Attributes.AccessTime = &t
}
if ev.Attrs.Mtime != nil {
t := time.Unix(int64(*ev.Attrs.Mtime), 0)
event.Attributes.ModificationTime = &t
}
event.Attributes.Permissions = ev.Attrs.Perms
event.Attributes.FileSize = ev.Attrs.Size
event.Attributes.UID = ev.Attrs.UID
event.Attributes.GID = ev.Attrs.GID
}
event.Error = ev.Error
return event, nil
}
// SFTPSummaryEventToProto converts a sftp_summary event from the format
// generated by the SFTP server process to its matching audit log struct.
func SFTPSummaryEventToProto(ev *sftputils.SFTPSummaryEvent) *apievents.SFTPSummary {
event := &apievents.SFTPSummary{
Metadata: apievents.Metadata{
Type: events.SFTPSummaryEvent,
Code: events.SFTPSummaryCode,
Time: time.Now(),
},
FileTransferStats: make([]*apievents.FileTransferStat, 0, len(ev.Stats)),
}
for _, stat := range ev.Stats {
event.FileTransferStats = append(event.FileTransferStats, &apievents.FileTransferStat{
Path: stat.Path,
BytesRead: stat.Read,
BytesWritten: stat.Written,
})
}
return event
}
+229
View File
@@ -0,0 +1,229 @@
// 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 <http://www.gnu.org/licenses/>.
package sftp
import (
"errors"
"io/fs"
"os"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/gravitational/trace"
"github.com/pkg/sftp"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/testing/protocmp"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/session/sftputils"
)
// legacyParseSFTPEvent is the original implementation of event conversion from
// [sftp.Request] to audit log event, before the change to custom types used by
// the SFTP server process.
func legacyParseSFTPEvent(req *sftp.Request, workingDirectory string, reqErr error) (*apievents.SFTP, error) {
event := &apievents.SFTP{
Metadata: apievents.Metadata{
Type: events.SFTPEvent,
Time: time.Now(),
},
}
switch req.Method {
case sftputils.MethodOpen, sftputils.MethodGet, sftputils.MethodPut:
if reqErr == nil {
event.Code = events.SFTPOpenCode
} else {
event.Code = events.SFTPOpenFailureCode
}
event.Action = apievents.SFTPAction_OPEN
case sftputils.MethodSetStat:
if reqErr == nil {
event.Code = events.SFTPSetstatCode
} else {
event.Code = events.SFTPSetstatFailureCode
}
event.Action = apievents.SFTPAction_SETSTAT
case sftputils.MethodList:
if reqErr == nil {
event.Code = events.SFTPReaddirCode
} else {
event.Code = events.SFTPReaddirFailureCode
}
event.Action = apievents.SFTPAction_READDIR
case sftputils.MethodRemove:
if reqErr == nil {
event.Code = events.SFTPRemoveCode
} else {
event.Code = events.SFTPRemoveFailureCode
}
event.Action = apievents.SFTPAction_REMOVE
case sftputils.MethodMkdir:
if reqErr == nil {
event.Code = events.SFTPMkdirCode
} else {
event.Code = events.SFTPMkdirFailureCode
}
event.Action = apievents.SFTPAction_MKDIR
case sftputils.MethodRmdir:
if reqErr == nil {
event.Code = events.SFTPRmdirCode
} else {
event.Code = events.SFTPRmdirFailureCode
}
event.Action = apievents.SFTPAction_RMDIR
case sftputils.MethodRename:
if reqErr == nil {
event.Code = events.SFTPRenameCode
} else {
event.Code = events.SFTPRenameFailureCode
}
event.Action = apievents.SFTPAction_RENAME
case sftputils.MethodSymlink:
if reqErr == nil {
event.Code = events.SFTPSymlinkCode
} else {
event.Code = events.SFTPSymlinkFailureCode
}
event.Action = apievents.SFTPAction_SYMLINK
case sftputils.MethodLink:
if reqErr == nil {
event.Code = events.SFTPLinkCode
} else {
event.Code = events.SFTPLinkFailureCode
}
event.Action = apievents.SFTPAction_LINK
default:
return nil, trace.BadParameter("unknown SFTP request %q", req.Method)
}
event.Path = req.Filepath
event.TargetPath = req.Target
event.Flags = req.Flags
event.WorkingDirectory = workingDirectory
if req.Method == sftputils.MethodSetStat {
attrFlags := req.AttrFlags()
attrs := req.Attributes()
event.Attributes = new(apievents.SFTPAttributes)
if attrFlags.Acmodtime {
atime := time.Unix(int64(attrs.Atime), 0)
mtime := time.Unix(int64(attrs.Mtime), 0)
event.Attributes.AccessTime = &atime
event.Attributes.ModificationTime = &mtime
}
if attrFlags.Permissions {
perms := uint32(attrs.FileMode().Perm())
event.Attributes.Permissions = &perms
}
if attrFlags.Size {
event.Attributes.FileSize = &attrs.Size
}
if attrFlags.UidGid {
event.Attributes.UID = &attrs.UID
event.Attributes.GID = &attrs.GID
}
}
if reqErr != nil {
// If possible, strip the filename from the error message. The
// path will be included in audit events already, no need to
// make the error message longer than it needs to be.
var pathErr *fs.PathError
var linkErr *os.LinkError
if errors.As(reqErr, &pathErr) {
event.Error = pathErr.Err.Error()
} else if errors.As(reqErr, &linkErr) {
event.Error = linkErr.Err.Error()
} else {
event.Error = reqErr.Error()
}
}
return event, nil
}
func TestSFTPEventMatchesLegacy(t *testing.T) {
// sftp protocol constants
const (
sshFileXferAttrSize = 0x00000001
sshFileXferAttrUIDGID = 0x00000002
sshFileXferAttrPermissions = 0x00000004
sshFileXferAttrACmodTime = 0x00000008
)
inputs := []struct {
req *sftp.Request
workingDirectory string
reqErr error
}{
{&sftp.Request{
Method: sftputils.MethodGet,
Filepath: "/fp",
}, "/mywd", nil},
{&sftp.Request{
Method: sftputils.MethodPut,
Filepath: "/fp",
Flags: 42,
}, "/mywd", nil},
{&sftp.Request{
Method: sftputils.MethodPut,
Filepath: "/fp",
}, "/mywd", &fs.PathError{Path: "/fp", Err: errors.New("lmao")}},
{&sftp.Request{
Method: sftputils.MethodRemove,
Filepath: "/fp",
}, "/mywd", nil},
{&sftp.Request{
Method: sftputils.MethodLink,
Filepath: "/fp",
Target: "/fp2",
}, "/mywd", &os.LinkError{Old: "/fp", New: "/fp2", Err: errors.New("lmao")}},
{&sftp.Request{
Method: sftputils.MethodSetStat,
Filepath: "/fp",
Flags: sshFileXferAttrACmodTime,
Attrs: []byte{0x1, 0x23, 0x45, 0x67, 0x12, 0x34, 0x56, 0x78},
}, "/mywd", nil},
{&sftp.Request{
Method: sftputils.MethodSetStat,
Filepath: "/fp",
Flags: sshFileXferAttrSize,
Attrs: []byte{0x1, 0x23, 0x45, 0x67, 0x12, 0x34, 0x56, 0x78},
}, "/mywd", nil},
{&sftp.Request{
Method: sftputils.MethodSetStat,
Filepath: "/fp",
Flags: sshFileXferAttrPermissions,
Attrs: []byte{0, 0, 0o7, 0o55},
}, "/mywd", nil},
}
for _, input := range inputs {
legacyEvent, err := legacyParseSFTPEvent(input.req, input.workingDirectory, input.reqErr)
require.NoError(t, err)
sftpEvent, err := sftputils.ParseSFTPEvent(input.req, input.workingDirectory, input.reqErr)
require.NoError(t, err)
newEvent, err := SFTPEventToProto(sftpEvent)
require.NoError(t, err)
require.Empty(t, cmp.Diff(legacyEvent, newEvent, protocmp.Transform()))
}
}
+4 -3
View File
@@ -32,6 +32,7 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/lib/httplib"
"github.com/gravitational/teleport/session/sftputils"
)
const (
@@ -71,7 +72,7 @@ func (h *httpFS) ReadDir(_ string) ([]fs.FileInfo, error) {
return nil, errDirsNotSupported
}
func (h *httpFS) Open(path string) (File, error) {
func (h *httpFS) Open(path string) (sftputils.File, error) {
if h.reader == nil {
return nil, trace.BadParameter("missing reader")
}
@@ -85,7 +86,7 @@ func (h *httpFS) Open(path string) (File, error) {
}, nil
}
func (h *httpFS) Create(p string, size int64) (File, error) {
func (h *httpFS) Create(p string, size int64) (sftputils.File, error) {
filename := path.Base(p)
contentLength := strconv.FormatInt(size, 10)
header := h.writer.Header()
@@ -106,7 +107,7 @@ func (h *httpFS) Create(p string, size int64) (File, error) {
}, nil
}
func (h *httpFS) OpenFile(p string, flags int) (File, error) {
func (h *httpFS) OpenFile(p string, flags int) (sftputils.File, error) {
switch flags & 3 {
case os.O_RDWR:
return nil, trace.BadParameter("read-write files not supported for http")
+4 -3
View File
@@ -30,6 +30,7 @@ import (
"github.com/gravitational/teleport"
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
"github.com/gravitational/teleport/session/sftputils"
)
// RemoteFS provides API for accessing the files on
@@ -136,15 +137,15 @@ func (r *RemoteFS) ReadDir(path string) ([]os.FileInfo, error) {
return fileInfos, nil
}
func (r *RemoteFS) Open(path string) (File, error) {
func (r *RemoteFS) Open(path string) (sftputils.File, error) {
return r.OpenFile(path, os.O_RDONLY)
}
func (r *RemoteFS) Create(path string, _ int64) (File, error) {
func (r *RemoteFS) Create(path string, _ int64) (sftputils.File, error) {
return r.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
}
func (r *RemoteFS) OpenFile(path string, flags int) (File, error) {
func (r *RemoteFS) OpenFile(path string, flags int) (sftputils.File, error) {
return r.Client.OpenFile(path, flags)
}
+8 -326
View File
@@ -29,9 +29,7 @@ import (
"net/http"
"os"
"path" // SFTP requires UNIX-style path separators
"runtime"
"strconv"
"strings"
"time"
"github.com/gravitational/trace"
@@ -42,38 +40,7 @@ import (
"github.com/gravitational/teleport/api/observability/tracing/ssh"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/sshutils/scp"
)
// SFTP request methods.
const (
// MethodGet opens a file for reading.
MethodGet = "Get"
// MethodPut opens a file for writing.
MethodPut = "Put"
// MethodOpen opens a file.
MethodOpen = "Open"
// MethodSetStat sets a file's stats.
MethodSetStat = "Setstat"
// MethodRename renames a file.
MethodRename = "Rename"
// MethodRmdir removes a directory.
MethodRmdir = "Rmdir"
// MethodMkdir creates a directory.
MethodMkdir = "Mkdir"
// MethodLink creates a hard link.
MethodLink = "Link"
// MethodSymlink creates a symbolic link.
MethodSymlink = "Symlink"
// MethodRemove deletes a file.
MethodRemove = "Remove"
// MethodList lists directory entries.
MethodList = "List"
// MethodStat gets a directory entry's stat info.
MethodStat = "Stat"
// MethodLstat gets a directory entry's stat info, without following symbolic links.
MethodLstat = "Lstat"
// MethodReadlink gets the target of a symbolic link.
MethodReadlink = "Readlink"
"github.com/gravitational/teleport/session/sftputils"
)
// FileTransferRequest holds the settings for an SFTP file transfer.
@@ -105,8 +72,8 @@ type FileTransferRequest struct {
// ModeratedSessionID is the optional ID of a moderated session.
ModeratedSessionID string
srcFS FileSystem
dstFS FileSystem
srcFS sftputils.FileSystem
dstFS sftputils.FileSystem
}
func (req *FileTransferRequest) checkAndSetDefaults() error {
@@ -141,67 +108,6 @@ func (req *FileTransferRequest) checkAndSetDefaults() error {
return nil
}
// File is the file interface required for [FileSystem].
type File interface {
sftp.WriterAtReaderAt
io.ReadWriteCloser
// Name returns the name of the file.
Name() string
// Stat returns the files stat info.
Stat() (fs.FileInfo, error)
}
// FileSystem describes file operations to be done either locally or over SFTP.
//
// Note: errors returned by a FileSystem should not be `trace.Wrap()`ed so the
// sftp package can parse os errors.
type FileSystem interface {
io.Closer
// Type returns whether the filesystem is "local" or "remote".
Type() string
// Glob returns matching files of a glob pattern.
Glob(pattern string) ([]string, error)
// Stat returns info about a file.
Stat(path string) (os.FileInfo, error)
// ReadDir returns information about files contained within a directory.
ReadDir(path string) ([]os.FileInfo, error)
// Open opens a file for reading.
Open(path string) (File, error)
// Create creates a new file for writing.
Create(path string, size int64) (File, error)
// Mkdir creates a directory.
Mkdir(path string) error
// Chmod sets file permissions.
Chmod(path string, mode os.FileMode) error
// Chtimes sets file access and modification time.
Chtimes(path string, atime, mtime time.Time) error
// OpenFile opens a file with the given flags.
OpenFile(path string, flags int) (File, error)
// Rename renames a file.
Rename(oldpath, newpath string) error
// Lstat returns info about a file or symlink.
Lstat(name string) (os.FileInfo, error)
// RemoveAll recursively removes a file or directory.
RemoveAll(path string) error
// Link creates a new link.
Link(oldname, newname string) error
// Symlink creates a new symlink.
Symlink(oldname, newname string) error
// Remove removes a file or (empty) directory.
Remove(name string) error
// Chown changes a file's owner and/or group.
Chown(name string, uid, gid int) error
// Truncate truncates a file's contents.
Truncate(name string, size int64) error
// Readlink gets the destination for a symlink.
Readlink(name string) (string, error)
// Getwd gets the current working directory.
Getwd() (string, error)
// RealPath canonicalizes a path name, including resolving ".." and
// following symlinks.
RealPath(path string) (string, error)
}
// HTTPTransferRequest describes file transfer request over HTTP.
type HTTPTransferRequest struct {
// Src is the source file name
@@ -310,14 +216,14 @@ func TransferFiles(ctx context.Context, req *FileTransferRequest) error {
return trace.Wrap(err)
}
for i, srcPath := range req.Sources.Paths {
expandedPath, err := ExpandHomeDir(srcPath)
expandedPath, err := sftputils.ExpandHomeDir(srcPath)
if err != nil {
return trace.Wrap(err)
}
req.Sources.Paths[i] = expandedPath
}
default:
req.srcFS = localFS{}
req.srcFS = sftputils.LocalFS{}
}
defer req.srcFS.Close()
@@ -338,73 +244,19 @@ func TransferFiles(ctx context.Context, req *FileTransferRequest) error {
if err != nil {
return trace.Wrap(err)
}
expandedPath, err := ExpandHomeDir(req.Destination.Path)
expandedPath, err := sftputils.ExpandHomeDir(req.Destination.Path)
if err != nil {
return trace.Wrap(err)
}
req.Destination.Path = expandedPath
default:
req.dstFS = localFS{}
req.dstFS = sftputils.LocalFS{}
}
defer req.dstFS.Close()
return trace.Wrap(transfer(ctx, req))
}
// PathExpansionError is an [error] indicating that
// path expansion was rejected.
type PathExpansionError struct {
path string
}
func (p PathExpansionError) Error() string {
return fmt.Sprintf("expanding remote ~user paths is not supported, specify an absolute path instead of %q", p.path)
}
// ExpandHomeDir evaluates the home directory ('~') in a path.
func ExpandHomeDir(pathStr string) (string, error) {
pfxLen, ok := homeDirPrefixLen(pathStr)
if !ok {
return pathStr, nil
}
if pfxLen == 1 && len(pathStr) > 1 {
return "", trace.Wrap(PathExpansionError{path: pathStr})
}
// if an SFTP path is not absolute, it is assumed to start at the user's
// home directory so just strip the prefix and let the SFTP server
// figure out the correct remote path.
trimmedPath := pathStr[pfxLen:]
// Returning an empty string is supported by SFTP but won't be as clear in
// logs or audit events. Since the SFTP server will be rooted at the user's
// home directory, "." and "" are equivalent in this context.
if trimmedPath == "" {
return ".", nil
}
return trimmedPath, nil
}
// homeDirPrefixLen returns the length of a set of characters that
// indicates the user wants the path to begin with a user's home
// directory and a bool that indicates whether such a prefix exists.
func homeDirPrefixLen(path string) (int, bool) {
if strings.HasPrefix(path, "~/") {
return 2, true
}
// allow '~\' or '~/' on Windows since '\' is the canonical path
// separator but some users may use '/' instead
if runtime.GOOS == "windows" && strings.HasPrefix(path, `~\`) {
return 2, true
}
if len(path) >= 1 && path[0] == '~' {
return 1, true
}
return -1, false
}
// transfer performs file transfers
func transfer(ctx context.Context, req *FileTransferRequest) error {
// get info of source files and ensure appropriate options were passed
@@ -439,7 +291,7 @@ func transfer(ctx context.Context, req *FileTransferRequest) error {
if fi.IsDir() && !req.Recursive {
// Note: Using an error constructor included in lib/client.IsErrorResolvableWithRelogin,
// e.g. BadParameter, will lead to relogin attempt and a completely obscure error message.
return trace.Wrap(&NonRecursiveDirectoryTransferError{Path: match})
return trace.Wrap(&sftputils.NonRecursiveDirectoryTransferError{Path: match})
}
fileInfos = append(fileInfos, fi)
}
@@ -739,173 +591,3 @@ func newProgressBar(size int64, desc string, writer io.Writer) *unboundedProgres
progressbar.OptionSetRenderBlankState(true),
)}
}
// NonRecursiveDirectoryTransferError is returned when an attempt is made
// to download a directory without providing the recursive option.
// It's used to distinguish this specific situation in clients which
// do not support the recursive option.
type NonRecursiveDirectoryTransferError struct {
Path string
}
func (n *NonRecursiveDirectoryTransferError) Error() string {
return fmt.Sprintf("%q is a directory, but the recursive option was not passed", n.Path)
}
func setstat(req *sftp.Request, fs FileSystem) error {
attrFlags := req.AttrFlags()
attrs := req.Attributes()
if attrFlags.Acmodtime {
atime := time.Unix(int64(attrs.Atime), 0)
mtime := time.Unix(int64(attrs.Mtime), 0)
err := fs.Chtimes(req.Filepath, atime, mtime)
if err != nil {
return err
}
}
if attrFlags.Permissions {
err := fs.Chmod(req.Filepath, attrs.FileMode())
if err != nil {
return err
}
}
if attrFlags.UidGid {
err := fs.Chown(req.Filepath, int(attrs.UID), int(attrs.GID))
if err != nil {
return err
}
}
if attrFlags.Size {
err := fs.Truncate(req.Filepath, int64(attrs.Size))
if err != nil {
return err
}
}
return nil
}
// HandleFilecmd handles file command requests. If filesys is nil, the local
// filesystem will be used.
func HandleFilecmd(req *sftp.Request, filesys FileSystem) error {
if filesys == nil {
filesys = localFS{}
}
switch req.Method {
case MethodSetStat:
return setstat(req, filesys)
case MethodRename:
if req.Target == "" {
return os.ErrInvalid
}
return filesys.Rename(req.Filepath, req.Target)
case MethodRmdir:
fi, err := filesys.Lstat(req.Filepath)
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("%q is not a directory", req.Filepath)
}
return filesys.RemoveAll(req.Filepath)
case MethodMkdir:
return filesys.Mkdir(req.Filepath)
case MethodLink:
if req.Target == "" {
return os.ErrInvalid
}
return filesys.Link(req.Target, req.Filepath)
case MethodSymlink:
if req.Target == "" {
return os.ErrInvalid
}
return filesys.Symlink(req.Target, req.Filepath)
case MethodRemove:
fi, err := filesys.Lstat(req.Filepath)
if err != nil {
return err
}
if fi.IsDir() {
return fmt.Errorf("%q is a directory", req.Filepath)
}
return filesys.Remove(req.Filepath)
default:
return sftp.ErrSSHFxOpUnsupported
}
}
// listerAt satisfies [sftp.listerAt].
type listerAt []fs.FileInfo
func (l listerAt) ListAt(ls []fs.FileInfo, offset int64) (int, error) {
if offset >= int64(len(l)) {
return 0, io.EOF
}
n := copy(ls, l[offset:])
if n < len(ls) {
return n, io.EOF
}
return n, nil
}
// fileName satisfies [fs.FileInfo] but only knows a file's name. This
// is necessary when handling 'readlink' requests in sftpHandler.FileList,
// as only the file's name is known after a readlink call.
type fileName string
func (f fileName) Name() string {
return string(f)
}
func (f fileName) Size() int64 {
return 0
}
func (f fileName) Mode() fs.FileMode {
return 0
}
func (f fileName) ModTime() time.Time {
return time.Time{}
}
func (f fileName) IsDir() bool {
return false
}
func (f fileName) Sys() any {
return nil
}
// HandleFilelist handles file list requests. If filesys is nil, the local
// filesystem will be used.
func HandleFilelist(req *sftp.Request, filesys FileSystem) (sftp.ListerAt, error) {
if filesys == nil {
filesys = localFS{}
}
switch req.Method {
case MethodList:
entries, err := filesys.ReadDir(req.Filepath)
if err != nil {
return nil, err
}
return listerAt(entries), nil
case MethodStat:
fi, err := filesys.Stat(req.Filepath)
if err != nil {
return nil, err
}
return listerAt{fi}, nil
case MethodReadlink:
dst, err := filesys.Readlink(req.Filepath)
if err != nil {
return nil, err
}
return listerAt{fileName(dst)}, nil
default:
return nil, sftp.ErrSSHFxOpUnsupported
}
}
+8 -397
View File
@@ -24,9 +24,7 @@ import (
cryptorand "crypto/rand"
"fmt"
"io"
"io/fs"
mathrand "math/rand/v2"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -34,16 +32,13 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/gravitational/trace"
"github.com/pkg/sftp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/utils/log/logtest"
"github.com/gravitational/teleport/session/sftputils"
)
const fileMaxSize = 1000
@@ -411,7 +406,7 @@ func TestTransferFiles(t *testing.T) {
},
errCheck: func(t require.TestingT, err error, i ...any) {
require.EqualError(t, err, fmt.Sprintf(`"%s/src" is a directory, but the recursive option was not passed`, i[0]))
require.ErrorAs(t, err, new(*NonRecursiveDirectoryTransferError))
require.ErrorAs(t, err, new(*sftputils.NonRecursiveDirectoryTransferError))
},
},
{
@@ -466,57 +461,6 @@ func TestTransferFiles(t *testing.T) {
}
}
func TestHomeDirExpansion(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
expandedPath string
errCheck require.ErrorAssertionFunc
}{
{
name: "absolute path",
path: "/foo/bar",
expandedPath: "/foo/bar",
},
{
name: "path with tilde-slash",
path: "~/foo/bar",
expandedPath: "foo/bar",
},
{
name: "just tilde",
path: "~",
expandedPath: ".",
},
{
name: "tilde slash",
path: "~/",
expandedPath: ".",
},
{
name: "~user path",
path: "~user/foo",
errCheck: func(t require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, PathExpansionError{path: "~user/foo"})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expanded, err := ExpandHomeDir(tt.path)
if tt.errCheck == nil {
require.NoError(t, err)
require.Equal(t, tt.expandedPath, expanded)
} else {
tt.errCheck(t, err)
}
})
}
}
func TestCopyingSymlinkedFile(t *testing.T) {
t.Parallel()
@@ -543,7 +487,7 @@ func TestCopyingSymlinkedFile(t *testing.T) {
}
type mockFile struct {
File
sftputils.File
altDataSource io.Reader
}
@@ -552,21 +496,21 @@ func (m *mockFile) Read(p []byte) (int, error) {
}
type mockFS struct {
localFS
sftputils.LocalFS
fileAccesses map[string]int
altData io.Reader
}
func (m *mockFS) Open(path string) (File, error) {
func (m *mockFS) Open(path string) (sftputils.File, error) {
if m.fileAccesses == nil {
m.fileAccesses = make(map[string]int)
}
realPath, err := m.localFS.RealPath(path)
realPath, err := m.LocalFS.RealPath(path)
if err != nil {
return nil, trace.Wrap(err)
}
m.fileAccesses[realPath]++
file, err := m.localFS.Open(path)
file, err := m.LocalFS.Open(path)
if err != nil || m.altData == nil {
return file, err
}
@@ -676,7 +620,7 @@ func TestHTTPUpload(t *testing.T) {
},
)
require.NoError(t, err)
transferReq.dstFS = &localFS{}
transferReq.dstFS = &sftputils.LocalFS{}
err = TransferFiles(t.Context(), transferReq)
require.NoError(t, err)
@@ -868,336 +812,3 @@ func compareFileInfos(t *testing.T, preserveAttrs bool, dstInfo, srcInfo os.File
// often different when run in CI
}
}
type mockCmdHandlers struct {
sftp.Handlers
}
func (m mockCmdHandlers) Filecmd(req *sftp.Request) error {
return trace.Wrap(HandleFilecmd(req, localFS{}))
}
func TestHandleFilecmd(t *testing.T) {
t.Parallel()
// We're using a full client/server instead of just calling HandleFilecmd so
// the sftp package can handle marshaling attributes.
clientConn, serverConn := net.Pipe()
srv := sftp.NewRequestServer(serverConn, sftp.Handlers{
FileGet: sftp.InMemHandler().FileGet,
FilePut: sftp.InMemHandler().FilePut,
FileCmd: mockCmdHandlers{},
FileList: sftp.InMemHandler().FileList,
})
t.Cleanup(func() { require.NoError(t, srv.Close()) })
go srv.Serve()
clt, err := sftp.NewClientPipe(clientConn, clientConn)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, clt.Close()) })
t.Run("chtimes", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
originalInfo, err := os.Stat(file)
require.NoError(t, err)
setTime := originalInfo.ModTime().Add(time.Hour).Round(time.Second)
assert.NoError(t, clt.Chtimes(file, setTime, setTime))
updatedInfo, err := os.Stat(file)
if assert.NoError(t, err) {
assert.Equal(t, setTime, updatedInfo.ModTime())
}
})
t.Run("chmod", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.NoError(t, clt.Chmod(file, 0o666))
fi, err := os.Stat(file)
if assert.NoError(t, err) {
assert.Equal(t, fs.FileMode(0o666), fi.Mode().Perm())
}
})
t.Run("truncate", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte(strings.Repeat("a", 100)), 0o644))
assert.NoError(t, clt.Truncate(file, 50))
data, err := os.ReadFile(file)
if assert.NoError(t, err) {
assert.Len(t, data, 50)
}
})
t.Run("rename", func(t *testing.T) {
root := t.TempDir()
initialFile := filepath.Join(root, "foo.txt")
finalFile := filepath.Join(root, "bar.txt")
require.NoError(t, os.WriteFile(initialFile, []byte("test"), 0o644))
assert.NoError(t, clt.Rename(initialFile, finalFile))
assert.NoFileExists(t, initialFile)
assert.FileExists(t, finalFile)
})
t.Run("rename missing target", func(t *testing.T) {
root := t.TempDir()
initialFile := filepath.Join(root, "foo.txt")
finalFile := filepath.Join(root, "bar.txt")
assert.Error(t, clt.Rename(initialFile, finalFile))
assert.NoFileExists(t, finalFile)
})
t.Run("rmdir", func(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "foo")
innerFile := filepath.Join(dir, "test.txt")
require.NoError(t, os.Mkdir(dir, defaults.DirectoryPermissions))
require.NoError(t, os.WriteFile(innerFile, []byte("test"), 0o644))
assert.NoError(t, clt.RemoveDirectory(dir))
assert.NoDirExists(t, dir)
})
t.Run("rmdir not found", func(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "foo")
assert.Error(t, clt.RemoveDirectory(dir))
})
t.Run("rmdir not a dir", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.Error(t, clt.RemoveDirectory(file))
assert.FileExists(t, file)
})
t.Run("mkdir", func(t *testing.T) {
root := t.TempDir()
outer := filepath.Join(root, "a")
inner := filepath.Join(outer, "b/c")
require.NoError(t, os.Mkdir(outer, defaults.DirectoryPermissions))
assert.NoError(t, clt.Mkdir(inner))
assert.DirExists(t, inner)
})
t.Run("link", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
target := filepath.Join(root, "bar.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.NoError(t, clt.Link(target, file))
fi, err := os.Lstat(target)
if assert.NoError(t, err) {
assert.Zero(t, fi.Mode()&os.ModeSymlink)
}
})
t.Run("link missing target", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
target := filepath.Join(root, "bar.txt")
assert.Error(t, clt.Link(target, file))
assert.NoFileExists(t, target)
})
t.Run("link unset target", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.Error(t, clt.Link(file, ""))
})
t.Run("symlink", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
target := filepath.Join(root, "bar.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.NoError(t, clt.Symlink(target, file))
fi, err := os.Lstat(target)
assert.NoError(t, err)
assert.NotZero(t, fi.Mode()&os.ModeSymlink)
})
t.Run("symlink unset target", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.Error(t, clt.Symlink(file, ""))
})
t.Run("remove", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.NoError(t, clt.Remove(file))
assert.NoFileExists(t, file)
})
t.Run("remove not found", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
assert.Error(t, clt.Remove(file))
})
t.Run("remove directory", func(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "dir")
require.NoError(t, os.Mkdir(dir, defaults.DirectoryPermissions))
assert.NoError(t, clt.Remove(dir))
assert.NoDirExists(t, dir)
})
t.Run("unsupported operation", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("foo"), 0o644))
req := sftp.NewRequest(MethodStat, file)
assert.Error(t, HandleFilecmd(req, localFS{}))
})
}
type fileInfo struct {
name string
mode fs.FileMode
size int64
}
func (fi fileInfo) Name() string {
return fi.name
}
func (fi fileInfo) Size() int64 {
return fi.size
}
func (fi fileInfo) Mode() fs.FileMode {
return fi.mode
}
func (fi fileInfo) ModTime() time.Time {
return time.Time{}
}
func (fi fileInfo) IsDir() bool {
return false
}
func (fi fileInfo) Sys() any {
return nil
}
func TestHandleFilelist(t *testing.T) {
t.Parallel()
root := t.TempDir()
statMap := make(map[string]fs.FileInfo, 10)
for i := range 5 {
fileName := fmt.Sprintf("file-%d", i)
file := filepath.Join(root, fileName)
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
statMap[fileName] = fileInfo{
name: fileName,
mode: 0o644,
size: 4,
}
symlinkName := fmt.Sprintf("file-%d", i+5)
symlink := filepath.Join(root, symlinkName)
require.NoError(t, os.Symlink(file, symlink))
statMap[symlinkName] = fileInfo{
name: symlinkName,
mode: 0o644,
size: 4,
}
}
// Add a broken symlink.
brokenSymlinkName := "broken-symlink"
brokenSymlink := filepath.Join(root, brokenSymlinkName)
brokenTarget := filepath.Join(root, "this-file-does-not-exist")
require.NoError(t, os.Symlink(brokenTarget, brokenSymlink))
symlinkStat, err := os.Lstat(brokenSymlink)
require.NoError(t, err)
statMap[brokenSymlinkName] = fileInfo{
name: brokenSymlinkName,
mode: symlinkStat.Mode(),
size: int64(len(brokenTarget)),
}
tests := []struct {
name string
req *sftp.Request
assert assert.ErrorAssertionFunc
expectedOutput map[string]fs.FileInfo
}{
{
name: "list",
req: sftp.NewRequest(MethodList, root),
assert: assert.NoError,
expectedOutput: statMap,
},
{
name: "stat",
req: sftp.NewRequest(MethodStat, root+"/file-0"),
assert: assert.NoError,
expectedOutput: map[string]fs.FileInfo{
"file-0": fileInfo{
name: "file-0",
mode: 0o644,
size: 4,
},
},
},
{
name: "readlink",
req: sftp.NewRequest(MethodReadlink, root+"/file-5"),
assert: assert.NoError,
expectedOutput: map[string]fs.FileInfo{
root + "/file-0": fileName(root + "/file-0"),
},
},
{
name: "unsupported operation",
req: sftp.NewRequest(MethodRemove, root),
assert: assert.Error,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
lister, err := HandleFilelist(tc.req, localFS{})
tc.assert(t, err)
if tc.expectedOutput == nil {
assert.Nil(t, lister)
return
}
assert.NotNil(t, lister)
list := make([]fs.FileInfo, len(tc.expectedOutput))
n, err := lister.ListAt(list, 0)
assert.NoError(t, err)
assert.Equal(t, len(tc.expectedOutput), n)
for _, fi := range list {
entry, ok := tc.expectedOutput[fi.Name()]
if assert.True(t, ok, "unexpected file %q", fi.Name()) {
assert.Equal(t, entry.Name(), fi.Name())
assert.Equal(t, entry.Size(), fi.Size(), fi.Name())
assert.Equal(t, entry.Mode(), fi.Mode(), "%s: expected mode 0o%o, got mode 0o%o", fi.Name(), entry.Mode(), fi.Mode())
}
}
})
}
}
-199
View File
@@ -24,26 +24,8 @@ import (
"io"
"io/fs"
"os"
"sync/atomic"
"time"
"github.com/gravitational/trace"
"github.com/pkg/sftp"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/events"
)
// fileWrapper is a wrapper for *os.File that implements the WriteTo() method
// required for concurrent data transfer.
type fileWrapper struct {
*os.File
}
func (wt *fileWrapper) WriteTo(w io.Writer) (n int64, err error) {
return io.Copy(w, wt.File)
}
// fileStreamReader is a thin wrapper around fs.File with additional streams.
type fileStreamReader struct {
ctx context.Context
@@ -91,184 +73,3 @@ func (c *cancelWriter) Write(b []byte) (int, error) {
}
return c.stream.Write(b)
}
// TrackedFile is a [File] that counts the bytes read from/written to it.
type TrackedFile struct {
File
// BytesRead is the number of bytes read.
bytesRead atomic.Uint64
// BytesWritten is the number of bytes written.
bytesWritten atomic.Uint64
}
func (t *TrackedFile) ReadAt(b []byte, off int64) (int, error) {
n, err := t.File.ReadAt(b, off)
t.bytesRead.Add(uint64(n))
return n, err
}
func (t *TrackedFile) WriteAt(b []byte, off int64) (int, error) {
n, err := t.File.WriteAt(b, off)
t.bytesWritten.Add(uint64(n))
return n, err
}
func (t *TrackedFile) BytesRead() uint64 {
return t.bytesRead.Load()
}
func (t *TrackedFile) BytesWritten() uint64 {
return t.bytesWritten.Load()
}
// ParseFlags parses Open flags from an SFTP request to an int as used by
// [os.OpenFile].
func ParseFlags(req *sftp.Request) int {
pflags := req.Pflags()
var flags int
if pflags.Read && pflags.Write {
flags = os.O_RDWR
} else if pflags.Read {
flags = os.O_RDONLY
} else if pflags.Write {
flags = os.O_WRONLY
}
if pflags.Append {
flags |= os.O_APPEND
}
if pflags.Creat {
flags |= os.O_CREATE
}
if pflags.Excl {
flags |= os.O_EXCL
}
if pflags.Trunc {
flags |= os.O_TRUNC
}
return flags
}
// ParseSFTPEvent parses an SFTP request and associated error into an SFTP
// audit event.
func ParseSFTPEvent(req *sftp.Request, workingDirectory string, reqErr error) (*apievents.SFTP, error) {
event := &apievents.SFTP{
Metadata: apievents.Metadata{
Type: events.SFTPEvent,
Time: time.Now(),
},
}
switch req.Method {
case MethodOpen, MethodGet, MethodPut:
if reqErr == nil {
event.Code = events.SFTPOpenCode
} else {
event.Code = events.SFTPOpenFailureCode
}
event.Action = apievents.SFTPAction_OPEN
case MethodSetStat:
if reqErr == nil {
event.Code = events.SFTPSetstatCode
} else {
event.Code = events.SFTPSetstatFailureCode
}
event.Action = apievents.SFTPAction_SETSTAT
case MethodList:
if reqErr == nil {
event.Code = events.SFTPReaddirCode
} else {
event.Code = events.SFTPReaddirFailureCode
}
event.Action = apievents.SFTPAction_READDIR
case MethodRemove:
if reqErr == nil {
event.Code = events.SFTPRemoveCode
} else {
event.Code = events.SFTPRemoveFailureCode
}
event.Action = apievents.SFTPAction_REMOVE
case MethodMkdir:
if reqErr == nil {
event.Code = events.SFTPMkdirCode
} else {
event.Code = events.SFTPMkdirFailureCode
}
event.Action = apievents.SFTPAction_MKDIR
case MethodRmdir:
if reqErr == nil {
event.Code = events.SFTPRmdirCode
} else {
event.Code = events.SFTPRmdirFailureCode
}
event.Action = apievents.SFTPAction_RMDIR
case MethodRename:
if reqErr == nil {
event.Code = events.SFTPRenameCode
} else {
event.Code = events.SFTPRenameFailureCode
}
event.Action = apievents.SFTPAction_RENAME
case MethodSymlink:
if reqErr == nil {
event.Code = events.SFTPSymlinkCode
} else {
event.Code = events.SFTPSymlinkFailureCode
}
event.Action = apievents.SFTPAction_SYMLINK
case MethodLink:
if reqErr == nil {
event.Code = events.SFTPLinkCode
} else {
event.Code = events.SFTPLinkFailureCode
}
event.Action = apievents.SFTPAction_LINK
default:
return nil, trace.BadParameter("unknown SFTP request %q", req.Method)
}
event.Path = req.Filepath
event.TargetPath = req.Target
event.Flags = req.Flags
event.WorkingDirectory = workingDirectory
if req.Method == MethodSetStat {
attrFlags := req.AttrFlags()
attrs := req.Attributes()
event.Attributes = new(apievents.SFTPAttributes)
if attrFlags.Acmodtime {
atime := time.Unix(int64(attrs.Atime), 0)
mtime := time.Unix(int64(attrs.Mtime), 0)
event.Attributes.AccessTime = &atime
event.Attributes.ModificationTime = &mtime
}
if attrFlags.Permissions {
perms := uint32(attrs.FileMode().Perm())
event.Attributes.Permissions = &perms
}
if attrFlags.Size {
event.Attributes.FileSize = &attrs.Size
}
if attrFlags.UidGid {
event.Attributes.UID = &attrs.UID
event.Attributes.GID = &attrs.GID
}
}
if reqErr != nil {
// If possible, strip the filename from the error message. The
// path will be included in audit events already, no need to
// make the error message longer than it needs to be.
var pathErr *fs.PathError
var linkErr *os.LinkError
if errors.As(reqErr, &pathErr) {
event.Error = pathErr.Err.Error()
} else if errors.As(reqErr, &linkErr) {
event.Error = linkErr.Err.Error()
} else {
event.Error = reqErr.Error()
}
}
return event, nil
}
+2
View File
@@ -82,10 +82,12 @@ import (
"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/testenv"
)
func TestMain(m *testing.M) {
reexec.MaybeReexec()
logtest.InitLogger(testing.Verbose)
ctx, cancel := context.WithCancel(context.Background())
@@ -34,6 +34,7 @@ import (
"github.com/gravitational/teleport/lib/sshutils/sftp"
"github.com/gravitational/teleport/lib/teleterm/api/uri"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/session/sftputils"
)
type FileTransferProgressSender = func(progress *api.FileTransferProgress) error
@@ -81,7 +82,7 @@ func (c *Cluster) TransferFile(ctx context.Context, clt *client.ClusterClient, r
err := AddMetadataToRetryableError(ctx, func() error {
err := c.clusterClient.TransferFiles(ctx, sftpReq)
if errors.As(err, new(*sftp.NonRecursiveDirectoryTransferError)) {
if errors.As(err, new(*sftputils.NonRecursiveDirectoryTransferError)) {
return trace.Errorf("transferring directories through Teleport Connect is not supported at the moment, please use tsh scp -r")
}
return trace.Wrap(err)
+1 -6
View File
@@ -189,14 +189,9 @@ type WebSuite struct {
// TestMain will re-execute Teleport to run a command if "exec" is passed to
// it as an argument. Otherwise, it will run tests as normal.
func TestMain(m *testing.M) {
reexec.MaybeReexec()
logtest.InitLogger(testing.Verbose)
modules.SetInsecureTestMode(true)
// If the test is re-executing itself, execute the command that comes over
// the pipe.
if reexec.IsReexec() {
reexec.RunAndExit(os.Args[1])
return
}
ctx, cancel := context.WithCancel(context.Background())
cryptosuitestest.PrecomputeRSAKeys(ctx)
+2 -1
View File
@@ -45,6 +45,7 @@ import (
"github.com/gravitational/teleport/lib/sshca"
"github.com/gravitational/teleport/lib/sshutils/sftp"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/session/sftputils"
)
// fileTransferRequest describes HTTP file transfer request
@@ -264,7 +265,7 @@ func (h *Handler) transferFile(w http.ResponseWriter, r *http.Request, p httprou
}
if err := sftp.TransferFiles(ctx, sftpReq); err != nil {
if errors.As(err, new(*sftp.NonRecursiveDirectoryTransferError)) {
if errors.As(err, new(*sftputils.NonRecursiveDirectoryTransferError)) {
return nil, trace.Errorf("transferring directories through the Web UI is not supported at the moment, please use tsh scp -r")
}
+1 -4
View File
@@ -26,10 +26,7 @@ import (
)
func TestMain(m *testing.M) {
if IsReexec() {
RunAndExit(os.Args[1])
return
}
MaybeReexec()
if !flag.Parsed() {
flag.Parse()
+32 -8
View File
@@ -56,6 +56,7 @@ import (
"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/reexec/reexecsftp"
"github.com/gravitational/teleport/session/selinux"
"github.com/gravitational/teleport/session/shell"
"github.com/gravitational/teleport/session/uacc"
@@ -1122,6 +1123,12 @@ func RunAndExit(commandType string) {
code = runCheckHomeDir()
case reexecconstants.ParkSubCommand:
code = runPark()
case reexecconstants.SFTPSubCommand:
initLogger("sftp", os.Stderr, ExecLogConfig{})
err = reexecsftp.RunSFTP(slog.Default())
if err != nil {
code = 1
}
default:
code, err = reexecconstants.RemoteCommandFailure, fmt.Errorf("unknown command type: %v", commandType)
}
@@ -1144,19 +1151,36 @@ func RunAndExit(commandType string) {
os.Exit(code)
}
// MaybeReexec checks if the command-line arguments are those of a Teleport
// reexec command, and if so, runs the logic for the command (terminating the
// process at the end). Should be the first thing called in the main function
// for the Teleport binary or in the TestMain for packages that rely on
// reexecution.
func MaybeReexec() {
if IsReexec() {
RunAndExit(os.Args[1])
}
}
// TODO(espadolini): remove IsReexec and RunAndExit in favor of requiring MaybeReexec, after enterprise is updated
// IsReexec determines if the current process is a teleport reexec command.
// Used by tests to reroute the execution to RunAndExit.
func IsReexec() bool {
if len(os.Args) >= 2 {
switch os.Args[1] {
case reexecconstants.ExecSubCommand, reexecconstants.NetworkingSubCommand,
reexecconstants.CheckHomeDirSubCommand,
reexecconstants.ParkSubCommand, reexecconstants.SFTPSubCommand:
return true
}
if len(os.Args) < 2 {
return false
}
return false
switch os.Args[1] {
case reexecconstants.ExecSubCommand,
reexecconstants.NetworkingSubCommand,
reexecconstants.CheckHomeDirSubCommand,
reexecconstants.ParkSubCommand,
reexecconstants.SFTPSubCommand:
return true
default:
return false
}
}
// openFileAsUser opens a file as the given user to ensure proper access checks. This is unsafe and should not be used outside of
+32
View File
@@ -0,0 +1,32 @@
// 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 <http://www.gnu.org/licenses/>.
package reexecsftp
// FileTransferRequest is a request to upload or download a file from a node.
type FileTransferRequest struct {
// ID is a UUID that uniquely identifies a file transfer request
// and is unlikely to collide with another file transfer request
ID string
// Requester is the Teleport User that requested the file transfer
Requester string
// Download is true if the request is a download, false if its an upload
Download bool
// Filename is the name of the file to upload.
Filename string
// Location of the requested download or where a file will be uploaded
Location string
}
@@ -16,11 +16,10 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package common
package reexecsftp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
@@ -33,17 +32,11 @@ import (
"sync"
"time"
"github.com/gogo/protobuf/jsonpb" //nolint:depguard // needed for backwards compatibility
"github.com/gravitational/trace"
"github.com/pkg/sftp"
"golang.org/x/sys/unix"
"github.com/gravitational/teleport"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/srv"
sftputils "github.com/gravitational/teleport/lib/sshutils/sftp"
"github.com/gravitational/teleport/session/sftputils"
)
type compositeCh struct {
@@ -77,10 +70,10 @@ type sftpHandler struct {
mtx sync.Mutex
files []*sftputils.TrackedFile
events chan<- apievents.AuditEvent
events chan<- sftputils.Event
}
func newSFTPHandler(logger *slog.Logger, req *srv.FileTransferRequest, events chan<- apievents.AuditEvent) (*sftpHandler, error) {
func newSFTPHandler(logger *slog.Logger, req *FileTransferRequest, events chan<- sftputils.Event) (*sftpHandler, error) {
var allowed *allowedOps
if req != nil {
allowed = &allowedOps{
@@ -193,7 +186,7 @@ func (s *sftpHandler) openFile(req *sftp.Request) (sftp.WriterAtReaderAt, error)
return nil, err
}
f, err := os.OpenFile(req.Filepath, sftputils.ParseFlags(req), defaults.FilePermissions)
f, err := os.OpenFile(req.Filepath, sftputils.ParseFlags(req), 0o644)
if err != nil {
return nil, err
}
@@ -261,10 +254,10 @@ func (s *sftpHandler) sendSFTPEvent(req *sftp.Request, reqErr error) {
} else if reqErr != nil {
s.logger.DebugContext(req.Context(), "failed handling SFTP request", "request", req.Method, "error", reqErr)
}
s.events <- event
s.events <- sftputils.Event{SFTP: event}
}
func onSFTP() error {
func RunSFTP(logger *slog.Logger) error {
chr, err := openFD(3, "chr")
if err != nil {
return trace.Wrap(err)
@@ -281,9 +274,6 @@ func onSFTP() error {
}
defer auditFile.Close()
// Ensure the parent process will receive log messages from us
logger := slog.With(teleport.ComponentKey, teleport.ComponentSubsystemSFTP)
currentUser, err := user.Current()
if err != nil {
return trace.Wrap(err)
@@ -296,7 +286,7 @@ func onSFTP() error {
// Read the file transfer request for this session if one exists
bufferedReader := bufio.NewReader(chr)
var encodedReq []byte
var fileTransferReq *srv.FileTransferRequest
var fileTransferReq *FileTransferRequest
for {
b, err := bufferedReader.ReadByte()
if err != nil {
@@ -309,14 +299,14 @@ func onSFTP() error {
encodedReq = append(encodedReq, b)
}
if len(encodedReq) != 0 {
fileTransferReq = new(srv.FileTransferRequest)
fileTransferReq = new(FileTransferRequest)
if err := json.Unmarshal(encodedReq, fileTransferReq); err != nil {
return trace.Wrap(err)
}
}
ch := compositeCh{io.NopCloser(bufferedReader), chw}
sftpEvents := make(chan apievents.AuditEvent, 1)
sftpEvents := make(chan sftputils.Event, 1)
h, err := newSFTPHandler(logger, fileTransferReq, sftpEvents)
if err != nil {
return trace.Wrap(err)
@@ -334,25 +324,10 @@ func onSFTP() error {
// process to avoid blocking the SFTP connection on event handling
done := make(chan struct{})
go func() {
var m jsonpb.Marshaler
var buf bytes.Buffer
enc := json.NewEncoder(auditFile)
enc.SetEscapeHTML(false)
for event := range sftpEvents {
oneOfEvent, err := apievents.ToOneOf(event)
if err != nil {
logger.WarnContext(ctx, "Failed to convert SFTP event to OneOf", "error", err)
continue
}
buf.Reset()
if err := m.Marshal(&buf, oneOfEvent); err != nil {
logger.WarnContext(ctx, "Failed to marshal SFTP event", "error", err)
continue
}
// Append a NULL byte so the parent process will know where
// this event ends
buf.WriteByte(0x0)
_, err = io.Copy(auditFile, &buf)
err := enc.Encode(event)
if err != nil {
logger.WarnContext(ctx, "Failed to send SFTP event to parent", "error", err)
}
@@ -369,23 +344,20 @@ func onSFTP() error {
}
// Send a summary event last
summaryEvent := &apievents.SFTPSummary{
Metadata: apievents.Metadata{
Type: events.SFTPSummaryEvent,
Code: events.SFTPSummaryCode,
Time: time.Now(),
},
summaryEvent := &sftputils.SFTPSummaryEvent{
Time: time.Now().UnixNano(),
Stats: make([]sftputils.SFTPSummaryEventFileTransferStat, 0, len(h.files)),
}
// We don't need to worry about closing these files, handler will
// take care of that for us
for _, f := range h.files {
summaryEvent.FileTransferStats = append(summaryEvent.FileTransferStats, &apievents.FileTransferStat{
Path: f.Name(),
BytesRead: f.BytesRead(),
BytesWritten: f.BytesWritten(),
summaryEvent.Stats = append(summaryEvent.Stats, sftputils.SFTPSummaryEventFileTransferStat{
Path: f.Name(),
Read: f.BytesRead(),
Written: f.BytesWritten(),
})
}
sftpEvents <- summaryEvent
sftpEvents <- sftputils.Event{Summary: summaryEvent}
// Wait until event marshaling goroutine is finished
close(sftpEvents)
@@ -16,34 +16,32 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package sftp
package sftputils
import (
"io/fs"
"os"
"path/filepath"
"time"
"github.com/gravitational/teleport/lib/defaults"
)
// localFS provides API for accessing the files on
// LocalFS provides API for accessing the files on
// the local file system
type localFS struct{}
type LocalFS struct{}
func (l localFS) Type() string {
func (l LocalFS) Type() string {
return "local"
}
func (l localFS) Glob(pattern string) ([]string, error) {
func (l LocalFS) Glob(pattern string) ([]string, error) {
return filepath.Glob(pattern)
}
func (l localFS) Stat(path string) (os.FileInfo, error) {
func (l LocalFS) Stat(path string) (os.FileInfo, error) {
return os.Stat(path)
}
func (l localFS) ReadDir(path string) ([]os.FileInfo, error) {
func (l LocalFS) ReadDir(path string) ([]os.FileInfo, error) {
entries, err := os.ReadDir(path)
if err != nil {
return nil, err
@@ -68,7 +66,7 @@ func (l localFS) ReadDir(path string) ([]os.FileInfo, error) {
return fileInfos, nil
}
func (l localFS) Open(path string) (File, error) {
func (l LocalFS) Open(path string) (File, error) {
f, err := os.Open(path)
if err != nil {
@@ -78,16 +76,16 @@ func (l localFS) Open(path string) (File, error) {
return &fileWrapper{File: f}, nil
}
func (l localFS) Create(path string, _ int64) (File, error) {
func (l LocalFS) Create(path string, _ int64) (File, error) {
return l.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
}
func (l localFS) OpenFile(path string, flags int) (File, error) {
return os.OpenFile(path, flags, defaults.FilePermissions)
func (l LocalFS) OpenFile(path string, flags int) (File, error) {
return os.OpenFile(path, flags, 0o644)
}
func (l localFS) Mkdir(path string) error {
err := os.MkdirAll(path, defaults.DirectoryPermissions)
func (l LocalFS) Mkdir(path string) error {
err := os.MkdirAll(path, 0o755)
if err != nil && !os.IsExist(err) {
return err
}
@@ -95,59 +93,59 @@ func (l localFS) Mkdir(path string) error {
return nil
}
func (l localFS) Chmod(path string, mode os.FileMode) error {
func (l LocalFS) Chmod(path string, mode os.FileMode) error {
return os.Chmod(path, mode)
}
func (l localFS) Chtimes(path string, atime, mtime time.Time) error {
func (l LocalFS) Chtimes(path string, atime, mtime time.Time) error {
return os.Chtimes(path, atime, mtime)
}
func (l localFS) Rename(oldpath, newpath string) error {
func (l LocalFS) Rename(oldpath, newpath string) error {
return os.Rename(oldpath, newpath)
}
func (l localFS) Lstat(name string) (os.FileInfo, error) {
func (l LocalFS) Lstat(name string) (os.FileInfo, error) {
return os.Lstat(name)
}
func (l localFS) RemoveAll(path string) error {
func (l LocalFS) RemoveAll(path string) error {
return os.RemoveAll(path)
}
func (l localFS) Link(oldname, newname string) error {
func (l LocalFS) Link(oldname, newname string) error {
return os.Link(oldname, newname)
}
func (l localFS) Symlink(oldname, newname string) error {
func (l LocalFS) Symlink(oldname, newname string) error {
return os.Symlink(oldname, newname)
}
func (l localFS) Remove(name string) error {
func (l LocalFS) Remove(name string) error {
return os.Remove(name)
}
func (l localFS) Chown(name string, uid, gid int) error {
func (l LocalFS) Chown(name string, uid, gid int) error {
return os.Chown(name, uid, gid)
}
func (l localFS) Truncate(name string, size int64) error {
func (l LocalFS) Truncate(name string, size int64) error {
return os.Truncate(name, size)
}
func (l localFS) Readlink(name string) (string, error) {
func (l LocalFS) Readlink(name string) (string, error) {
return os.Readlink(name)
}
func (l localFS) Getwd() (string, error) {
func (l LocalFS) Getwd() (string, error) {
return os.Getwd()
}
func (l localFS) RealPath(path string) (string, error) {
func (l LocalFS) RealPath(path string) (string, error) {
return Realpath(path)
}
func (l localFS) Close() error {
func (l LocalFS) Close() error {
return nil
}
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package sftp
package sftputils
import (
"os"
+349
View File
@@ -0,0 +1,349 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package sftputils
import (
"fmt"
"io"
"io/fs"
"os"
"runtime"
"strings"
"time"
"github.com/gravitational/trace"
"github.com/pkg/sftp"
)
// SFTP request methods.
const (
// MethodGet opens a file for reading.
MethodGet = "Get"
// MethodPut opens a file for writing.
MethodPut = "Put"
// MethodOpen opens a file.
MethodOpen = "Open"
// MethodSetStat sets a file's stats.
MethodSetStat = "Setstat"
// MethodRename renames a file.
MethodRename = "Rename"
// MethodRmdir removes a directory.
MethodRmdir = "Rmdir"
// MethodMkdir creates a directory.
MethodMkdir = "Mkdir"
// MethodLink creates a hard link.
MethodLink = "Link"
// MethodSymlink creates a symbolic link.
MethodSymlink = "Symlink"
// MethodRemove deletes a file.
MethodRemove = "Remove"
// MethodList lists directory entries.
MethodList = "List"
// MethodStat gets a directory entry's stat info.
MethodStat = "Stat"
// MethodLstat gets a directory entry's stat info, without following symbolic links.
MethodLstat = "Lstat"
// MethodReadlink gets the target of a symbolic link.
MethodReadlink = "Readlink"
)
// File is the file interface required for [FileSystem].
type File interface {
sftp.WriterAtReaderAt
io.ReadWriteCloser
// Name returns the name of the file.
Name() string
// Stat returns the files stat info.
Stat() (fs.FileInfo, error)
}
// FileSystem describes file operations to be done either locally or over SFTP.
//
// Note: errors returned by a FileSystem should not be `trace.Wrap()`ed so the
// sftp package can parse os errors.
type FileSystem interface {
io.Closer
// Type returns whether the filesystem is "local" or "remote".
Type() string
// Glob returns matching files of a glob pattern.
Glob(pattern string) ([]string, error)
// Stat returns info about a file.
Stat(path string) (os.FileInfo, error)
// ReadDir returns information about files contained within a directory.
ReadDir(path string) ([]os.FileInfo, error)
// Open opens a file for reading.
Open(path string) (File, error)
// Create creates a new file for writing.
Create(path string, size int64) (File, error)
// Mkdir creates a directory.
Mkdir(path string) error
// Chmod sets file permissions.
Chmod(path string, mode os.FileMode) error
// Chtimes sets file access and modification time.
Chtimes(path string, atime, mtime time.Time) error
// OpenFile opens a file with the given flags.
OpenFile(path string, flags int) (File, error)
// Rename renames a file.
Rename(oldpath, newpath string) error
// Lstat returns info about a file or symlink.
Lstat(name string) (os.FileInfo, error)
// RemoveAll recursively removes a file or directory.
RemoveAll(path string) error
// Link creates a new link.
Link(oldname, newname string) error
// Symlink creates a new symlink.
Symlink(oldname, newname string) error
// Remove removes a file or (empty) directory.
Remove(name string) error
// Chown changes a file's owner and/or group.
Chown(name string, uid, gid int) error
// Truncate truncates a file's contents.
Truncate(name string, size int64) error
// Readlink gets the destination for a symlink.
Readlink(name string) (string, error)
// Getwd gets the current working directory.
Getwd() (string, error)
// RealPath canonicalizes a path name, including resolving ".." and
// following symlinks.
RealPath(path string) (string, error)
}
// PathExpansionError is an [error] indicating that
// path expansion was rejected.
type PathExpansionError struct {
path string
}
func (p PathExpansionError) Error() string {
return fmt.Sprintf("expanding remote ~user paths is not supported, specify an absolute path instead of %q", p.path)
}
// ExpandHomeDir evaluates the home directory ('~') in a path.
func ExpandHomeDir(pathStr string) (string, error) {
pfxLen, ok := homeDirPrefixLen(pathStr)
if !ok {
return pathStr, nil
}
if pfxLen == 1 && len(pathStr) > 1 {
return "", trace.Wrap(PathExpansionError{path: pathStr})
}
// if an SFTP path is not absolute, it is assumed to start at the user's
// home directory so just strip the prefix and let the SFTP server
// figure out the correct remote path.
trimmedPath := pathStr[pfxLen:]
// Returning an empty string is supported by SFTP but won't be as clear in
// logs or audit events. Since the SFTP server will be rooted at the user's
// home directory, "." and "" are equivalent in this context.
if trimmedPath == "" {
return ".", nil
}
return trimmedPath, nil
}
// homeDirPrefixLen returns the length of a set of characters that
// indicates the user wants the path to begin with a user's home
// directory and a bool that indicates whether such a prefix exists.
func homeDirPrefixLen(path string) (int, bool) {
if strings.HasPrefix(path, "~/") {
return 2, true
}
// allow '~\' or '~/' on Windows since '\' is the canonical path
// separator but some users may use '/' instead
if runtime.GOOS == "windows" && strings.HasPrefix(path, `~\`) {
return 2, true
}
if len(path) >= 1 && path[0] == '~' {
return 1, true
}
return -1, false
}
// NonRecursiveDirectoryTransferError is returned when an attempt is made
// to download a directory without providing the recursive option.
// It's used to distinguish this specific situation in clients which
// do not support the recursive option.
type NonRecursiveDirectoryTransferError struct {
Path string
}
func (n *NonRecursiveDirectoryTransferError) Error() string {
return fmt.Sprintf("%q is a directory, but the recursive option was not passed", n.Path)
}
func setstat(req *sftp.Request, fs FileSystem) error {
attrFlags := req.AttrFlags()
attrs := req.Attributes()
if attrFlags.Acmodtime {
atime := time.Unix(int64(attrs.Atime), 0)
mtime := time.Unix(int64(attrs.Mtime), 0)
err := fs.Chtimes(req.Filepath, atime, mtime)
if err != nil {
return err
}
}
if attrFlags.Permissions {
err := fs.Chmod(req.Filepath, attrs.FileMode())
if err != nil {
return err
}
}
if attrFlags.UidGid {
err := fs.Chown(req.Filepath, int(attrs.UID), int(attrs.GID))
if err != nil {
return err
}
}
if attrFlags.Size {
err := fs.Truncate(req.Filepath, int64(attrs.Size))
if err != nil {
return err
}
}
return nil
}
// HandleFilecmd handles file command requests. If filesys is nil, the local
// filesystem will be used.
func HandleFilecmd(req *sftp.Request, filesys FileSystem) error {
if filesys == nil {
filesys = LocalFS{}
}
switch req.Method {
case MethodSetStat:
return setstat(req, filesys)
case MethodRename:
if req.Target == "" {
return os.ErrInvalid
}
return filesys.Rename(req.Filepath, req.Target)
case MethodRmdir:
fi, err := filesys.Lstat(req.Filepath)
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("%q is not a directory", req.Filepath)
}
return filesys.RemoveAll(req.Filepath)
case MethodMkdir:
return filesys.Mkdir(req.Filepath)
case MethodLink:
if req.Target == "" {
return os.ErrInvalid
}
return filesys.Link(req.Target, req.Filepath)
case MethodSymlink:
if req.Target == "" {
return os.ErrInvalid
}
return filesys.Symlink(req.Target, req.Filepath)
case MethodRemove:
fi, err := filesys.Lstat(req.Filepath)
if err != nil {
return err
}
if fi.IsDir() {
return fmt.Errorf("%q is a directory", req.Filepath)
}
return filesys.Remove(req.Filepath)
default:
return sftp.ErrSSHFxOpUnsupported
}
}
// listerAt satisfies [sftp.listerAt].
type listerAt []fs.FileInfo
func (l listerAt) ListAt(ls []fs.FileInfo, offset int64) (int, error) {
if offset >= int64(len(l)) {
return 0, io.EOF
}
n := copy(ls, l[offset:])
if n < len(ls) {
return n, io.EOF
}
return n, nil
}
// fileName satisfies [fs.FileInfo] but only knows a file's name. This
// is necessary when handling 'readlink' requests in sftpHandler.FileList,
// as only the file's name is known after a readlink call.
type fileName string
func (f fileName) Name() string {
return string(f)
}
func (f fileName) Size() int64 {
return 0
}
func (f fileName) Mode() fs.FileMode {
return 0
}
func (f fileName) ModTime() time.Time {
return time.Time{}
}
func (f fileName) IsDir() bool {
return false
}
func (f fileName) Sys() any {
return nil
}
// HandleFilelist handles file list requests. If filesys is nil, the local
// filesystem will be used.
func HandleFilelist(req *sftp.Request, filesys FileSystem) (sftp.ListerAt, error) {
if filesys == nil {
filesys = LocalFS{}
}
switch req.Method {
case MethodList:
entries, err := filesys.ReadDir(req.Filepath)
if err != nil {
return nil, err
}
return listerAt(entries), nil
case MethodStat:
fi, err := filesys.Stat(req.Filepath)
if err != nil {
return nil, err
}
return listerAt{fi}, nil
case MethodReadlink:
dst, err := filesys.Readlink(req.Filepath)
if err != nil {
return nil, err
}
return listerAt{fileName(dst)}, nil
default:
return nil, sftp.ErrSSHFxOpUnsupported
}
}
+419
View File
@@ -0,0 +1,419 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package sftputils
import (
"fmt"
"io/fs"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gravitational/trace"
"github.com/pkg/sftp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHomeDirExpansion(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
expandedPath string
errCheck require.ErrorAssertionFunc
}{
{
name: "absolute path",
path: "/foo/bar",
expandedPath: "/foo/bar",
},
{
name: "path with tilde-slash",
path: "~/foo/bar",
expandedPath: "foo/bar",
},
{
name: "just tilde",
path: "~",
expandedPath: ".",
},
{
name: "tilde slash",
path: "~/",
expandedPath: ".",
},
{
name: "~user path",
path: "~user/foo",
errCheck: func(t require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, PathExpansionError{path: "~user/foo"})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expanded, err := ExpandHomeDir(tt.path)
if tt.errCheck == nil {
require.NoError(t, err)
require.Equal(t, tt.expandedPath, expanded)
} else {
tt.errCheck(t, err)
}
})
}
}
type mockCmdHandlers struct {
sftp.Handlers
}
func (m mockCmdHandlers) Filecmd(req *sftp.Request) error {
return trace.Wrap(HandleFilecmd(req, LocalFS{}))
}
func TestHandleFilecmd(t *testing.T) {
t.Parallel()
// We're using a full client/server instead of just calling HandleFilecmd so
// the sftp package can handle marshaling attributes.
clientConn, serverConn := net.Pipe()
srv := sftp.NewRequestServer(serverConn, sftp.Handlers{
FileGet: sftp.InMemHandler().FileGet,
FilePut: sftp.InMemHandler().FilePut,
FileCmd: mockCmdHandlers{},
FileList: sftp.InMemHandler().FileList,
})
t.Cleanup(func() { require.NoError(t, srv.Close()) })
go srv.Serve()
clt, err := sftp.NewClientPipe(clientConn, clientConn)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, clt.Close()) })
t.Run("chtimes", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
originalInfo, err := os.Stat(file)
require.NoError(t, err)
setTime := originalInfo.ModTime().Add(time.Hour).Round(time.Second)
assert.NoError(t, clt.Chtimes(file, setTime, setTime))
updatedInfo, err := os.Stat(file)
if assert.NoError(t, err) {
assert.Equal(t, setTime, updatedInfo.ModTime())
}
})
t.Run("chmod", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.NoError(t, clt.Chmod(file, 0o666))
fi, err := os.Stat(file)
if assert.NoError(t, err) {
assert.Equal(t, fs.FileMode(0o666), fi.Mode().Perm())
}
})
t.Run("truncate", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte(strings.Repeat("a", 100)), 0o644))
assert.NoError(t, clt.Truncate(file, 50))
data, err := os.ReadFile(file)
if assert.NoError(t, err) {
assert.Len(t, data, 50)
}
})
t.Run("rename", func(t *testing.T) {
root := t.TempDir()
initialFile := filepath.Join(root, "foo.txt")
finalFile := filepath.Join(root, "bar.txt")
require.NoError(t, os.WriteFile(initialFile, []byte("test"), 0o644))
assert.NoError(t, clt.Rename(initialFile, finalFile))
assert.NoFileExists(t, initialFile)
assert.FileExists(t, finalFile)
})
t.Run("rename missing target", func(t *testing.T) {
root := t.TempDir()
initialFile := filepath.Join(root, "foo.txt")
finalFile := filepath.Join(root, "bar.txt")
assert.Error(t, clt.Rename(initialFile, finalFile))
assert.NoFileExists(t, finalFile)
})
t.Run("rmdir", func(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "foo")
innerFile := filepath.Join(dir, "test.txt")
require.NoError(t, os.Mkdir(dir, 0o755))
require.NoError(t, os.WriteFile(innerFile, []byte("test"), 0o644))
assert.NoError(t, clt.RemoveDirectory(dir))
assert.NoDirExists(t, dir)
})
t.Run("rmdir not found", func(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "foo")
assert.Error(t, clt.RemoveDirectory(dir))
})
t.Run("rmdir not a dir", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.Error(t, clt.RemoveDirectory(file))
assert.FileExists(t, file)
})
t.Run("mkdir", func(t *testing.T) {
root := t.TempDir()
outer := filepath.Join(root, "a")
inner := filepath.Join(outer, "b/c")
require.NoError(t, os.Mkdir(outer, 0o755))
assert.NoError(t, clt.Mkdir(inner))
assert.DirExists(t, inner)
})
t.Run("link", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
target := filepath.Join(root, "bar.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.NoError(t, clt.Link(target, file))
fi, err := os.Lstat(target)
if assert.NoError(t, err) {
assert.Zero(t, fi.Mode()&os.ModeSymlink)
}
})
t.Run("link missing target", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
target := filepath.Join(root, "bar.txt")
assert.Error(t, clt.Link(target, file))
assert.NoFileExists(t, target)
})
t.Run("link unset target", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.Error(t, clt.Link(file, ""))
})
t.Run("symlink", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
target := filepath.Join(root, "bar.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.NoError(t, clt.Symlink(target, file))
fi, err := os.Lstat(target)
assert.NoError(t, err)
assert.NotZero(t, fi.Mode()&os.ModeSymlink)
})
t.Run("symlink unset target", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "foo.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.Error(t, clt.Symlink(file, ""))
})
t.Run("remove", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
assert.NoError(t, clt.Remove(file))
assert.NoFileExists(t, file)
})
t.Run("remove not found", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
assert.Error(t, clt.Remove(file))
})
t.Run("remove directory", func(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "dir")
require.NoError(t, os.Mkdir(dir, 0o755))
assert.NoError(t, clt.Remove(dir))
assert.NoDirExists(t, dir)
})
t.Run("unsupported operation", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "test.txt")
require.NoError(t, os.WriteFile(file, []byte("foo"), 0o644))
req := sftp.NewRequest(MethodStat, file)
assert.Error(t, HandleFilecmd(req, LocalFS{}))
})
}
type fileInfo struct {
name string
mode fs.FileMode
size int64
}
func (fi fileInfo) Name() string {
return fi.name
}
func (fi fileInfo) Size() int64 {
return fi.size
}
func (fi fileInfo) Mode() fs.FileMode {
return fi.mode
}
func (fi fileInfo) ModTime() time.Time {
return time.Time{}
}
func (fi fileInfo) IsDir() bool {
return false
}
func (fi fileInfo) Sys() any {
return nil
}
func TestHandleFilelist(t *testing.T) {
t.Parallel()
root := t.TempDir()
statMap := make(map[string]fs.FileInfo, 10)
for i := range 5 {
fileName := fmt.Sprintf("file-%d", i)
file := filepath.Join(root, fileName)
require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
statMap[fileName] = fileInfo{
name: fileName,
mode: 0o644,
size: 4,
}
symlinkName := fmt.Sprintf("file-%d", i+5)
symlink := filepath.Join(root, symlinkName)
require.NoError(t, os.Symlink(file, symlink))
statMap[symlinkName] = fileInfo{
name: symlinkName,
mode: 0o644,
size: 4,
}
}
// Add a broken symlink.
brokenSymlinkName := "broken-symlink"
brokenSymlink := filepath.Join(root, brokenSymlinkName)
brokenTarget := filepath.Join(root, "this-file-does-not-exist")
require.NoError(t, os.Symlink(brokenTarget, brokenSymlink))
symlinkStat, err := os.Lstat(brokenSymlink)
require.NoError(t, err)
statMap[brokenSymlinkName] = fileInfo{
name: brokenSymlinkName,
mode: symlinkStat.Mode(),
size: int64(len(brokenTarget)),
}
tests := []struct {
name string
req *sftp.Request
assert assert.ErrorAssertionFunc
expectedOutput map[string]fs.FileInfo
}{
{
name: "list",
req: sftp.NewRequest(MethodList, root),
assert: assert.NoError,
expectedOutput: statMap,
},
{
name: "stat",
req: sftp.NewRequest(MethodStat, root+"/file-0"),
assert: assert.NoError,
expectedOutput: map[string]fs.FileInfo{
"file-0": fileInfo{
name: "file-0",
mode: 0o644,
size: 4,
},
},
},
{
name: "readlink",
req: sftp.NewRequest(MethodReadlink, root+"/file-5"),
assert: assert.NoError,
expectedOutput: map[string]fs.FileInfo{
root + "/file-0": fileName(root + "/file-0"),
},
},
{
name: "unsupported operation",
req: sftp.NewRequest(MethodRemove, root),
assert: assert.Error,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
lister, err := HandleFilelist(tc.req, LocalFS{})
tc.assert(t, err)
if tc.expectedOutput == nil {
assert.Nil(t, lister)
return
}
assert.NotNil(t, lister)
list := make([]fs.FileInfo, len(tc.expectedOutput))
n, err := lister.ListAt(list, 0)
assert.NoError(t, err)
assert.Equal(t, len(tc.expectedOutput), n)
for _, fi := range list {
entry, ok := tc.expectedOutput[fi.Name()]
if assert.True(t, ok, "unexpected file %q", fi.Name()) {
assert.Equal(t, entry.Name(), fi.Name())
assert.Equal(t, entry.Size(), fi.Size(), fi.Name())
assert.Equal(t, entry.Mode(), fi.Mode(), "%s: expected mode 0o%o, got mode 0o%o", fi.Name(), entry.Mode(), fi.Mode())
}
}
})
}
}
+233
View File
@@ -0,0 +1,233 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package sftputils
import (
"errors"
"io"
"io/fs"
"os"
"sync/atomic"
"time"
"github.com/gravitational/trace"
"github.com/pkg/sftp"
)
// fileWrapper is a wrapper for *os.File that implements the WriteTo() method
// required for concurrent data transfer.
type fileWrapper struct {
*os.File
}
func (wt *fileWrapper) WriteTo(w io.Writer) (n int64, err error) {
return io.Copy(w, wt.File)
}
// TrackedFile is a [File] that counts the bytes read from/written to it.
type TrackedFile struct {
File
// BytesRead is the number of bytes read.
bytesRead atomic.Uint64
// BytesWritten is the number of bytes written.
bytesWritten atomic.Uint64
}
func (t *TrackedFile) ReadAt(b []byte, off int64) (int, error) {
n, err := t.File.ReadAt(b, off)
t.bytesRead.Add(uint64(n))
return n, err
}
func (t *TrackedFile) WriteAt(b []byte, off int64) (int, error) {
n, err := t.File.WriteAt(b, off)
t.bytesWritten.Add(uint64(n))
return n, err
}
func (t *TrackedFile) BytesRead() uint64 {
return t.bytesRead.Load()
}
func (t *TrackedFile) BytesWritten() uint64 {
return t.bytesWritten.Load()
}
// ParseFlags parses Open flags from an SFTP request to an int as used by
// [os.OpenFile].
func ParseFlags(req *sftp.Request) int {
pflags := req.Pflags()
var flags int
if pflags.Read && pflags.Write {
flags = os.O_RDWR
} else if pflags.Read {
flags = os.O_RDONLY
} else if pflags.Write {
flags = os.O_WRONLY
}
if pflags.Append {
flags |= os.O_APPEND
}
if pflags.Creat {
flags |= os.O_CREATE
}
if pflags.Excl {
flags |= os.O_EXCL
}
if pflags.Trunc {
flags |= os.O_TRUNC
}
return flags
}
// Event is an audit log event passed from the SFTP server process back to the
// main Teleport process. Only one field at a time should be set.
type Event struct {
SFTP *SFTPEvent `json:",omitempty"`
Summary *SFTPSummaryEvent `json:",omitempty"`
}
// SFTPEvent is an event generated in response to a SFTP operation.
type SFTPEvent struct {
// Time is the event timestamp in nanos since the Unix epoch.
Time int64
// Method is the SFTP method.
Method string
// Error, if non-empty, signifies that the request has failed and Error
// contains the error message.
Error string `json:",omitempty"`
// Path is the filepath sent by the client.
Path string
// Target is the new path sent by the client for rename and link operations.
Target string `json:",omitempty"`
// Flags are the numerical SFTP flags for the operations, with meaning that
// depends on the operation (Open/Write or SetStat, typically).
Flags uint32
// WorkDir is the directory that the SFTP server process is in.
WorkDir string
// Attrs contains attributes, currently only populated for SetStat.
Attrs *SFTPEventAttributes `json:",omitempty"`
}
type SFTPEventAttributes struct {
// Atime is the file access time in seconds since the Unix epoch.
Atime *uint32 `json:",omitempty"`
// Mtime is the file modification time in seconds since the Unix epoch.
Mtime *uint32 `json:",omitempty"`
// Perms is the file permissions.
Perms *uint32 `json:",omitempty"`
// Size is the file size.
Size *uint64 `json:",omitempty"`
// UID is the numerical owner of the file.
UID *uint32 `json:",omitempty"`
// GID is the numerical group of the file.
GID *uint32 `json:",omitempty"`
}
// SFTPSummaryEvent is generated before the SFTP server process exits.
type SFTPSummaryEvent struct {
// Time is the event timestamp in nanos since the Unix epoch.
Time int64
// Stats is stats for files that this SFTP session has interacted with.
Stats []SFTPSummaryEventFileTransferStat `json:",omitempty"`
}
type SFTPSummaryEventFileTransferStat struct {
// Path is the path of the file.
Path string
// Read is the count of bytes read.
Read uint64
// Written is the count of bytes written.
Written uint64
}
// ParseSFTPEvent parses an SFTP request and associated error into an SFTP audit
// event. Changes to this function should be reflected in
// [sshutils/sftp.SFTPEventToProto].
func ParseSFTPEvent(req *sftp.Request, workingDirectory string, reqErr error) (*SFTPEvent, error) {
event := &SFTPEvent{
Time: time.Now().UnixNano(),
}
switch req.Method {
case MethodOpen, MethodGet, MethodPut:
case MethodSetStat:
case MethodList:
case MethodRemove:
case MethodMkdir:
case MethodRmdir:
case MethodRename:
case MethodSymlink:
case MethodLink:
default:
return nil, trace.BadParameter("unknown SFTP request %+q", req.Method)
}
event.Method = req.Method
event.Path = req.Filepath
event.Target = req.Target
event.Flags = req.Flags
event.WorkDir = workingDirectory
if req.Method == MethodSetStat {
attrFlags := req.AttrFlags()
attrs := *req.Attributes()
event.Attrs = new(SFTPEventAttributes)
if attrFlags.Acmodtime {
event.Attrs.Atime = &attrs.Atime
event.Attrs.Mtime = &attrs.Mtime
}
if attrFlags.Permissions {
perms := uint32(attrs.FileMode().Perm())
event.Attrs.Perms = &perms
}
if attrFlags.Size {
event.Attrs.Size = &attrs.Size
}
if attrFlags.UidGid {
event.Attrs.UID = &attrs.UID
event.Attrs.GID = &attrs.GID
}
}
if reqErr != nil {
// If possible, strip the filename from the error message. The
// path will be included in audit events already, no need to
// make the error message longer than it needs to be.
var pathErr *fs.PathError
var linkErr *os.LinkError
if errors.As(reqErr, &pathErr) {
event.Error = pathErr.Err.Error()
} else if errors.As(reqErr, &linkErr) {
event.Error = linkErr.Err.Error()
} else {
event.Error = reqErr.Error()
}
if event.Error == "" {
// we signal the failure of a request by the presence of an error
// string, so it must be nonempty here
event.Error = "SFTP request failed with no error message"
}
}
return event, nil
}
+8 -2
View File
@@ -738,8 +738,6 @@ Examples:
}
case scpc.FullCommand():
err = onSCP(&scpFlags)
case sftp.FullCommand():
err = onSFTP()
case status.FullCommand():
err = onStatus()
case dump.FullCommand():
@@ -747,6 +745,11 @@ Examples:
case dumpNodeConfigure.FullCommand():
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():
@@ -755,6 +758,9 @@ Examples:
reexec.RunAndExit(reexecconstants.CheckHomeDirSubCommand)
case park.FullCommand():
reexec.RunAndExit(reexecconstants.ParkSubCommand)
case sftp.FullCommand():
reexec.RunAndExit(reexecconstants.SFTPSubCommand)
case waitNoResolveCmd.FullCommand():
err = onWaitNoResolve(waitFlags)
case waitDurationCmd.FullCommand():
+3
View File
@@ -22,6 +22,7 @@ import (
"os"
"github.com/gravitational/teleport/lib/observability/metrics"
"github.com/gravitational/teleport/session/reexec"
"github.com/gravitational/teleport/tool/teleport/common"
)
@@ -30,6 +31,8 @@ func init() {
}
func main() {
reexec.MaybeReexec()
common.Run(common.Options{
Args: os.Args[1:],
})
-10
View File
@@ -27,7 +27,6 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"time"
@@ -53,8 +52,6 @@ import (
"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"
)
const (
@@ -66,13 +63,6 @@ const (
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 reexec.IsReexec() {
common.Run(common.Options{Args: os.Args[1:]})
return
}
modules.SetModules(&cliModules{})
}
+1 -5
View File
@@ -125,6 +125,7 @@ var ports utils.PortList
const initTestSentinel = "init_test"
func TestMain(m *testing.M) {
reexec.MaybeReexec()
handleReexec()
var err error
@@ -238,11 +239,6 @@ func handleReexec() {
}
os.Exit(0)
}
// Re-exec teleport commands. Used to test tsh ssh command.
if reexec.IsReexec() {
reexec.RunAndExit(os.Args[1])
}
}
type cliModules struct{}