diff --git a/panelapi/backup.go b/panelapi/backup.go new file mode 100644 index 0000000..f2b26c3 --- /dev/null +++ b/panelapi/backup.go @@ -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() +} diff --git a/panelapi/client.go b/panelapi/client.go new file mode 100644 index 0000000..90eab83 --- /dev/null +++ b/panelapi/client.go @@ -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 + } +} diff --git a/panelapi/errors.go b/panelapi/errors.go new file mode 100644 index 0000000..1a7ac0d --- /dev/null +++ b/panelapi/errors.go @@ -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 +} diff --git a/panelapi/http.go b/panelapi/http.go new file mode 100644 index 0000000..96bc587 --- /dev/null +++ b/panelapi/http.go @@ -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 +} diff --git a/panelapi/servers.go b/panelapi/servers.go new file mode 100644 index 0000000..b0065cd --- /dev/null +++ b/panelapi/servers.go @@ -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() +} diff --git a/panelapi/sftp.go b/panelapi/sftp.go new file mode 100644 index 0000000..abdc7f8 --- /dev/null +++ b/panelapi/sftp.go @@ -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 +} diff --git a/panelapi/util.go b/panelapi/util.go new file mode 100644 index 0000000..9410881 --- /dev/null +++ b/panelapi/util.go @@ -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") +}