Switch to gorm for activity logging
This commit is contained in:
parent
61baccb1a3
commit
8a867ccc44
|
@ -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
19
go.mod
|
@ -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
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
41
internal/database/database.go
Normal file
41
internal/database/database.go
Normal 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
|
||||||
|
}
|
64
internal/models/activity.go
Normal file
64
internal/models/activity.go
Normal 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
31
internal/models/models.go
Normal 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
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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, ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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, "")
|
||||||
|
}
|
||||||
|
|
4
wings.go
4
wings.go
|
@ -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.
|
||||||
|
|
Loading…
Reference in New Issue
Block a user