2021-01-10 19:52:54 +00:00
|
|
|
package remote
|
2021-01-08 22:43:03 +00:00
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"io/ioutil"
|
|
|
|
"net/http"
|
2021-05-02 22:16:30 +00:00
|
|
|
"strconv"
|
2021-02-02 05:43:04 +00:00
|
|
|
"strings"
|
|
|
|
"time"
|
2021-01-08 22:43:03 +00:00
|
|
|
|
2021-03-04 04:51:49 +00:00
|
|
|
"emperror.dev/errors"
|
2021-02-02 05:43:04 +00:00
|
|
|
"github.com/apex/log"
|
2021-05-02 22:16:30 +00:00
|
|
|
"github.com/cenkalti/backoff/v4"
|
2021-01-08 22:43:03 +00:00
|
|
|
"github.com/pterodactyl/wings/system"
|
|
|
|
)
|
|
|
|
|
2021-02-02 05:43:04 +00:00
|
|
|
type Client interface {
|
|
|
|
GetBackupRemoteUploadURLs(ctx context.Context, backup string, size int64) (BackupRemoteUploadResponse, error)
|
|
|
|
GetInstallationScript(ctx context.Context, uuid string) (InstallationScript, error)
|
|
|
|
GetServerConfiguration(ctx context.Context, uuid string) (ServerConfigurationResponse, error)
|
|
|
|
GetServers(context context.Context, perPage int) ([]RawServerData, error)
|
2021-02-24 05:23:49 +00:00
|
|
|
ResetServersState(ctx context.Context) error
|
2021-02-02 05:43:04 +00:00
|
|
|
SetArchiveStatus(ctx context.Context, uuid string, successful bool) error
|
|
|
|
SetBackupStatus(ctx context.Context, backup string, data BackupRequest) error
|
|
|
|
SendRestorationStatus(ctx context.Context, backup string, successful bool) error
|
|
|
|
SetInstallationStatus(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)
|
|
|
|
}
|
2021-01-08 22:43:03 +00:00
|
|
|
|
2021-02-02 05:43:04 +00:00
|
|
|
type client struct {
|
2021-05-02 22:16:30 +00:00
|
|
|
httpClient *http.Client
|
|
|
|
baseUrl string
|
|
|
|
tokenId string
|
|
|
|
token string
|
|
|
|
maxAttempts int
|
2021-02-02 05:43:04 +00:00
|
|
|
}
|
2021-01-08 22:43:03 +00:00
|
|
|
|
2021-02-02 05:43:04 +00:00
|
|
|
// New returns a new HTTP request client that is used for making authenticated
|
|
|
|
// requests to the Panel that this instance is running under.
|
|
|
|
func New(base string, opts ...ClientOption) Client {
|
|
|
|
c := client{
|
|
|
|
baseUrl: strings.TrimSuffix(base, "/") + "/api/remote",
|
|
|
|
httpClient: &http.Client{
|
|
|
|
Timeout: time.Second * 15,
|
|
|
|
},
|
2021-05-02 22:16:30 +00:00
|
|
|
maxAttempts: 0,
|
2021-02-02 05:43:04 +00:00
|
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
|
|
opt(&c)
|
|
|
|
}
|
|
|
|
return &c
|
2021-02-02 04:50:23 +00:00
|
|
|
}
|
|
|
|
|
2021-02-02 05:43:04 +00:00
|
|
|
// WithCredentials sets the credentials to use when making request to the remote
|
|
|
|
// API endpoint.
|
|
|
|
func WithCredentials(id, token string) ClientOption {
|
|
|
|
return func(c *client) {
|
|
|
|
c.tokenId = id
|
|
|
|
c.token = token
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// WithHttpClient sets the underlying HTTP client instance to use when making
|
|
|
|
// requests to the Panel API.
|
|
|
|
func WithHttpClient(httpClient *http.Client) ClientOption {
|
|
|
|
return func(c *client) {
|
|
|
|
c.httpClient = httpClient
|
|
|
|
}
|
2021-02-02 04:50:23 +00:00
|
|
|
}
|
|
|
|
|
2021-05-02 22:16:30 +00:00
|
|
|
// Get executes a HTTP GET request.
|
|
|
|
func (c *client) Get(ctx context.Context, path string, query q) (*Response, error) {
|
|
|
|
return c.request(ctx, http.MethodGet, path, nil, func(r *http.Request) {
|
|
|
|
q := r.URL.Query()
|
|
|
|
for k, v := range query {
|
|
|
|
q.Set(k, v)
|
|
|
|
}
|
|
|
|
r.URL.RawQuery = q.Encode()
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// Post executes a HTTP POST request.
|
|
|
|
func (c *client) Post(ctx context.Context, path string, data interface{}) (*Response, error) {
|
|
|
|
b, err := json.Marshal(data)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return c.request(ctx, http.MethodPost, path, bytes.NewBuffer(b))
|
|
|
|
}
|
|
|
|
|
2021-02-02 04:33:35 +00:00
|
|
|
// requestOnce creates a http request and executes it once. Prefer request()
|
|
|
|
// over this method when possible. It appends the path to the endpoint of the
|
|
|
|
// client and adds the authentication token to the request.
|
2021-01-08 22:43:03 +00:00
|
|
|
func (c *client) requestOnce(ctx context.Context, method, path string, body io.Reader, opts ...func(r *http.Request)) (*Response, error) {
|
2021-05-02 22:41:02 +00:00
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseUrl+path, body)
|
2021-01-08 22:43:03 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
req.Header.Set("User-Agent", fmt.Sprintf("Pterodactyl Wings/v%s (id:%s)", system.Version, c.tokenId))
|
|
|
|
req.Header.Set("Accept", "application/vnd.pterodactyl.v1+json")
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s.%s", c.tokenId, c.token))
|
|
|
|
|
|
|
|
// Call all opts functions to allow modifying the request
|
|
|
|
for _, o := range opts {
|
|
|
|
o(req)
|
|
|
|
}
|
|
|
|
|
|
|
|
debugLogRequest(req)
|
|
|
|
|
2021-05-02 22:41:02 +00:00
|
|
|
res, err := c.httpClient.Do(req)
|
2021-01-08 22:43:03 +00:00
|
|
|
return &Response{res}, err
|
|
|
|
}
|
|
|
|
|
2021-05-02 22:16:30 +00:00
|
|
|
// request executes a HTTP request against the Panel API. If there is an error
|
|
|
|
// encountered with the request it will be retried using an exponential backoff.
|
|
|
|
// If the error returned from the Panel is due to API throttling or because there
|
|
|
|
// are invalid authentication credentials provided the request will _not_ be
|
|
|
|
// retried by the backoff.
|
|
|
|
//
|
|
|
|
// This function automatically appends the path to the current client endpoint
|
|
|
|
// and adds the required authentication headers to the request that is being
|
|
|
|
// created. Errors returned will be of the RequestError type if there was some
|
|
|
|
// type of response from the API that can be parsed.
|
|
|
|
func (c *client) request(ctx context.Context, method, path string, body io.Reader, opts ...func(r *http.Request)) (*Response, error) {
|
|
|
|
var res *Response
|
|
|
|
err := backoff.Retry(func() error {
|
|
|
|
r, err := c.requestOnce(ctx, method, path, body, opts...)
|
|
|
|
if err != nil {
|
|
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
|
|
return backoff.Permanent(err)
|
|
|
|
}
|
|
|
|
return errors.WrapIf(err, "http: request creation failed")
|
2021-01-08 22:43:03 +00:00
|
|
|
}
|
2021-05-02 22:16:30 +00:00
|
|
|
res = r
|
|
|
|
if r.HasError() {
|
2021-05-02 22:41:02 +00:00
|
|
|
// Close the request body after returning the error to free up resources.
|
|
|
|
defer r.Body.Close()
|
2021-05-02 22:16:30 +00:00
|
|
|
// Don't keep spamming the endpoint if we've already made too many requests or
|
|
|
|
// if we're not even authenticated correctly. Retrying generally won't fix either
|
|
|
|
// of these issues.
|
2021-05-02 22:41:02 +00:00
|
|
|
if r.StatusCode == http.StatusForbidden ||
|
|
|
|
r.StatusCode == http.StatusTooManyRequests ||
|
|
|
|
r.StatusCode == http.StatusUnauthorized {
|
2021-05-02 22:16:30 +00:00
|
|
|
return backoff.Permanent(r.Error())
|
|
|
|
}
|
|
|
|
return r.Error()
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}, c.backoff(ctx))
|
2021-03-04 04:51:49 +00:00
|
|
|
if err != nil {
|
2021-05-02 22:16:30 +00:00
|
|
|
if v, ok := err.(*backoff.PermanentError); ok {
|
|
|
|
return nil, v.Unwrap()
|
|
|
|
}
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return res, nil
|
2021-01-08 22:43:03 +00:00
|
|
|
}
|
|
|
|
|
2021-05-02 22:16:30 +00:00
|
|
|
// backoff returns an exponential backoff function for use with remote API
|
|
|
|
// requests. This will allow an API call to be executed approximately 10 times
|
|
|
|
// before it is finally reported back as an error.
|
|
|
|
//
|
|
|
|
// This allows for issues with DNS resolution, or rare race conditions due to
|
|
|
|
// slower SQL queries on the Panel to potentially self-resolve without just
|
|
|
|
// immediately failing the first request. The example below shows the amount of
|
|
|
|
// time that has ellapsed between each call to the handler when an error is
|
|
|
|
// returned. You can tweak these values as needed to get the effect you desire.
|
|
|
|
//
|
|
|
|
// If maxAttempts is a value greater than 0 the backoff will be capped at a total
|
|
|
|
// number of executions, or the MaxElapsedTime, whichever comes first.
|
|
|
|
//
|
|
|
|
// call(): 0s
|
|
|
|
// call(): 552.330144ms
|
|
|
|
// call(): 1.63271196s
|
|
|
|
// call(): 2.94284202s
|
|
|
|
// call(): 4.525234711s
|
|
|
|
// call(): 6.865723375s
|
|
|
|
// call(): 11.37194223s
|
|
|
|
// call(): 14.593421816s
|
|
|
|
// call(): 20.202045293s
|
|
|
|
// call(): 27.36567952s <-- Stops here as MaxElapsedTime is 30 seconds
|
|
|
|
func (c *client) backoff(ctx context.Context) backoff.BackOffContext {
|
|
|
|
b := backoff.NewExponentialBackOff()
|
|
|
|
b.MaxInterval = time.Second * 12
|
|
|
|
b.MaxElapsedTime = time.Second * 30
|
|
|
|
if c.maxAttempts > 0 {
|
|
|
|
return backoff.WithContext(backoff.WithMaxRetries(b, uint64(c.maxAttempts)), ctx)
|
2021-01-08 22:43:03 +00:00
|
|
|
}
|
2021-05-02 22:16:30 +00:00
|
|
|
return backoff.WithContext(b, ctx)
|
2021-01-08 22:43:03 +00:00
|
|
|
}
|
|
|
|
|
2021-02-02 05:43:04 +00:00
|
|
|
// Response is a custom response type that allows for commonly used error
|
|
|
|
// handling and response parsing from the Panel API. This just embeds the normal
|
|
|
|
// HTTP response from Go and we attach a few helper functions to it.
|
|
|
|
type Response struct {
|
|
|
|
*http.Response
|
|
|
|
}
|
|
|
|
|
2021-02-02 04:33:35 +00:00
|
|
|
// HasError determines if the API call encountered an error. If no request has
|
|
|
|
// been made the response will be false. This function will evaluate to true if
|
|
|
|
// the response code is anything 300 or higher.
|
2021-01-08 22:43:03 +00:00
|
|
|
func (r *Response) HasError() bool {
|
|
|
|
if r.Response == nil {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
return r.StatusCode >= 300 || r.StatusCode < 200
|
|
|
|
}
|
|
|
|
|
|
|
|
// Reads the body from the response and returns it, then replaces it on the response
|
|
|
|
// so that it can be read again later. This does not close the response body, so any
|
|
|
|
// functions calling this should be sure to manually defer a Body.Close() call.
|
|
|
|
func (r *Response) Read() ([]byte, error) {
|
|
|
|
var b []byte
|
|
|
|
if r.Response == nil {
|
2021-05-02 22:41:02 +00:00
|
|
|
return nil, errors.New("remote: attempting to read missing response")
|
2021-01-08 22:43:03 +00:00
|
|
|
}
|
|
|
|
if r.Response.Body != nil {
|
|
|
|
b, _ = ioutil.ReadAll(r.Response.Body)
|
|
|
|
}
|
|
|
|
r.Response.Body = ioutil.NopCloser(bytes.NewBuffer(b))
|
|
|
|
return b, nil
|
|
|
|
}
|
|
|
|
|
2021-02-02 04:33:35 +00:00
|
|
|
// BindJSON binds a given interface with the data returned in the response. This
|
|
|
|
// is a shortcut for calling Read and then manually calling json.Unmarshal on
|
|
|
|
// the raw bytes.
|
2021-01-08 22:43:03 +00:00
|
|
|
func (r *Response) BindJSON(v interface{}) error {
|
|
|
|
b, err := r.Read()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2021-03-04 04:51:49 +00:00
|
|
|
if err := json.Unmarshal(b, &v); err != nil {
|
2021-05-02 22:41:02 +00:00
|
|
|
return errors.Wrap(err, "remote: could not unmarshal response")
|
2021-03-04 04:51:49 +00:00
|
|
|
}
|
|
|
|
return nil
|
2021-01-08 22:43:03 +00:00
|
|
|
}
|
|
|
|
|
2021-02-02 04:33:35 +00:00
|
|
|
// Returns the first error message from the API call as a string. The error
|
2021-05-02 22:16:30 +00:00
|
|
|
// message will be formatted similar to the below example. If there is no error
|
|
|
|
// that can be parsed out of the API you'll still get a RequestError returned
|
|
|
|
// but the RequestError.Code will be "_MissingResponseCode".
|
2021-01-08 22:43:03 +00:00
|
|
|
//
|
|
|
|
// HttpNotFoundException: The requested resource does not exist. (HTTP/404)
|
|
|
|
func (r *Response) Error() error {
|
|
|
|
if !r.HasError() {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var errs RequestErrors
|
|
|
|
_ = r.BindJSON(&errs)
|
|
|
|
|
2021-05-02 22:16:30 +00:00
|
|
|
e := &RequestError{
|
|
|
|
Code: "_MissingResponseCode",
|
|
|
|
Status: strconv.Itoa(r.StatusCode),
|
|
|
|
Detail: "No error response returned from API endpoint.",
|
|
|
|
}
|
2021-01-08 22:43:03 +00:00
|
|
|
if len(errs.Errors) > 0 {
|
|
|
|
e = &errs.Errors[0]
|
|
|
|
}
|
|
|
|
|
|
|
|
e.response = r.Response
|
|
|
|
|
2021-05-02 22:41:02 +00:00
|
|
|
return errors.WithStackDepth(e, 1)
|
2021-01-08 22:43:03 +00:00
|
|
|
}
|
2021-02-02 05:43:04 +00:00
|
|
|
|
|
|
|
// Logs the request into the debug log with all of the important request bits.
|
|
|
|
// The authorization key will be cleaned up before being output.
|
|
|
|
func debugLogRequest(req *http.Request) {
|
|
|
|
if l, ok := log.Log.(*log.Logger); ok && l.Level != log.DebugLevel {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
headers := make(map[string][]string)
|
|
|
|
for k, v := range req.Header {
|
|
|
|
if k != "Authorization" || len(v) == 0 || len(v[0]) == 0 {
|
|
|
|
headers[k] = v
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
headers[k] = []string{"(redacted)"}
|
|
|
|
}
|
|
|
|
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
"method": req.Method,
|
|
|
|
"endpoint": req.URL.String(),
|
|
|
|
"headers": headers,
|
|
|
|
}).Debug("making request to external HTTP endpoint")
|
|
|
|
}
|