Better tracking of SFTP events
This commit is contained in:
82
sftp/event.go
Normal file
82
sftp/event.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user