Better tracking of SFTP events

This commit is contained in:
DaneEveritt
2022-07-10 14:30:32 -04:00
parent 59fbd2bcea
commit f28e06267c
9 changed files with 331 additions and 60 deletions

82
sftp/event.go Normal file
View File

@@ -0,0 +1,82 @@
package sftp
import (
"bytes"
"emperror.dev/errors"
"encoding/gob"
"github.com/apex/log"
"github.com/pterodactyl/wings/internal/database"
"github.com/xujiajun/nutsdb"
"regexp"
"time"
)
type eventHandler struct {
ip string
user string
server string
}
type Event string
type FileAction struct {
// Entity is the targeted file or directory (depending on the event) that the action
// is being performed _against_, such as "/foo/test.txt". This will always be the full
// path to the element.
Entity string
// Target is an optional (often blank) field that only has a value in it when the event
// is specifically modifying the entity, such as a rename or move event. In that case
// the Target field will be the final value, such as "/bar/new.txt"
Target string
}
type EventRecord struct {
Event Event
Action FileAction
IP string
User string
Timestamp time.Time
}
const (
EventWrite = Event("write")
EventCreate = Event("create")
EventCreateDirectory = Event("create-directory")
EventRename = Event("rename")
EventDelete = Event("delete")
)
var ipTrimRegex = regexp.MustCompile(`(:\d*)?$`)
// Log logs an event into the Wings bucket for SFTP activity which then allows a seperate
// cron to run and parse the events into a more manageable stream of event data to send
// back to the Panel instance.
func (eh *eventHandler) Log(e Event, fa FileAction) error {
r := EventRecord{
Event: e,
Action: fa,
IP: ipTrimRegex.ReplaceAllString(eh.ip, ""),
User: eh.user,
Timestamp: time.Now().UTC(),
}
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(r); err != nil {
return errors.Wrap(err, "sftp: failed to encode event")
}
return database.DB().Update(func(tx *nutsdb.Tx) error {
if err := tx.RPush(database.SftpActivityBucket, []byte(eh.server), buf.Bytes()); err != nil {
return errors.Wrap(err, "sftp: failed to push event to stack")
}
return nil
})
}
// MustLog is a wrapper around log that will trigger a fatal error and exit the application
// if an error is encountered during the logging of the event.
func (eh *eventHandler) MustLog(e Event, fa FileAction) {
if err := eh.Log(e, fa); err != nil {
log.WithField("error", err).Fatal("sftp: failed to log event")
}
}

View File

@@ -26,16 +26,12 @@ const (
PermissionFileDelete = "file.delete"
)
type handlerMeta struct {
ra server.RequestActivity
server *server.Server
}
type Handler struct {
mu sync.Mutex
meta handlerMeta
permissions []string
server *server.Server
fs *filesystem.Filesystem
events *eventHandler
permissions []string
logger *log.Entry
ro bool
}
@@ -48,19 +44,23 @@ func NewHandler(sc *ssh.ServerConn, srv *server.Server) (*Handler, error) {
return nil, errors.New("sftp: mismatched Wings and Panel versions — Panel 1.10 is required for this version of Wings.")
}
events := eventHandler{
ip: sc.RemoteAddr().String(),
user: uuid,
server: srv.ID(),
}
return &Handler{
permissions: strings.Split(sc.Permissions.Extensions["permissions"], ","),
meta: handlerMeta{
server: srv,
ra: srv.NewRequestActivity(uuid, sc.RemoteAddr().String()),
},
fs: srv.Filesystem(),
ro: config.Get().System.Sftp.ReadOnly,
logger: log.WithFields(log.Fields{"subsystem": "sftp", "user": uuid, "ip": sc.RemoteAddr()}),
server: srv,
fs: srv.Filesystem(),
events: &events,
ro: config.Get().System.Sftp.ReadOnly,
logger: log.WithFields(log.Fields{"subsystem": "sftp", "user": uuid, "ip": sc.RemoteAddr()}),
}, nil
}
// Returns the sftp.Handlers for this struct.
// Handlers returns the sftp.Handlers for this struct.
func (h *Handler) Handlers() sftp.Handlers {
return sftp.Handlers{
FileGet: h,
@@ -88,7 +88,6 @@ func (h *Handler) Fileread(request *sftp.Request) (io.ReaderAt, error) {
}
return nil, sftp.ErrSSHFxNoSuchFile
}
h.event(server.ActivityFileRead, server.ActivityMeta{"file": filepath.Clean(request.Filepath)})
return f, nil
}
@@ -131,7 +130,11 @@ func (h *Handler) Filewrite(request *sftp.Request) (io.WriterAt, error) {
// Chown may or may not have been called in the touch function, so always do
// it at this point to avoid the file being improperly owned.
_ = h.fs.Chown(request.Filepath)
h.event(server.ActivityFileWrite, server.ActivityMeta{"file": filepath.Clean(request.Filepath)})
event := EventWrite
if permission == PermissionFileCreate {
event = EventCreate
}
h.events.MustLog(event, FileAction{Entity: request.Filepath})
return f, nil
}
@@ -175,21 +178,14 @@ func (h *Handler) Filecmd(request *sftp.Request) error {
if !h.can(PermissionFileUpdate) {
return sftp.ErrSSHFxPermissionDenied
}
p := filepath.Clean(request.Filepath)
t := filepath.Clean(request.Target)
if err := h.fs.Rename(p, t); err != nil {
if err := h.fs.Rename(request.Filepath, request.Target); err != nil {
if errors.Is(err, os.ErrNotExist) {
return sftp.ErrSSHFxNoSuchFile
}
l.WithField("error", err).Error("failed to rename file")
return sftp.ErrSSHFxFailure
}
h.event(server.ActivityFileRename, server.ActivityMeta{
"directory": filepath.Dir(p),
"files": []map[string]string{
{"from": filepath.Base(p), "to": t},
},
})
h.events.MustLog(EventRename, FileAction{Entity: request.Filepath, Target: request.Target})
break
// Handle deletion of a directory. This will properly delete all of the files and
// folders within that directory if it is not already empty (unlike a lot of SFTP
@@ -203,10 +199,7 @@ func (h *Handler) Filecmd(request *sftp.Request) error {
l.WithField("error", err).Error("failed to remove directory")
return sftp.ErrSSHFxFailure
}
h.event(server.ActivityFileDeleted, server.ActivityMeta{
"directory": filepath.Dir(p),
"files": []string{filepath.Base(p)},
})
h.events.MustLog(EventDelete, FileAction{Entity: request.Filepath})
return sftp.ErrSSHFxOk
// Handle requests to create a new Directory.
case "Mkdir":
@@ -219,10 +212,7 @@ func (h *Handler) Filecmd(request *sftp.Request) error {
l.WithField("error", err).Error("failed to create directory")
return sftp.ErrSSHFxFailure
}
h.event(server.ActivityFileCreateDirectory, server.ActivityMeta{
"directory": p,
"name": name[len(name)-1],
})
h.events.MustLog(EventCreateDirectory, FileAction{Entity: request.Filepath})
break
// Support creating symlinks between files. The source and target must resolve within
// the server home directory.
@@ -248,18 +238,14 @@ func (h *Handler) Filecmd(request *sftp.Request) error {
if !h.can(PermissionFileDelete) {
return sftp.ErrSSHFxPermissionDenied
}
p := filepath.Clean(request.Filepath)
if err := h.fs.Delete(p); err != nil {
if err := h.fs.Delete(request.Filepath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return sftp.ErrSSHFxNoSuchFile
}
l.WithField("error", err).Error("failed to remove a file")
return sftp.ErrSSHFxFailure
}
h.event(server.ActivityFileDeleted, server.ActivityMeta{
"directory": filepath.Dir(p),
"files": []string{filepath.Base(p)},
})
h.events.MustLog(EventDelete, FileAction{Entity: request.Filepath})
return sftp.ErrSSHFxOk
default:
return sftp.ErrSSHFxOpUnsupported
@@ -316,7 +302,7 @@ func (h *Handler) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
// Determines if a user has permission to perform a specific action on the SFTP server. These
// permissions are defined and returned by the Panel API.
func (h *Handler) can(permission string) bool {
if h.meta.server.IsSuspended() {
if h.server.IsSuspended() {
return false
}
for _, p := range h.permissions {
@@ -328,16 +314,3 @@ func (h *Handler) can(permission string) bool {
}
return false
}
// event is a wrapper around the server.RequestActivity struct for this handler to
// make logging events a little less noisy for SFTP. This also tags every event logged
// using it with a "{using_sftp: true}" metadata field to make this easier to understand
// in the Panel's activity logs.
func (h *Handler) event(event server.Event, metadata server.ActivityMeta) {
m := metadata
if m == nil {
m = make(map[string]interface{})
}
m["using_sftp"] = true
_ = h.meta.ra.Save(h.meta.server, event, m)
}