mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-07 16:02:13 +00:00
149 lines
4.5 KiB
Go
149 lines
4.5 KiB
Go
package cloudlink
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/pkg/safehttp"
|
|
)
|
|
|
|
// client is the outbound-only HTTP client to the cloud's pool-link API.
|
|
type client struct {
|
|
baseURL string
|
|
token string
|
|
version string
|
|
http *http.Client
|
|
}
|
|
|
|
func newClient(baseURL, token, version string) *client {
|
|
// The cloud URL is configurable, so it dials public addresses only; a loopback cloud is a dev setup.
|
|
hc := safehttp.Client(20 * time.Second)
|
|
if devMode() {
|
|
hc = &http.Client{Timeout: 20 * time.Second}
|
|
}
|
|
return &client{
|
|
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
|
token: token,
|
|
version: version,
|
|
http: hc,
|
|
}
|
|
}
|
|
|
|
// remoteError is the cloud's error envelope, re-surfaced with its own code.
|
|
type remoteError struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
Code string `json:"code"`
|
|
RequestID string `json:"request_id"`
|
|
}
|
|
|
|
var (
|
|
// remoteIdentifier is a machine code callers branch on; anything else is replaced.
|
|
remoteIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
|
// poolLinkCode is the cloud API's own vocabulary, the only codes whose message is shown.
|
|
poolLinkCode = regexp.MustCompile(`^pool_link_[a-z_]{1,48}$`)
|
|
|
|
errCloudUnreachable = errx.NewWithIdentifier(errx.ServiceUnavailable, "cloud_link_unreachable", "Warmbly Cloud could not be reached. Try again in a moment.")
|
|
)
|
|
|
|
func (c *client) do(ctx context.Context, method, path string, body any, out any) *errx.Error {
|
|
var buf io.Reader
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
buf = bytes.NewReader(raw)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+"/v1/pool-link"+path, buf)
|
|
if err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "warmbly-cloudlink/"+c.version)
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
if c.version != "" {
|
|
req.Header.Set("X-Warmbly-Instance-Version", c.version)
|
|
}
|
|
res, err := c.http.Do(req)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("path", path).Msg("cloudlink: request failed")
|
|
return errCloudUnreachable
|
|
}
|
|
defer res.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20))
|
|
if res.StatusCode >= 400 {
|
|
return remoteFailure(res.StatusCode, path, raw)
|
|
}
|
|
if out != nil && len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
return errx.NewWithIdentifier(errx.ServiceUnavailable, "cloud_link_bad_response", "Warmbly Cloud returned an unreadable response.")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// remoteFailure re-surfaces the cloud's code; its text only for the pool-link vocabulary.
|
|
func remoteFailure(status int, path string, raw []byte) *errx.Error {
|
|
var re remoteError
|
|
_ = json.Unmarshal(raw, &re)
|
|
code := remoteCode(status)
|
|
id := re.Code
|
|
if !remoteIdentifier.MatchString(id) {
|
|
id = "cloud_link_remote"
|
|
}
|
|
if poolLinkCode.MatchString(id) {
|
|
if msg := cleanRemoteMessage(re.Message); msg != "" {
|
|
return errx.NewWithIdentifier(code, id, msg)
|
|
}
|
|
}
|
|
log.Warn().Int("status", status).Str("path", path).Str("code", re.Code).Str("remote_request_id", re.RequestID).Msg("cloudlink: cloud refused the request")
|
|
return errx.NewWithIdentifier(code, id, fmt.Sprintf("Warmbly Cloud answered %d.", status))
|
|
}
|
|
|
|
// cleanRemoteMessage keeps a cloud message short and printable, or drops it.
|
|
func cleanRemoteMessage(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" || len(s) > 400 {
|
|
return ""
|
|
}
|
|
for _, r := range s {
|
|
if !unicode.IsPrint(r) {
|
|
return ""
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// remoteCode maps the cloud's status onto one errx can answer with. errx.JSON
|
|
// writes status 0 for a code outside its table, which gin turns into a 200, so
|
|
// an unmapped answer (a proxy's 502) would report a failed call as a success.
|
|
func remoteCode(status int) errx.Code {
|
|
switch status {
|
|
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusPaymentRequired, http.StatusForbidden,
|
|
http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity, http.StatusTooManyRequests,
|
|
http.StatusInternalServerError, http.StatusNotImplemented, http.StatusServiceUnavailable:
|
|
return errx.Code(status)
|
|
case http.StatusGone:
|
|
return errx.NotFound
|
|
}
|
|
if status >= 500 {
|
|
return errx.ServiceUnavailable
|
|
}
|
|
return errx.BadRequest
|
|
}
|