123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- package tunnelstore
- import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/url"
- "path"
- "strings"
- "time"
- "github.com/cloudflare/cloudflared/teamnet"
- "github.com/google/uuid"
- "github.com/pkg/errors"
- "github.com/rs/zerolog"
- )
- const (
- defaultTimeout = 15 * time.Second
- jsonContentType = "application/json"
- )
- var (
- ErrTunnelNameConflict = errors.New("tunnel with name already exists")
- ErrUnauthorized = errors.New("unauthorized")
- ErrBadRequest = errors.New("incorrect request parameters")
- ErrNotFound = errors.New("not found")
- ErrAPINoSuccess = errors.New("API call failed")
- )
- type Tunnel struct {
- ID uuid.UUID `json:"id"`
- Name string `json:"name"`
- CreatedAt time.Time `json:"created_at"`
- DeletedAt time.Time `json:"deleted_at"`
- Connections []Connection `json:"connections"`
- }
- type Connection struct {
- ColoName string `json:"colo_name"`
- ID uuid.UUID `json:"id"`
- IsPendingReconnect bool `json:"is_pending_reconnect"`
- }
- type Change = string
- const (
- ChangeNew = "new"
- ChangeUpdated = "updated"
- ChangeUnchanged = "unchanged"
- )
- // Route represents a record type that can route to a tunnel
- type Route interface {
- json.Marshaler
- RecordType() string
- UnmarshalResult(body io.Reader) (RouteResult, error)
- }
- type RouteResult interface {
- // SuccessSummary explains what will route to this tunnel when it's provisioned successfully
- SuccessSummary() string
- }
- type DNSRoute struct {
- userHostname string
- }
- type DNSRouteResult struct {
- route *DNSRoute
- CName Change `json:"cname"`
- }
- func NewDNSRoute(userHostname string) Route {
- return &DNSRoute{
- userHostname: userHostname,
- }
- }
- func (dr *DNSRoute) MarshalJSON() ([]byte, error) {
- s := struct {
- Type string `json:"type"`
- UserHostname string `json:"user_hostname"`
- }{
- Type: dr.RecordType(),
- UserHostname: dr.userHostname,
- }
- return json.Marshal(&s)
- }
- func (dr *DNSRoute) UnmarshalResult(body io.Reader) (RouteResult, error) {
- var result DNSRouteResult
- err := parseResponse(body, &result)
- result.route = dr
- return &result, err
- }
- func (dr *DNSRoute) RecordType() string {
- return "dns"
- }
- func (res *DNSRouteResult) SuccessSummary() string {
- var msgFmt string
- switch res.CName {
- case ChangeNew:
- msgFmt = "Added CNAME %s which will route to this tunnel"
- case ChangeUpdated: // this is not currently returned by tunnelsore
- msgFmt = "%s updated to route to your tunnel"
- case ChangeUnchanged:
- msgFmt = "%s is already configured to route to your tunnel"
- }
- return fmt.Sprintf(msgFmt, res.route.userHostname)
- }
- type LBRoute struct {
- lbName string
- lbPool string
- }
- type LBRouteResult struct {
- route *LBRoute
- LoadBalancer Change `json:"load_balancer"`
- Pool Change `json:"pool"`
- }
- func NewLBRoute(lbName, lbPool string) Route {
- return &LBRoute{
- lbName: lbName,
- lbPool: lbPool,
- }
- }
- func (lr *LBRoute) MarshalJSON() ([]byte, error) {
- s := struct {
- Type string `json:"type"`
- LBName string `json:"lb_name"`
- LBPool string `json:"lb_pool"`
- }{
- Type: lr.RecordType(),
- LBName: lr.lbName,
- LBPool: lr.lbPool,
- }
- return json.Marshal(&s)
- }
- func (lr *LBRoute) RecordType() string {
- return "lb"
- }
- func (lr *LBRoute) UnmarshalResult(body io.Reader) (RouteResult, error) {
- var result LBRouteResult
- err := parseResponse(body, &result)
- result.route = lr
- return &result, err
- }
- func (res *LBRouteResult) SuccessSummary() string {
- var msg string
- switch res.LoadBalancer + "," + res.Pool {
- case "new,new":
- msg = "Created load balancer %s and added a new pool %s with this tunnel as an origin"
- case "new,updated":
- msg = "Created load balancer %s with an existing pool %s which was updated to use this tunnel as an origin"
- case "new,unchanged":
- msg = "Created load balancer %s with an existing pool %s which already has this tunnel as an origin"
- case "updated,new":
- msg = "Added new pool %[2]s with this tunnel as an origin to load balancer %[1]s"
- case "updated,updated":
- msg = "Updated pool %[2]s to use this tunnel as an origin and added it to load balancer %[1]s"
- case "updated,unchanged":
- msg = "Added pool %[2]s, which already has this tunnel as an origin, to load balancer %[1]s"
- case "unchanged,updated":
- msg = "Added this tunnel as an origin in pool %[2]s which is already used by load balancer %[1]s"
- case "unchanged,unchanged":
- msg = "Load balancer %s already uses pool %s which has this tunnel as an origin"
- case "unchanged,new":
- // this state is not possible
- fallthrough
- default:
- msg = "Something went wrong: failed to modify load balancer %s with pool %s; please check traffic manager configuration in the dashboard"
- }
- return fmt.Sprintf(msg, res.route.lbName, res.route.lbPool)
- }
- type Client interface {
- // Named Tunnels endpoints
- CreateTunnel(name string, tunnelSecret []byte) (*Tunnel, error)
- GetTunnel(tunnelID uuid.UUID) (*Tunnel, error)
- DeleteTunnel(tunnelID uuid.UUID) error
- ListTunnels(filter *Filter) ([]*Tunnel, error)
- CleanupConnections(tunnelID uuid.UUID) error
- RouteTunnel(tunnelID uuid.UUID, route Route) (RouteResult, error)
- // Teamnet endpoints
- ListRoutes(filter *teamnet.Filter) ([]*teamnet.DetailedRoute, error)
- AddRoute(newRoute teamnet.NewRoute) (teamnet.Route, error)
- DeleteRoute(network net.IPNet) error
- GetByIP(ip net.IP) (teamnet.DetailedRoute, error)
- }
- type RESTClient struct {
- baseEndpoints *baseEndpoints
- authToken string
- userAgent string
- client http.Client
- log *zerolog.Logger
- }
- type baseEndpoints struct {
- accountLevel url.URL
- zoneLevel url.URL
- accountRoutes url.URL
- }
- var _ Client = (*RESTClient)(nil)
- func NewRESTClient(baseURL, accountTag, zoneTag, authToken, userAgent string, log *zerolog.Logger) (*RESTClient, error) {
- if strings.HasSuffix(baseURL, "/") {
- baseURL = baseURL[:len(baseURL)-1]
- }
- accountLevelEndpoint, err := url.Parse(fmt.Sprintf("%s/accounts/%s/tunnels", baseURL, accountTag))
- if err != nil {
- return nil, errors.Wrap(err, "failed to create account level endpoint")
- }
- accountRoutesEndpoint, err := url.Parse(fmt.Sprintf("%s/accounts/%s/teamnet/routes", baseURL, accountTag))
- if err != nil {
- return nil, errors.Wrap(err, "failed to create route account-level endpoint")
- }
- zoneLevelEndpoint, err := url.Parse(fmt.Sprintf("%s/zones/%s/tunnels", baseURL, zoneTag))
- if err != nil {
- return nil, errors.Wrap(err, "failed to create account level endpoint")
- }
- return &RESTClient{
- baseEndpoints: &baseEndpoints{
- accountLevel: *accountLevelEndpoint,
- zoneLevel: *zoneLevelEndpoint,
- accountRoutes: *accountRoutesEndpoint,
- },
- authToken: authToken,
- userAgent: userAgent,
- client: http.Client{
- Transport: &http.Transport{
- TLSHandshakeTimeout: defaultTimeout,
- ResponseHeaderTimeout: defaultTimeout,
- },
- Timeout: defaultTimeout,
- },
- log: log,
- }, nil
- }
- type newTunnel struct {
- Name string `json:"name"`
- TunnelSecret []byte `json:"tunnel_secret"`
- }
- func (r *RESTClient) CreateTunnel(name string, tunnelSecret []byte) (*Tunnel, error) {
- if name == "" {
- return nil, errors.New("tunnel name required")
- }
- if _, err := uuid.Parse(name); err == nil {
- return nil, errors.New("you cannot use UUIDs as tunnel names")
- }
- body := &newTunnel{
- Name: name,
- TunnelSecret: tunnelSecret,
- }
- resp, err := r.sendRequest("POST", r.baseEndpoints.accountLevel, body)
- if err != nil {
- return nil, errors.Wrap(err, "REST request failed")
- }
- defer resp.Body.Close()
- switch resp.StatusCode {
- case http.StatusOK:
- return unmarshalTunnel(resp.Body)
- case http.StatusConflict:
- return nil, ErrTunnelNameConflict
- }
- return nil, r.statusCodeToError("create tunnel", resp)
- }
- func (r *RESTClient) GetTunnel(tunnelID uuid.UUID) (*Tunnel, error) {
- endpoint := r.baseEndpoints.accountLevel
- endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v", tunnelID))
- resp, err := r.sendRequest("GET", endpoint, nil)
- if err != nil {
- return nil, errors.Wrap(err, "REST request failed")
- }
- defer resp.Body.Close()
- if resp.StatusCode == http.StatusOK {
- return unmarshalTunnel(resp.Body)
- }
- return nil, r.statusCodeToError("get tunnel", resp)
- }
- func (r *RESTClient) DeleteTunnel(tunnelID uuid.UUID) error {
- endpoint := r.baseEndpoints.accountLevel
- endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v", tunnelID))
- resp, err := r.sendRequest("DELETE", endpoint, nil)
- if err != nil {
- return errors.Wrap(err, "REST request failed")
- }
- defer resp.Body.Close()
- return r.statusCodeToError("delete tunnel", resp)
- }
- func (r *RESTClient) ListTunnels(filter *Filter) ([]*Tunnel, error) {
- endpoint := r.baseEndpoints.accountLevel
- endpoint.RawQuery = filter.encode()
- resp, err := r.sendRequest("GET", endpoint, nil)
- if err != nil {
- return nil, errors.Wrap(err, "REST request failed")
- }
- defer resp.Body.Close()
- if resp.StatusCode == http.StatusOK {
- return parseListTunnels(resp.Body)
- }
- return nil, r.statusCodeToError("list tunnels", resp)
- }
- func parseListTunnels(body io.ReadCloser) ([]*Tunnel, error) {
- var tunnels []*Tunnel
- err := parseResponse(body, &tunnels)
- return tunnels, err
- }
- func (r *RESTClient) CleanupConnections(tunnelID uuid.UUID) error {
- endpoint := r.baseEndpoints.accountLevel
- endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v/connections", tunnelID))
- resp, err := r.sendRequest("DELETE", endpoint, nil)
- if err != nil {
- return errors.Wrap(err, "REST request failed")
- }
- defer resp.Body.Close()
- return r.statusCodeToError("cleanup connections", resp)
- }
- func (r *RESTClient) RouteTunnel(tunnelID uuid.UUID, route Route) (RouteResult, error) {
- endpoint := r.baseEndpoints.zoneLevel
- endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v/routes", tunnelID))
- resp, err := r.sendRequest("PUT", endpoint, route)
- if err != nil {
- return nil, errors.Wrap(err, "REST request failed")
- }
- defer resp.Body.Close()
- if resp.StatusCode == http.StatusOK {
- return route.UnmarshalResult(resp.Body)
- }
- return nil, r.statusCodeToError("add route", resp)
- }
- func (r *RESTClient) sendRequest(method string, url url.URL, body interface{}) (*http.Response, error) {
- var bodyReader io.Reader
- if body != nil {
- if bodyBytes, err := json.Marshal(body); err != nil {
- return nil, errors.Wrap(err, "failed to serialize json body")
- } else {
- bodyReader = bytes.NewBuffer(bodyBytes)
- }
- }
- req, err := http.NewRequest(method, url.String(), bodyReader)
- if err != nil {
- return nil, errors.Wrapf(err, "can't create %s request", method)
- }
- req.Header.Set("User-Agent", r.userAgent)
- if bodyReader != nil {
- req.Header.Set("Content-Type", jsonContentType)
- }
- req.Header.Add("X-Auth-User-Service-Key", r.authToken)
- req.Header.Add("Accept", "application/json;version=1")
- return r.client.Do(req)
- }
- func parseResponse(reader io.Reader, data interface{}) error {
- // Schema for Tunnelstore responses in the v1 API.
- // Roughly, it's a wrapper around a particular result that adds failures/errors/etc
- var result response
- // First, parse the wrapper and check the API call succeeded
- if err := json.NewDecoder(reader).Decode(&result); err != nil {
- return errors.Wrap(err, "failed to decode response")
- }
- if err := result.checkErrors(); err != nil {
- return err
- }
- if !result.Success {
- return ErrAPINoSuccess
- }
- // At this point we know the API call succeeded, so, parse out the inner
- // result into the datatype provided as a parameter.
- if err := json.Unmarshal(result.Result, &data); err != nil {
- return errors.Wrap(err, "the Cloudflare API response was an unexpected type")
- }
- return nil
- }
- func unmarshalTunnel(reader io.Reader) (*Tunnel, error) {
- var tunnel Tunnel
- err := parseResponse(reader, &tunnel)
- return &tunnel, err
- }
- type response struct {
- Success bool `json:"success,omitempty"`
- Errors []apiErr `json:"errors,omitempty"`
- Messages []string `json:"messages,omitempty"`
- Result json.RawMessage `json:"result,omitempty"`
- }
- func (r *response) checkErrors() error {
- if len(r.Errors) == 0 {
- return nil
- }
- if len(r.Errors) == 1 {
- return r.Errors[0]
- }
- var messages string
- for _, e := range r.Errors {
- messages += fmt.Sprintf("%s; ", e)
- }
- return fmt.Errorf("API errors: %s", messages)
- }
- type apiErr struct {
- Code json.Number `json:"code,omitempty"`
- Message string `json:"message,omitempty"`
- }
- func (e apiErr) Error() string {
- return fmt.Sprintf("code: %v, reason: %s", e.Code, e.Message)
- }
- func (r *RESTClient) statusCodeToError(op string, resp *http.Response) error {
- if resp.Header.Get("Content-Type") == "application/json" {
- var errorsResp response
- if json.NewDecoder(resp.Body).Decode(&errorsResp) == nil {
- if err := errorsResp.checkErrors(); err != nil {
- return errors.Errorf("Failed to %s: %s", op, err)
- }
- }
- }
- switch resp.StatusCode {
- case http.StatusOK:
- return nil
- case http.StatusBadRequest:
- return ErrBadRequest
- case http.StatusUnauthorized, http.StatusForbidden:
- return ErrUnauthorized
- case http.StatusNotFound:
- return ErrNotFound
- }
- return errors.Errorf("API call to %s failed with status %d: %s", op,
- resp.StatusCode, http.StatusText(resp.StatusCode))
- }
|