Migrate SFTP endpoints

This commit is contained in:
Dane Everitt 2021-02-01 20:59:17 -08:00
parent 6775c17324
commit 62cbe5e135
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
6 changed files with 97 additions and 160 deletions

View File

@ -1,79 +0,0 @@
package api
import (
"regexp"
"emperror.dev/errors"
"github.com/apex/log"
)
type SftpAuthRequest struct {
User string `json:"username"`
Pass string `json:"password"`
IP string `json:"ip"`
SessionID []byte `json:"session_id"`
ClientVersion []byte `json:"client_version"`
}
type SftpAuthResponse struct {
Server string `json:"server"`
Token string `json:"token"`
Permissions []string `json:"permissions"`
}
type sftpInvalidCredentialsError struct {
}
func (ice sftpInvalidCredentialsError) Error() string {
return "the credentials provided were invalid"
}
func IsInvalidCredentialsError(err error) bool {
_, ok := err.(*sftpInvalidCredentialsError)
return ok
}
// Usernames all follow the same format, so don't even bother hitting the API if the username is not
// at least in the expected format. This is very basic protection against random bots finding the SFTP
// server and sending a flood of usernames.
var validUsernameRegexp = regexp.MustCompile(`^(?i)(.+)\.([a-z0-9]{8})$`)
func (r *Request) ValidateSftpCredentials(request SftpAuthRequest) (*SftpAuthResponse, error) {
// If the username doesn't meet the expected format that the Panel would even recognize just go ahead
// and bail out of the process here to avoid accidentally brute forcing the panel if a bot decides
// to connect to spam username attempts.
if !validUsernameRegexp.MatchString(request.User) {
log.WithFields(log.Fields{
"subsystem": "sftp",
"username": request.User,
"ip": request.IP,
}).Warn("failed to validate user credentials (invalid format)")
return nil, new(sftpInvalidCredentialsError)
}
resp, err := r.Post("/sftp/auth", request)
if err != nil {
return nil, err
}
defer resp.Body.Close()
e := resp.Error()
if e != nil {
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return nil, &sftpInvalidCredentialsError{}
}
rerr := errors.New(e.Error())
return nil, rerr
}
var response SftpAuthResponse
if err := resp.Bind(&response); err != nil {
return nil, err
}
return &response, nil
}

View File

@ -18,7 +18,7 @@ type Client interface {
SetBackupStatus(ctx context.Context, backup string, data api.BackupRequest) error SetBackupStatus(ctx context.Context, backup string, data api.BackupRequest) error
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 api.SftpAuthRequest) (api.SftpAuthResponse, error) ValidateSftpCredentials(ctx context.Context, request SftpAuthRequest) (SftpAuthResponse, error)
} }
type client struct { type client struct {

View File

@ -32,15 +32,9 @@ func (re *RequestError) Error() string {
return fmt.Sprintf("Error response from Panel: %s: %s (HTTP/%d)", re.Code, re.Detail, c) return fmt.Sprintf("Error response from Panel: %s: %s (HTTP/%d)", re.Code, re.Detail, c)
} }
type sftpInvalidCredentialsError struct { type SftpInvalidCredentialsError struct {
} }
func (ice sftpInvalidCredentialsError) Error() string { func (ice SftpInvalidCredentialsError) Error() string {
return "the credentials provided were invalid" return "the credentials provided were invalid"
} }
func IsInvalidCredentialsError(err error) bool {
_, ok := err.(*sftpInvalidCredentialsError)
return ok
}

View File

@ -3,30 +3,34 @@ package remote
import ( import (
"context" "context"
"errors" "errors"
"regexp"
"github.com/apex/log" "github.com/apex/log"
"github.com/pterodactyl/wings/api"
) )
// Usernames all follow the same format, so don't even bother hitting the API if the username is not type SftpAuthRequest struct {
// at least in the expected format. This is very basic protection against random bots finding the SFTP User string `json:"username"`
// server and sending a flood of usernames. Pass string `json:"password"`
var validUsernameRegexp = regexp.MustCompile(`^(?i)(.+)\.([a-z0-9]{8})$`) IP string `json:"ip"`
SessionID []byte `json:"session_id"`
func (c *client) ValidateSftpCredentials(ctx context.Context, request api.SftpAuthRequest) (api.SftpAuthResponse, error) { ClientVersion []byte `json:"client_version"`
if !validUsernameRegexp.MatchString(request.User) {
log.WithFields(log.Fields{
"subsystem": "sftp",
"username": request.User,
"ip": request.IP,
}).Warn("failed to validate user credentials (invalid format)")
return api.SftpAuthResponse{}, new(sftpInvalidCredentialsError)
} }
type SftpAuthResponse struct {
Server string `json:"server"`
Token string `json:"token"`
Permissions []string `json:"permissions"`
}
// ValidateSftpCredentials makes a request to determine if the username and
// password combination provided is associated with a valid server on the instance
// using the Panel's authentication control mechanisms. This will get itself
// throttled if too many requests are made, allowing us to completely offload
// all of the authorization security logic to the Panel.
func (c *client) ValidateSftpCredentials(ctx context.Context, request SftpAuthRequest) (SftpAuthResponse, error) {
var auth SftpAuthResponse
res, err := c.post(ctx, "/sftp/auth", request) res, err := c.post(ctx, "/sftp/auth", request)
if err != nil { if err != nil {
return api.SftpAuthResponse{}, err return auth, err
} }
e := res.Error() e := res.Error()
@ -38,13 +42,12 @@ func (c *client) ValidateSftpCredentials(ctx context.Context, request api.SftpAu
"ip": request.IP, "ip": request.IP,
}).Warn(e.Error()) }).Warn(e.Error())
return api.SftpAuthResponse{}, &sftpInvalidCredentialsError{} return auth, &SftpInvalidCredentialsError{}
} }
return api.SftpAuthResponse{}, errors.New(e.Error()) return auth, errors.New(e.Error())
} }
r := api.SftpAuthResponse{} err = res.BindJSON(&auth)
err = res.BindJSON(&r) return auth, err
return r, err
} }

View File

@ -46,54 +46,10 @@ func NewEmptyManager(client remote.Client) *Manager {
return &Manager{client: client} return &Manager{client: client}
} }
// initializeFromRemoteSource iterates over a given directory and loads all of // Client returns the HTTP client interface that allows interaction with the
// the servers listed before returning them to the calling function. // Panel API.
func (m *Manager) init(ctx context.Context) error { func (m *Manager) Client() remote.Client {
log.Info("fetching list of servers from API") return m.client
servers, err := m.client.GetServers(ctx, config.Get().RemoteQuery.BootServersPerPage)
if err != nil {
if !remote.IsRequestError(err) {
return errors.WithStackIf(err)
}
return errors.New(err.Error())
}
start := time.Now()
log.WithField("total_configs", len(servers)).Info("processing servers returned by the API")
pool := workerpool.New(runtime.NumCPU())
log.Debugf("using %d workerpools to instantiate server instances", runtime.NumCPU())
for _, data := range servers {
data := data
pool.Submit(func() {
// Parse the json.RawMessage into an expected struct value. We do this here so that a single broken
// server does not cause the entire boot process to hang, and allows us to show more useful error
// messaging in the output.
d := remote.ServerConfigurationResponse{
Settings: data.Settings,
}
log.WithField("server", data.Uuid).Info("creating new server object from API response")
if err := json.Unmarshal(data.ProcessConfiguration, &d.ProcessConfiguration); err != nil {
log.WithField("server", data.Uuid).WithField("error", err).Error("failed to parse server configuration from API response, skipping...")
return
}
s, err := m.InitServer(d)
if err != nil {
log.WithField("server", data.Uuid).WithField("error", err).Error("failed to load server, skipping...")
return
}
m.Add(s)
})
}
// Wait until we've processed all of the configuration files in the directory
// before continuing.
pool.StopWait()
diff := time.Now().Sub(start)
log.WithField("duration", fmt.Sprintf("%s", diff)).Info("finished processing server configurations")
return nil
} }
// Put replaces all of the current values in the collection with the value that // Put replaces all of the current values in the collection with the value that
@ -256,3 +212,53 @@ func (m *Manager) InitServer(data remote.ServerConfigurationResponse) (*Server,
return s, nil return s, nil
} }
// initializeFromRemoteSource iterates over a given directory and loads all of
// the servers listed before returning them to the calling function.
func (m *Manager) init(ctx context.Context) error {
log.Info("fetching list of servers from API")
servers, err := m.client.GetServers(ctx, config.Get().RemoteQuery.BootServersPerPage)
if err != nil {
if !remote.IsRequestError(err) {
return errors.WithStackIf(err)
}
return errors.New(err.Error())
}
start := time.Now()
log.WithField("total_configs", len(servers)).Info("processing servers returned by the API")
pool := workerpool.New(runtime.NumCPU())
log.Debugf("using %d workerpools to instantiate server instances", runtime.NumCPU())
for _, data := range servers {
data := data
pool.Submit(func() {
// Parse the json.RawMessage into an expected struct value. We do this here so that a single broken
// server does not cause the entire boot process to hang, and allows us to show more useful error
// messaging in the output.
d := remote.ServerConfigurationResponse{
Settings: data.Settings,
}
log.WithField("server", data.Uuid).Info("creating new server object from API response")
if err := json.Unmarshal(data.ProcessConfiguration, &d.ProcessConfiguration); err != nil {
log.WithField("server", data.Uuid).WithField("error", err).Error("failed to parse server configuration from API response, skipping...")
return
}
s, err := m.InitServer(d)
if err != nil {
log.WithField("server", data.Uuid).WithField("error", err).Error("failed to load server, skipping...")
return
}
m.Add(s)
})
}
// Wait until we've processed all of the configuration files in the directory
// before continuing.
pool.StopWait()
diff := time.Now().Sub(start)
log.WithField("duration", fmt.Sprintf("%s", diff)).Info("finished processing server configurations")
return nil
}

View File

@ -1,6 +1,7 @@
package sftp package sftp
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
@ -10,6 +11,7 @@ import (
"net" "net"
"os" "os"
"path" "path"
"regexp"
"strconv" "strconv"
"strings" "strings"
@ -18,10 +20,16 @@ import (
"github.com/pkg/sftp" "github.com/pkg/sftp"
"github.com/pterodactyl/wings/api" "github.com/pterodactyl/wings/api"
"github.com/pterodactyl/wings/config" "github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/remote"
"github.com/pterodactyl/wings/server" "github.com/pterodactyl/wings/server"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
// Usernames all follow the same format, so don't even bother hitting the API if the username is not
// at least in the expected format. This is very basic protection against random bots finding the SFTP
// server and sending a flood of usernames.
var validUsernameRegexp = regexp.MustCompile(`^(?i)(.+)\.([a-z0-9]{8})$`)
//goland:noinspection GoNameStartsWithPackageName //goland:noinspection GoNameStartsWithPackageName
type SFTPServer struct { type SFTPServer struct {
manager *server.Manager manager *server.Manager
@ -164,7 +172,7 @@ func (c *SFTPServer) generatePrivateKey() error {
// A function capable of validating user credentials with the Panel API. // A function capable of validating user credentials with the Panel API.
func (c *SFTPServer) passwordCallback(conn ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { func (c *SFTPServer) passwordCallback(conn ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
request := api.SftpAuthRequest{ request := remote.SftpAuthRequest{
User: conn.User(), User: conn.User(),
Pass: string(pass), Pass: string(pass),
IP: conn.RemoteAddr().String(), IP: conn.RemoteAddr().String(),
@ -175,7 +183,12 @@ func (c *SFTPServer) passwordCallback(conn ssh.ConnMetadata, pass []byte) (*ssh.
logger := log.WithFields(log.Fields{"subsystem": "sftp", "username": conn.User(), "ip": conn.RemoteAddr().String()}) logger := log.WithFields(log.Fields{"subsystem": "sftp", "username": conn.User(), "ip": conn.RemoteAddr().String()})
logger.Debug("validating credentials for SFTP connection") logger.Debug("validating credentials for SFTP connection")
resp, err := api.New().ValidateSftpCredentials(request) if !validUsernameRegexp.MatchString(request.User) {
logger.Warn("failed to validate user credentials (invalid format)")
return nil, &remote.SftpInvalidCredentialsError{}
}
resp, err := c.manager.Client().ValidateSftpCredentials(context.Background(), request)
if err != nil { if err != nil {
if api.IsInvalidCredentialsError(err) { if api.IsInvalidCredentialsError(err) {
logger.Warn("failed to validate user credentials (invalid username or password)") logger.Warn("failed to validate user credentials (invalid username or password)")