add new panelapi package

should eventually replace the api package
This commit is contained in:
Jakob Schrettenbrunner 2021-01-08 22:43:03 +00:00
parent 217ca72eb3
commit 94f4207d60
7 changed files with 539 additions and 0 deletions

34
panelapi/backup.go Normal file
View File

@ -0,0 +1,34 @@
package panelapi
import (
"context"
"fmt"
"strconv"
"github.com/pterodactyl/wings/api"
)
func (c *client) GetBackupRemoteUploadURLs(ctx context.Context, backup string, size int64) (api.BackupRemoteUploadResponse, error) {
res, err := c.get(ctx, fmt.Sprintf("/backups/%s", backup), q{"size": strconv.FormatInt(size, 10)})
if err != nil {
return api.BackupRemoteUploadResponse{}, err
}
defer res.Body.Close()
if res.HasError() {
return api.BackupRemoteUploadResponse{}, res.Error()
}
r := api.BackupRemoteUploadResponse{}
err = res.BindJSON(&r)
return r, err
}
func (c *client) SetBackupStatus(ctx context.Context, backup string, data api.BackupRequest) error {
resp, err := c.post(ctx, fmt.Sprintf("/backups/%s", backup), data)
if err != nil {
return err
}
defer resp.Body.Close()
return resp.Error()
}

55
panelapi/client.go Normal file
View File

@ -0,0 +1,55 @@
package panelapi
import (
"context"
"net/http"
"path/filepath"
"time"
"github.com/pterodactyl/wings/api"
)
type Client interface {
GetBackupRemoteUploadURLs(ctx context.Context, backup string, size int64) (api.BackupRemoteUploadResponse, error)
GetInstallationScript(ctx context.Context, uuid string) (api.InstallationScript, error)
GetServerConfiguration(ctx context.Context, uuid string) (api.ServerConfigurationResponse, error)
GetServers(context context.Context, perPage int) ([]api.RawServerData, error)
SetArchiveStatus(ctx context.Context, uuid string, successful bool) error
SetBackupStatus(ctx context.Context, backup string, data api.BackupRequest) error
SetInstallationStatus(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)
}
type client struct {
httpClient *http.Client
baseUrl string
tokenId string
token string
retries int
}
type ClientOption func(c *client)
func CreateClient(base, tokenId, token string, opts ...ClientOption) Client {
httpClient := &http.Client{
Timeout: time.Second * 15,
}
c := &client{
baseUrl: filepath.Join(base, "api/remote"),
tokenId: tokenId,
token: token,
httpClient: httpClient,
retries: 3,
}
for _, o := range opts {
o(c)
}
return c
}
func WithTimeout(timeout time.Duration) ClientOption {
return func(c *client) {
c.httpClient.Timeout = timeout
}
}

46
panelapi/errors.go Normal file
View File

@ -0,0 +1,46 @@
package panelapi
import (
"fmt"
"net/http"
)
type RequestErrors struct {
Errors []RequestError `json:"errors"`
}
type RequestError struct {
response *http.Response
Code string `json:"code"`
Status string `json:"status"`
Detail string `json:"detail"`
}
func IsRequestError(err error) bool {
_, ok := err.(*RequestError)
return ok
}
// Returns the error response in a string form that can be more easily consumed.
func (re *RequestError) Error() string {
c := 0
if re.response != nil {
c = re.response.StatusCode
}
return fmt.Sprintf("Error response from Panel: %s: %s (HTTP/%d)", re.Code, re.Detail, c)
}
type sftpInvalidCredentialsError struct {
}
func (ice sftpInvalidCredentialsError) Error() string {
return "the credentials provided were invalid"
}
func IsInvalidCredentialsError(err error) bool {
_, ok := err.(*sftpInvalidCredentialsError)
return ok
}

158
panelapi/http.go Normal file
View File

@ -0,0 +1,158 @@
package panelapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"path/filepath"
"github.com/pterodactyl/wings/system"
)
// 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
}
// A generic type allowing for easy binding use when making requests to API endpoints
// that only expect a singular argument or something that would not benefit from being
// a typed struct.
//
// Inspired by gin.H, same concept.
type d map[string]interface{}
// Same concept as d, but a map of strings, used for querying GET requests.
type q map[string]string
// 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.
func (c *client) requestOnce(ctx context.Context, method, path string, body io.Reader, opts ...func(r *http.Request)) (*Response, error) {
req, err := http.NewRequest(method, filepath.Join(c.baseUrl, path), body)
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)
res, err := c.httpClient.Do(req.WithContext(ctx))
return &Response{res}, err
}
// request executes a http request and retries when errors occur.
// It appends the path to the endpoint of the client and adds the authentication token to the request.
func (c *client) request(ctx context.Context, method, path string, body io.Reader, opts ...func(r *http.Request)) (*Response, error) {
var doErr error
var res *Response
for i := 0; i < c.retries; i++ {
res, doErr = c.requestOnce(ctx, method, path, body, opts...)
if doErr == nil &&
res.StatusCode < http.StatusInternalServerError &&
res.StatusCode != http.StatusTooManyRequests {
break
}
}
return res, doErr
}
// 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))
}
// 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.
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 {
return nil, errors.New("no response exists on interface")
}
if r.Response.Body != nil {
b, _ = ioutil.ReadAll(r.Response.Body)
}
r.Response.Body = ioutil.NopCloser(bytes.NewBuffer(b))
return b, nil
}
// 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.
func (r *Response) BindJSON(v interface{}) error {
b, err := r.Read()
if err != nil {
return err
}
return json.Unmarshal(b, &v)
}
// Returns the first error message from the API call as a string.
// The error message will be formatted similar to the below example:
//
// 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)
e := &RequestError{}
if len(errs.Errors) > 0 {
e = &errs.Errors[0]
}
e.response = r.Response
return e
}

167
panelapi/servers.go Normal file
View File

@ -0,0 +1,167 @@
package panelapi
import (
"context"
"encoding/json"
"fmt"
"strconv"
"sync"
"github.com/pterodactyl/wings/api"
"golang.org/x/sync/errgroup"
)
const (
ProcessStopCommand = "command"
ProcessStopSignal = "signal"
ProcessStopNativeStop = "stop"
)
// Holds the server configuration data returned from the Panel. When a server process
// is started, Wings communicates with the Panel to fetch the latest build information
// as well as get all of the details needed to parse the given Egg.
//
// This means we do not need to hit Wings each time part of the server is updated, and
// the Panel serves as the source of truth at all times. This also means if a configuration
// is accidentally wiped on Wings we can self-recover without too much hassle, so long
// as Wings is aware of what servers should exist on it.
type ServerConfigurationResponse struct {
Settings json.RawMessage `json:"settings"`
ProcessConfiguration *api.ProcessConfiguration `json:"process_configuration"`
}
// Defines installation script information for a server process. This is used when
// a server is installed for the first time, and when a server is marked for re-installation.
type InstallationScript struct {
ContainerImage string `json:"container_image"`
Entrypoint string `json:"entrypoint"`
Script string `json:"script"`
}
type allServerResponse struct {
Data []api.RawServerData `json:"data"`
Meta api.Pagination `json:"meta"`
}
type RawServerData struct {
Uuid string `json:"uuid"`
Settings json.RawMessage `json:"settings"`
ProcessConfiguration json.RawMessage `json:"process_configuration"`
}
func (c *client) GetServersPaged(ctx context.Context, page, limit int) ([]api.RawServerData, api.Pagination, error) {
res, err := c.get(ctx, "/servers", q{
"page": strconv.Itoa(page),
"per_page": strconv.Itoa(limit),
})
if err != nil {
return nil, api.Pagination{}, err
}
defer res.Body.Close()
if res.HasError() {
return nil, api.Pagination{}, res.Error()
}
var r allServerResponse
if err := res.BindJSON(&r); err != nil {
return nil, api.Pagination{}, err
}
return r.Data, r.Meta, nil
}
func (c *client) GetServers(ctx context.Context, perPage int) ([]api.RawServerData, error) {
servers, pageMeta, err := c.GetServersPaged(ctx, 0, perPage)
if err != nil {
return nil, err
}
// if the amount of servers exceeds the page limit, get the remaining pages in parallel
if pageMeta.LastPage > 1 {
eg, _ := errgroup.WithContext(ctx)
serversMu := sync.Mutex{}
for page := pageMeta.CurrentPage + 1; page <= pageMeta.LastPage; page++ {
eg.Go(func() error {
ps, _, err := c.GetServersPaged(ctx, perPage, int(page))
if err != nil {
return err
}
serversMu.Lock()
servers = append(servers, ps...)
serversMu.Unlock()
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, err
}
}
return servers, nil
}
func (c *client) GetServerConfiguration(ctx context.Context, uuid string) (api.ServerConfigurationResponse, error) {
res, err := c.get(ctx, fmt.Sprintf("/servers/%s", uuid), nil)
if err != nil {
return api.ServerConfigurationResponse{}, err
}
defer res.Body.Close()
if res.HasError() {
return api.ServerConfigurationResponse{}, err
}
config := api.ServerConfigurationResponse{}
err = res.BindJSON(&config)
return config, err
}
func (c *client) GetInstallationScript(ctx context.Context, uuid string) (api.InstallationScript, error) {
res, err := c.get(ctx, fmt.Sprintf("/servers/%s/install", uuid), nil)
if err != nil {
return api.InstallationScript{}, err
}
defer res.Body.Close()
if res.HasError() {
return api.InstallationScript{}, err
}
config := api.InstallationScript{}
err = res.BindJSON(&config)
return config, err
}
func (c *client) SetInstallationStatus(ctx context.Context, uuid string, successful bool) error {
resp, err := c.post(ctx, fmt.Sprintf("/servers/%s/install", uuid), d{"successful": successful})
if err != nil {
return err
}
defer resp.Body.Close()
return resp.Error()
}
func (c *client) SetArchiveStatus(ctx context.Context, uuid string, successful bool) error {
resp, err := c.post(ctx, fmt.Sprintf("/servers/%s/archive", uuid), d{"successful": successful})
if err != nil {
return err
}
defer resp.Body.Close()
return resp.Error()
}
func (c *client) SetTransferStatus(ctx context.Context, uuid string, successful bool) error {
state := "failure"
if successful {
state = "success"
}
resp, err := c.post(ctx, fmt.Sprintf("/servers/%s/transfer/%s", uuid), state)
if err != nil {
return err
}
defer resp.Body.Close()
return resp.Error()
}

50
panelapi/sftp.go Normal file
View File

@ -0,0 +1,50 @@
package panelapi
import (
"context"
"errors"
"regexp"
"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
// 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 (c *client) ValidateSftpCredentials(ctx context.Context, request api.SftpAuthRequest) (api.SftpAuthResponse, error) {
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)
}
res, err := c.post(ctx, "/sftp/auth", request)
if err != nil {
return api.SftpAuthResponse{}, err
}
e := res.Error()
if e != nil {
if res.StatusCode >= 400 && res.StatusCode < 500 {
log.WithFields(log.Fields{
"subsystem": "sftp",
"username": request.User,
"ip": request.IP,
}).Warn(e.Error())
return api.SftpAuthResponse{}, &sftpInvalidCredentialsError{}
}
return api.SftpAuthResponse{}, errors.New(e.Error())
}
r := api.SftpAuthResponse{}
err = res.BindJSON(&r)
return r, err
}

29
panelapi/util.go Normal file
View File

@ -0,0 +1,29 @@
package panelapi
import (
"net/http"
"github.com/apex/log"
)
// Logs the request into the debug log with all of the important request bits.
// The authorization key will be cleaned up before being output.
//
// TODO(schrej): Somehow only execute the logic when log level is debug.
func debugLogRequest(req *http.Request) {
headers := make(map[string][]string)
for k, v := range req.Header {
if k != "Authorization" || len(v) == 0 {
headers[k] = v
continue
}
headers[k] = []string{v[0][0:15] + "(redacted)"}
}
log.WithFields(log.Fields{
"method": req.Method,
"endpoint": req.URL.String(),
"headers": headers,
}).Debug("making request to external HTTP endpoint")
}