Switch to gorm for activity logging

This commit is contained in:
DaneEveritt 2022-07-24 11:43:48 -04:00
parent 61baccb1a3
commit 8a867ccc44
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
17 changed files with 514 additions and 421 deletions

View File

@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/pterodactyl/wings/internal/cron" "github.com/pterodactyl/wings/internal/cron"
"github.com/pterodactyl/wings/internal/sqlite" "github.com/pterodactyl/wings/internal/database"
log2 "log" log2 "log"
"net/http" "net/http"
_ "net/http/pprof" _ "net/http/pprof"
@ -132,7 +132,7 @@ func rootCmdRun(cmd *cobra.Command, _ []string) {
}), }),
) )
if err := sqlite.Initialize(cmd.Context()); err != nil { if err := database.Initialize(); err != nil {
log.WithField("error", err).Fatal("failed to initialize database") log.WithField("error", err).Fatal("failed to initialize database")
} }

19
go.mod
View File

@ -45,13 +45,14 @@ require (
) )
require ( require (
github.com/glebarez/sqlite v1.4.6
github.com/go-co-op/gocron v1.15.0 github.com/go-co-op/gocron v1.15.0
github.com/goccy/go-json v0.9.6 github.com/goccy/go-json v0.9.6
github.com/klauspost/compress v1.15.1 github.com/klauspost/compress v1.15.1
modernc.org/sqlite v1.17.3 gorm.io/gorm v1.23.8
) )
require golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e // indirect require golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
require ( require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
@ -70,6 +71,7 @@ require (
github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/gammazero/deque v0.1.1 // indirect github.com/gammazero/deque v0.1.1 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.17.3 // indirect
github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.10.1 // indirect github.com/go-playground/validator/v10 v10.10.1 // indirect
@ -78,6 +80,8 @@ require (
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/mux v1.7.4 // indirect github.com/gorilla/mux v1.7.4 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kr/fs v0.1.0 // indirect github.com/kr/fs v0.1.0 // indirect
@ -109,24 +113,17 @@ require (
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect go.uber.org/multierr v1.8.0 // indirect
golang.org/x/mod v0.4.2 // indirect
golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.3.7 // indirect golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect
golang.org/x/tools v0.1.1 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb // indirect google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb // indirect
google.golang.org/grpc v1.45.0 // indirect google.golang.org/grpc v1.45.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.1.1 // indirect modernc.org/libc v1.16.17 // indirect
modernc.org/cc/v3 v3.36.0 // indirect
modernc.org/ccgo/v3 v3.16.6 // indirect
modernc.org/libc v1.16.7 // indirect
modernc.org/mathutil v1.4.1 // indirect modernc.org/mathutil v1.4.1 // indirect
modernc.org/memory v1.1.1 // indirect modernc.org/memory v1.1.1 // indirect
modernc.org/opt v0.1.1 // indirect modernc.org/sqlite v1.17.3 // indirect
modernc.org/strutil v1.1.1 // indirect
modernc.org/token v1.0.0 // indirect
) )

316
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,12 @@
package cron package cron
import ( import (
"bytes"
"context" "context"
"database/sql"
"emperror.dev/errors" "emperror.dev/errors"
"encoding/gob" "github.com/pterodactyl/wings/internal/database"
"github.com/pterodactyl/wings/internal/sqlite" "github.com/pterodactyl/wings/internal/models"
"github.com/pterodactyl/wings/server" "github.com/pterodactyl/wings/server"
"github.com/pterodactyl/wings/system" "github.com/pterodactyl/wings/system"
"strings"
) )
type activityCron struct { type activityCron struct {
@ -18,32 +15,6 @@ type activityCron struct {
max int64 max int64
} }
const queryRegularActivity = `
SELECT id, event, user_uuid, server_uuid, metadata, ip, timestamp FROM activity_logs
WHERE event NOT LIKE 'server:sftp.%'
ORDER BY timestamp
LIMIT ?
`
type QueriedActivity struct {
id int
b []byte
server.Activity
}
// Parse parses the internal query results into the QueriedActivity type and then properly
// sets the Metadata onto it. This also sets the ID that was returned to ensure we're able
// to then delete all of the matching rows in the database after we're done.
func (qa *QueriedActivity) Parse(r *sql.Rows) error {
if err := r.Scan(&qa.id, &qa.Event, &qa.User, &qa.Server, &qa.b, &qa.IP, &qa.Timestamp); err != nil {
return errors.Wrap(err, "cron: failed to parse activity log")
}
if err := gob.NewDecoder(bytes.NewBuffer(qa.b)).Decode(&qa.Metadata); err != nil {
return errors.WithStack(err)
}
return nil
}
// Run executes the cronjob and ensures we fetch and send all of the stored activity to the // Run executes the cronjob and ensures we fetch and send all of the stored activity to the
// Panel instance. Once activity is sent it is deleted from the local database instance. Any // Panel instance. Once activity is sent it is deleted from the local database instance. Any
// SFTP specific events are not handled in this cron, they're handled seperately to account // SFTP specific events are not handled in this cron, they're handled seperately to account
@ -56,47 +27,31 @@ func (ac *activityCron) Run(ctx context.Context) error {
} }
defer ac.mu.Store(false) defer ac.mu.Store(false)
rows, err := sqlite.Instance().QueryContext(ctx, queryRegularActivity, ac.max) var activity []models.Activity
if err != nil { tx := database.Instance().WithContext(ctx).
return errors.Wrap(err, "cron: failed to query activity logs") Where("event NOT LIKE ?", "server:sftp.%").
} Limit(int(ac.max)).
defer rows.Close() Find(&activity)
var logs []server.Activity if tx.Error != nil {
var ids []int return errors.WithStack(tx.Error)
for rows.Next() {
var qa QueriedActivity
if err := qa.Parse(rows); err != nil {
return err
}
ids = append(ids, qa.id)
logs = append(logs, qa.Activity)
} }
if len(activity) == 0 {
if err := rows.Err(); err != nil {
return errors.WithStack(err)
}
if len(logs) == 0 {
return nil return nil
} }
if err := ac.manager.Client().SendActivityLogs(ctx, logs); err != nil {
if err := ac.manager.Client().SendActivityLogs(ctx, activity); err != nil {
return errors.WrapIf(err, "cron: failed to send activity events to Panel") return errors.WrapIf(err, "cron: failed to send activity events to Panel")
} }
if tx, err := sqlite.Instance().Begin(); err != nil { var ids []int
return err for _, v := range activity {
} else { ids = append(ids, v.ID)
t := make([]string, len(ids))
params := make([]interface{}, len(ids))
for i := 0; i < len(ids); i++ {
t[i] = "?"
params[i] = ids[i]
}
q := strings.Join(t, ",")
_, err := tx.Exec(`DELETE FROM activity_logs WHERE id IN(`+q+`)`, params...)
if err != nil {
return errors.Combine(errors.WithStack(err), tx.Rollback())
}
return errors.WithStack(tx.Commit())
} }
tx = database.Instance().WithContext(ctx).Where("id IN ?", ids).Delete(&models.Activity{})
if tx.Error != nil {
return errors.WithStack(tx.Error)
}
return nil
} }

View File

@ -33,14 +33,8 @@ func Scheduler(ctx context.Context, m *server.Manager) (*gocron.Scheduler, error
max: config.Get().System.ActivitySendCount, max: config.Get().System.ActivitySendCount,
} }
sftp := sftpActivityCron{
mu: system.NewAtomicBool(false),
manager: m,
max: config.Get().System.ActivitySendCount,
}
s := gocron.NewScheduler(l) s := gocron.NewScheduler(l)
_, _ = s.Tag("activity").Every(config.Get().System.ActivitySendInterval).Seconds().Do(func() { _, _ = s.Tag("activity").Every(5).Seconds().Do(func() {
if err := activity.Run(ctx); err != nil { if err := activity.Run(ctx); err != nil {
if errors.Is(err, ErrCronRunning) { if errors.Is(err, ErrCronRunning) {
log.WithField("cron", "activity").Warn("cron: process is already running, skipping...") log.WithField("cron", "activity").Warn("cron: process is already running, skipping...")
@ -50,15 +44,5 @@ func Scheduler(ctx context.Context, m *server.Manager) (*gocron.Scheduler, error
} }
}) })
_, _ = s.Tag("sftp_activity").Every(5).Seconds().Do(func() {
if err := sftp.Run(ctx); err != nil {
if errors.Is(err, ErrCronRunning) {
log.WithField("cron", "sftp").Warn("cron: process is already running, skipping...")
} else {
log.WithField("error", err).Error("cron: failed to process sftp events")
}
}
})
return s, nil return s, nil
} }

View File

@ -1,145 +0,0 @@
package cron
import (
"bytes"
"context"
"emperror.dev/errors"
"encoding/gob"
"github.com/pterodactyl/wings/internal/sqlite"
"github.com/pterodactyl/wings/server"
"github.com/pterodactyl/wings/system"
"time"
)
const querySftpActivity = `
SELECT
event,
user_uuid,
server_uuid,
ip,
GROUP_CONCAT(metadata, '::') AS metadata,
MIN(timestamp) AS first_timestamp
FROM activity_logs
WHERE event LIKE 'server:sftp.%'
GROUP BY event, STRFTIME('%Y-%m-%d %H:%M:00', DATETIME(timestamp, 'unixepoch', 'utc')), user_uuid, server_uuid, ip
LIMIT ?
`
type sftpActivityGroup struct {
Event server.Event
User string
Server string
IP string
Metadata []byte
Timestamp int64
}
// Activity takes the struct and converts it into a single activity entity to
// process and send back over to the Panel.
func (g *sftpActivityGroup) Activity() (server.Activity, error) {
m, err := g.processMetadata()
if err != nil {
return server.Activity{}, err
}
t := time.Unix(g.Timestamp, 0)
a := server.Activity{
User: g.User,
Server: g.Server,
Event: g.Event,
Metadata: m,
IP: g.IP,
Timestamp: t.UTC(),
}
return a, nil
}
// processMetadata takes all of the concatenated metadata returned by the SQL
// query and then processes it all into individual entity records before then
// merging them into a final, single metadata, object.
func (g *sftpActivityGroup) processMetadata() (server.ActivityMeta, error) {
b := bytes.Split(g.Metadata, []byte("::"))
if len(b) == 0 {
return server.ActivityMeta{}, nil
}
entities := make([]server.ActivityMeta, len(b))
for i, v := range b {
if len(v) == 0 {
continue
}
if err := gob.NewDecoder(bytes.NewBuffer(v)).Decode(&entities[i]); err != nil {
return nil, errors.Wrap(err, "could not decode metadata bytes")
}
}
var files []interface{}
// Iterate over every entity that we've gotten back from the database's metadata fields
// and merge them all into a single entity by checking what the data type returned is and
// going from there.
//
// We only store a slice of strings, or a string/string map value in the database for SFTP
// actions, hence the case statement.
for _, e := range entities {
if e == nil {
continue
}
if f, ok := e["files"]; ok {
var a []interface{}
switch f.(type) {
case []string:
for _, v := range f.([]string) {
a = append(a, v)
}
case map[string]string:
a = append(a, f)
}
files = append(files, a)
}
}
return server.ActivityMeta{"files": files}, nil
}
type sftpActivityCron struct {
mu *system.AtomicBool
manager *server.Manager
max int64
}
// Run executes the cronjob and finds all associated SFTP events, bundles them up so
// that multiple events in the same timespan are recorded as a single event, and then
// cleans up the database.
func (sac *sftpActivityCron) Run(ctx context.Context) error {
if !sac.mu.SwapIf(true) {
return errors.WithStack(ErrCronRunning)
}
defer sac.mu.Store(false)
rows, err := sqlite.Instance().QueryContext(ctx, querySftpActivity, sac.max)
if err != nil {
return errors.Wrap(err, "cron: failed to query sftp activity")
}
defer rows.Close()
if err := rows.Err(); err != nil {
return errors.WithStack(err)
}
var out []server.Activity
for rows.Next() {
v := sftpActivityGroup{}
if err := rows.Scan(&v.Event, &v.User, &v.Server, &v.IP, &v.Metadata, &v.Timestamp); err != nil {
return errors.Wrap(err, "failed to scan row")
}
if a, err := v.Activity(); err != nil {
return errors.Wrap(err, "could not parse data into activity type")
} else {
out = append(out, a)
}
}
if len(out) == 0 {
return nil
}
if err := sac.manager.Client().SendActivityLogs(ctx, out); err != nil {
return errors.Wrap(err, "could not send activity logs to Panel")
}
return nil
}

View File

@ -0,0 +1,41 @@
package database
import (
"emperror.dev/errors"
"github.com/glebarez/sqlite"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/internal/models"
"github.com/pterodactyl/wings/system"
"gorm.io/gorm"
"path/filepath"
)
var o system.AtomicBool
var db *gorm.DB
// Initialize configures the local SQLite database for Wings and ensures that the models have
// been fully migrated.
func Initialize() error {
if !o.SwapIf(true) {
panic("database: attempt to initialize more than once during application lifecycle")
}
p := filepath.Join(config.Get().System.RootDirectory, "wings.db")
instance, err := gorm.Open(sqlite.Open(p), &gorm.Config{})
if err != nil {
return errors.Wrap(err, "database: could not open database file")
}
db = instance
if err := db.AutoMigrate(&models.Activity{}); err != nil {
return errors.WithStack(err)
}
return nil
}
// Instance returns the gorm database instance that was configured when the application was
// booted.
func Instance() *gorm.DB {
if db == nil {
panic("database: attempt to access instance before initialized")
}
return db
}

View File

@ -0,0 +1,64 @@
package models
import (
"github.com/pterodactyl/wings/system"
"gorm.io/gorm"
"time"
)
type Event string
type ActivityMeta map[string]interface{}
// Activity defines an activity log event for a server entity performed by a user. This is
// used for tracking commands, power actions, and SFTP events so that they can be reconciled
// and sent back to the Panel instance to be displayed to the user.
type Activity struct {
ID int `gorm:"primaryKey;not null" json:"-"`
// User is UUID of the user that triggered this event, or an empty string if the event
// cannot be tied to a specific user, in which case we will assume it was the system
// user.
User JsonNullString `gorm:"type:uuid" json:"user"`
// Server is the UUID of the server this event is associated with.
Server string `gorm:"type:uuid;not null" json:"server"`
// Event is a string that describes what occurred, and is used by the Panel instance to
// properly associate this event in the activity logs.
Event Event `gorm:"index;not null" json:"event"`
// Metadata is either a null value, string, or a JSON blob with additional event specific
// metadata that can be provided.
Metadata ActivityMeta `gorm:"serializer:json" json:"metadata"`
// IP is the IP address that triggered this event, or an empty string if it cannot be
// determined properly. This should be the connecting user's IP address, and not the
// internal system IP.
IP string `gorm:"not null" json:"ip"`
Timestamp time.Time `gorm:"not null" json:"timestamp"`
}
// SetUser sets the current user that performed the action. If an empty string is provided
// it is cast into a null value when stored.
func (a Activity) SetUser(u string) *Activity {
var ns JsonNullString
if u == "" {
if err := ns.Scan(nil); err != nil {
panic(err)
}
} else {
if err := ns.Scan(u); err != nil {
panic(err)
}
}
a.User = ns
return &a
}
// BeforeCreate executes before we create any activity entry to ensure the IP address
// is trimmed down to remove any extraneous data, and the timestamp is set to the current
// system time and then stored as UTC.
func (a *Activity) BeforeCreate(_ *gorm.DB) error {
a.IP = system.TrimIPSuffix(a.IP)
if a.Timestamp.IsZero() {
a.Timestamp = time.Now()
}
a.Timestamp = a.Timestamp.UTC()
return nil
}

31
internal/models/models.go Normal file
View File

@ -0,0 +1,31 @@
package models
import (
"database/sql"
"emperror.dev/errors"
"github.com/goccy/go-json"
)
type JsonNullString struct {
sql.NullString
}
func (v JsonNullString) MarshalJSON() ([]byte, error) {
if v.Valid {
return json.Marshal(v.String)
} else {
return json.Marshal(nil)
}
}
func (v *JsonNullString) UnmarshalJSON(data []byte) error {
var s *string
if err := json.Unmarshal(data, &s); err != nil {
return errors.WithStack(err)
}
if s != nil {
v.String = *s
}
v.Valid = s != nil
return nil
}

View File

@ -1,56 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"emperror.dev/errors"
"github.com/apex/log"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/system"
_ "modernc.org/sqlite"
"path/filepath"
)
var o system.AtomicBool
var db *sql.DB
const schema = `
CREATE TABLE IF NOT EXISTS "activity_logs" (
"id" integer,
"event" varchar NOT NULL,
"user_uuid" varchar,
"server_uuid" varchar NOT NULL,
"metadata" blob,
"ip" varchar,
"timestamp" integer NOT NULL,
PRIMARY KEY (id)
);
-- Add an index otherwise we're gonna end up with performance issues over time especially
-- on huge Wings instances where we'll have a large number of activity logs to parse through.
CREATE INDEX IF NOT EXISTS idx_event ON activity_logs(event);
`
func Initialize(ctx context.Context) error {
if !o.SwapIf(true) {
panic("database: attempt to initialize more than once during application lifecycle")
}
p := filepath.Join(config.Get().System.RootDirectory, "wings.db")
log.WithField("subsystem", "sqlite").WithField("path", p).Info("initializing local database")
database, err := sql.Open("sqlite", p)
if err != nil {
return errors.Wrap(err, "database: could not open database file")
}
db = database
if _, err := db.ExecContext(ctx, schema); err != nil {
return errors.Wrap(err, "database: failed to initialize base schema")
}
return nil
}
func Instance() *sql.DB {
if db == nil {
panic("database: attempt to access instance before initialized")
}
return db
}

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"github.com/pterodactyl/wings/internal/models"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@ -30,7 +31,7 @@ type Client interface {
SetInstallationStatus(ctx context.Context, uuid string, successful bool) error SetInstallationStatus(ctx context.Context, uuid string, successful bool) error
SetTransferStatus(ctx context.Context, uuid string, successful bool) error SetTransferStatus(ctx context.Context, uuid string, successful bool) error
ValidateSftpCredentials(ctx context.Context, request SftpAuthRequest) (SftpAuthResponse, error) ValidateSftpCredentials(ctx context.Context, request SftpAuthRequest) (SftpAuthResponse, error)
SendActivityLogs(ctx context.Context, activity interface{}) error SendActivityLogs(ctx context.Context, activity []models.Activity) error
} }
type client struct { type client struct {

View File

@ -3,6 +3,7 @@ package remote
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/pterodactyl/wings/internal/models"
"strconv" "strconv"
"sync" "sync"
@ -179,7 +180,7 @@ func (c *client) SendRestorationStatus(ctx context.Context, backup string, succe
} }
// SendActivityLogs sends activity logs back to the Panel for processing. // SendActivityLogs sends activity logs back to the Panel for processing.
func (c *client) SendActivityLogs(ctx context.Context, activity interface{}) error { func (c *client) SendActivityLogs(ctx context.Context, activity []models.Activity) error {
resp, err := c.Post(ctx, "/activity", d{"data": activity}) resp, err := c.Post(ctx, "/activity", d{"data": activity})
if err != nil { if err != nil {
return errors.WithStackIf(err) return errors.WithStackIf(err)

View File

@ -3,6 +3,7 @@ package websocket
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/pterodactyl/wings/internal/models"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@ -369,7 +370,7 @@ func (h *Handler) HandleInbound(ctx context.Context, m Message) error {
} }
if err == nil { if err == nil {
_ = h.ra.Save(h.server, server.Event(server.ActivityPowerPrefix+action), nil) _ = h.ra.Save(h.server, models.Event(server.ActivityPowerPrefix+action), nil)
} }
return err return err
@ -428,7 +429,7 @@ func (h *Handler) HandleInbound(ctx context.Context, m Message) error {
} }
} }
_ = h.ra.Save(h.server, server.ActivityConsoleCommand, server.ActivityMeta{ _ = h.ra.Save(h.server, server.ActivityConsoleCommand, models.ActivityMeta{
"command": strings.Join(m.Args, ""), "command": strings.Join(m.Args, ""),
}) })

View File

@ -1,50 +1,22 @@
package server package server
import ( import (
"bytes"
"emperror.dev/errors" "emperror.dev/errors"
"encoding/gob" "github.com/pterodactyl/wings/internal/database"
"github.com/apex/log" "github.com/pterodactyl/wings/internal/models"
"github.com/pterodactyl/wings/internal/sqlite"
"regexp"
"time"
) )
type Event string
type ActivityMeta map[string]interface{}
const ActivityPowerPrefix = "server:power." const ActivityPowerPrefix = "server:power."
const ( const (
ActivityConsoleCommand = Event("server:console.command") ActivityConsoleCommand = models.Event("server:console.command")
ActivitySftpWrite = Event("server:sftp.write") ActivitySftpWrite = models.Event("server:sftp.write")
ActivitySftpCreate = Event("server:sftp.create") ActivitySftpCreate = models.Event("server:sftp.create")
ActivitySftpCreateDirectory = Event("server:sftp.create-directory") ActivitySftpCreateDirectory = models.Event("server:sftp.create-directory")
ActivitySftpRename = Event("server:sftp.rename") ActivitySftpRename = models.Event("server:sftp.rename")
ActivitySftpDelete = Event("server:sftp.delete") ActivitySftpDelete = models.Event("server:sftp.delete")
) )
var ipTrimRegex = regexp.MustCompile(`(:\d*)?$`)
type Activity struct {
// User is UUID of the user that triggered this event, or an empty string if the event
// cannot be tied to a specific user, in which case we will assume it was the system
// user.
User string `json:"user"`
// Server is the UUID of the server this event is associated with.
Server string `json:"server"`
// Event is a string that describes what occurred, and is used by the Panel instance to
// properly associate this event in the activity logs.
Event Event `json:"event"`
// Metadata is either a null value, string, or a JSON blob with additional event specific
// metadata that can be provided.
Metadata ActivityMeta `json:"metadata"`
// IP is the IP address that triggered this event, or an empty string if it cannot be
// determined properly.
IP string `json:"ip"`
Timestamp time.Time `json:"timestamp"`
}
// RequestActivity is a wrapper around a LoggedEvent that is able to track additional request // RequestActivity is a wrapper around a LoggedEvent that is able to track additional request
// specific metadata including the specific user and IP address associated with all subsequent // specific metadata including the specific user and IP address associated with all subsequent
// events. The internal logged event structure can be extracted by calling RequestEvent.Event(). // events. The internal logged event structure can be extracted by calling RequestEvent.Event().
@ -56,23 +28,22 @@ type RequestActivity struct {
// Event returns the underlying logged event from the RequestEvent instance and sets the // Event returns the underlying logged event from the RequestEvent instance and sets the
// specific event and metadata on it. // specific event and metadata on it.
func (ra RequestActivity) Event(event Event, metadata ActivityMeta) Activity { func (ra RequestActivity) Event(event models.Event, metadata models.ActivityMeta) *models.Activity {
return Activity{ a := models.Activity{Server: ra.server, IP: ra.ip, Event: event, Metadata: metadata}
User: ra.user,
Server: ra.server, return a.SetUser(ra.user)
IP: ra.ip,
Event: event,
Metadata: metadata,
}
} }
// Save creates a new event instance and saves it. If an error is encountered it is automatically // Save creates a new event instance and saves it. If an error is encountered it is automatically
// logged to the provided server's error logging output. The error is also returned to the caller // logged to the provided server's error logging output. The error is also returned to the caller
// but can be ignored. // but can be ignored.
func (ra RequestActivity) Save(s *Server, event Event, metadata ActivityMeta) error { func (ra RequestActivity) Save(s *Server, event models.Event, metadata models.ActivityMeta) error {
if err := ra.Event(event, metadata).Save(); err != nil { if tx := database.Instance().Create(ra.Event(event, metadata)); tx.Error != nil {
err := errors.WithStackIf(tx.Error)
s.Log().WithField("error", err).WithField("event", event).Error("activity: failed to save event") s.Log().WithField("error", err).WithField("event", event).Error("activity: failed to save event")
return errors.WithStack(err)
return err
} }
return nil return nil
} }
@ -94,46 +65,6 @@ func (ra RequestActivity) SetUser(u string) RequestActivity {
return c return c
} }
// Save logs the provided event using Wings' internal K/V store so that we can then
// pass it along to the Panel at set intervals. In addition, this will ensure that the events
// are persisted to the disk, even between instance restarts.
func (a Activity) Save() error {
if a.Timestamp.IsZero() {
a.Timestamp = time.Now().UTC()
}
// Since the "RemoteAddr" field can often include a port on the end we need to
// trim that off, otherwise it'll fail validation when sent to the Panel.
a.IP = ipTrimRegex.ReplaceAllString(a.IP, "")
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(&a.Metadata); err != nil {
return errors.Wrap(err, "activity: error encoding metadata")
}
log.WithField("subsystem", "activity").
WithFields(log.Fields{"server": a.Server, "user": a.User, "event": a.Event, "ip": a.IP}).
Debug("saving activity to database")
stmt := `INSERT INTO activity_logs(event, user_uuid, server_uuid, metadata, ip, timestamp) VALUES(?, ?, ?, ?, ?, ?)`
if _, err := sqlite.Instance().Exec(stmt, a.Event, a.User, a.Server, buf.Bytes(), a.IP, a.Timestamp.UTC().Unix()); err != nil {
return errors.WithStack(err)
}
return nil
}
func (s *Server) NewRequestActivity(user string, ip string) RequestActivity { func (s *Server) NewRequestActivity(user string, ip string) RequestActivity {
return RequestActivity{server: s.ID(), user: user, ip: ip} return RequestActivity{server: s.ID(), user: user, ip: ip}
} }
// NewActivity creates a new event instance for the server in question.
func (s *Server) NewActivity(user string, event Event, metadata ActivityMeta, ip string) Activity {
return Activity{
User: user,
Server: s.ID(),
Event: event,
Metadata: metadata,
IP: ip,
}
}

View File

@ -3,8 +3,8 @@ package sftp
import ( import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/apex/log" "github.com/apex/log"
"github.com/pterodactyl/wings/server" "github.com/pterodactyl/wings/internal/database"
"time" "github.com/pterodactyl/wings/internal/models"
) )
type eventHandler struct { type eventHandler struct {
@ -26,7 +26,7 @@ type FileAction struct {
// Log parses a SFTP specific file activity event and then passes it off to be stored // Log parses a SFTP specific file activity event and then passes it off to be stored
// in the normal activity database. // in the normal activity database.
func (eh *eventHandler) Log(e server.Event, fa FileAction) error { func (eh *eventHandler) Log(e models.Event, fa FileAction) error {
metadata := map[string]interface{}{ metadata := map[string]interface{}{
"files": []string{fa.Entity}, "files": []string{fa.Entity},
} }
@ -36,21 +36,22 @@ func (eh *eventHandler) Log(e server.Event, fa FileAction) error {
} }
} }
r := server.Activity{ a := models.Activity{
User: eh.user, Server: eh.server,
Server: eh.server, Event: e,
Event: e, Metadata: metadata,
Metadata: metadata, IP: eh.ip,
IP: eh.ip,
Timestamp: time.Now().UTC(),
} }
return errors.Wrap(r.Save(), "sftp: failed to store file event") if tx := database.Instance().Create(a.SetUser(eh.user)); tx.Error != nil {
return errors.Wrap(tx.Error, "sftp: failed to save event to database")
}
return nil
} }
// MustLog is a wrapper around log that will trigger a fatal error and exit the application // 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. // if an error is encountered during the logging of the event.
func (eh *eventHandler) MustLog(e server.Event, fa FileAction) { func (eh *eventHandler) MustLog(e models.Event, fa FileAction) {
if err := eh.Log(e, fa); err != nil { if err := eh.Log(e, fa); err != nil {
log.WithField("error", err).Fatal("sftp: failed to log event") log.WithField("error", err).Fatal("sftp: failed to log event")
} }

View File

@ -2,9 +2,12 @@ package system
import ( import (
"math/rand" "math/rand"
"regexp"
"strings" "strings"
) )
var ipTrimRegex = regexp.MustCompile(`(:\d*)?$`)
const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
// RandomString generates a random string of alpha-numeric characters using a // RandomString generates a random string of alpha-numeric characters using a
@ -18,3 +21,9 @@ func RandomString(n int) string {
} }
return b.String() return b.String()
} }
// TrimIPSuffix removes the internal port value from an IP address to ensure we're only
// ever working directly with the IP address.
func TrimIPSuffix(s string) string {
return ipTrimRegex.ReplaceAllString(s, "")
}

View File

@ -1,16 +1,12 @@
package main package main
import ( import (
"encoding/gob"
"github.com/pterodactyl/wings/cmd" "github.com/pterodactyl/wings/cmd"
"github.com/pterodactyl/wings/server"
"math/rand" "math/rand"
"time" "time"
) )
func main() { func main() {
gob.Register(server.ActivityMeta{})
// Since we make use of the math/rand package in the code, especially for generating // Since we make use of the math/rand package in the code, especially for generating
// non-cryptographically secure random strings we need to seed the RNG. Just make use // non-cryptographically secure random strings we need to seed the RNG. Just make use
// of the current time for this. // of the current time for this.