client.go raw

   1  package internal
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"encoding/json"
   7  	"fmt"
   8  	"io"
   9  	"net/http"
  10  	"net/url"
  11  	"regexp"
  12  	"strconv"
  13  	"strings"
  14  	"time"
  15  
  16  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  17  )
  18  
  19  // Object types.
  20  const (
  21  	ConfigType = "Configuration"
  22  	ViewType   = "View"
  23  	ZoneType   = "Zone"
  24  	TXTType    = "TXTRecord"
  25  )
  26  
  27  const authorizationHeader = "Authorization"
  28  
  29  type Client struct {
  30  	username string
  31  	password string
  32  
  33  	tokenExp *regexp.Regexp
  34  
  35  	baseURL    *url.URL
  36  	HTTPClient *http.Client
  37  }
  38  
  39  func NewClient(baseURL, username, password string) *Client {
  40  	bu, _ := url.Parse(baseURL)
  41  
  42  	return &Client{
  43  		username:   username,
  44  		password:   password,
  45  		tokenExp:   regexp.MustCompile("BAMAuthToken: [^ ]+"),
  46  		baseURL:    bu,
  47  		HTTPClient: &http.Client{Timeout: 30 * time.Second},
  48  	}
  49  }
  50  
  51  // Deploy the DNS config for the specified entity to the authoritative servers.
  52  // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/quickDeploy/9.5.0
  53  func (c *Client) Deploy(ctx context.Context, entityID uint) error {
  54  	endpoint := c.createEndpoint("quickDeploy")
  55  
  56  	q := endpoint.Query()
  57  	q.Set("entityId", strconv.FormatUint(uint64(entityID), 10))
  58  	endpoint.RawQuery = q.Encode()
  59  
  60  	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil)
  61  	if err != nil {
  62  		return err
  63  	}
  64  
  65  	resp, err := c.doAuthenticated(ctx, req)
  66  	if err != nil {
  67  		return errutils.NewHTTPDoError(req, err)
  68  	}
  69  
  70  	defer func() { _ = resp.Body.Close() }()
  71  
  72  	// The API doc says that 201 is expected but in the reality 200 is return.
  73  	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
  74  		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
  75  	}
  76  
  77  	return nil
  78  }
  79  
  80  // AddEntity A generic method for adding configurations, DNS zones, and DNS resource records.
  81  // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/addEntity/9.5.0
  82  func (c *Client) AddEntity(ctx context.Context, parentID uint, entity Entity) (uint64, error) {
  83  	endpoint := c.createEndpoint("addEntity")
  84  
  85  	q := endpoint.Query()
  86  	q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
  87  	endpoint.RawQuery = q.Encode()
  88  
  89  	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, entity)
  90  	if err != nil {
  91  		return 0, err
  92  	}
  93  
  94  	resp, err := c.doAuthenticated(ctx, req)
  95  	if err != nil {
  96  		return 0, errutils.NewHTTPDoError(req, err)
  97  	}
  98  
  99  	defer func() { _ = resp.Body.Close() }()
 100  
 101  	if resp.StatusCode != http.StatusOK {
 102  		return 0, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 103  	}
 104  
 105  	raw, _ := io.ReadAll(resp.Body)
 106  
 107  	// addEntity responds only with body text containing the ID of the created record
 108  	addTxtResp := string(raw)
 109  
 110  	id, err := strconv.ParseUint(addTxtResp, 10, 64)
 111  	if err != nil {
 112  		return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp)
 113  	}
 114  
 115  	return id, nil
 116  }
 117  
 118  // GetEntityByName Returns objects from the database referenced by their database ID and with its properties fields populated.
 119  // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/getEntityById/9.5.0
 120  func (c *Client) GetEntityByName(ctx context.Context, parentID uint, name, objType string) (*EntityResponse, error) {
 121  	endpoint := c.createEndpoint("getEntityByName")
 122  
 123  	q := endpoint.Query()
 124  	q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
 125  	q.Set("name", name)
 126  	q.Set("type", objType)
 127  	endpoint.RawQuery = q.Encode()
 128  
 129  	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 130  	if err != nil {
 131  		return nil, err
 132  	}
 133  
 134  	resp, err := c.doAuthenticated(ctx, req)
 135  	if err != nil {
 136  		return nil, errutils.NewHTTPDoError(req, err)
 137  	}
 138  
 139  	defer func() { _ = resp.Body.Close() }()
 140  
 141  	if resp.StatusCode != http.StatusOK {
 142  		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 143  	}
 144  
 145  	raw, err := io.ReadAll(resp.Body)
 146  	if err != nil {
 147  		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 148  	}
 149  
 150  	var entity EntityResponse
 151  
 152  	err = json.Unmarshal(raw, &entity)
 153  	if err != nil {
 154  		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 155  	}
 156  
 157  	return &entity, nil
 158  }
 159  
 160  // Delete Deletes an object using the generic delete method.
 161  // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/DELETE/v1/delete/9.5.0
 162  func (c *Client) Delete(ctx context.Context, objectID uint) error {
 163  	endpoint := c.createEndpoint("delete")
 164  
 165  	q := endpoint.Query()
 166  	q.Set("objectId", strconv.FormatUint(uint64(objectID), 10))
 167  	endpoint.RawQuery = q.Encode()
 168  
 169  	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 170  	if err != nil {
 171  		return err
 172  	}
 173  
 174  	resp, err := c.doAuthenticated(ctx, req)
 175  	if err != nil {
 176  		return errutils.NewHTTPDoError(req, err)
 177  	}
 178  
 179  	defer func() { _ = resp.Body.Close() }()
 180  
 181  	// The API doc says that 204 is expected but in the reality 200 is returned.
 182  	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
 183  		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 184  	}
 185  
 186  	return nil
 187  }
 188  
 189  // LookupViewID Find the DNS view with the given name within.
 190  func (c *Client) LookupViewID(ctx context.Context, configName, viewName string) (uint, error) {
 191  	// Lookup the entity ID of the configuration named in our properties.
 192  	conf, err := c.GetEntityByName(ctx, 0, configName, ConfigType)
 193  	if err != nil {
 194  		return 0, err
 195  	}
 196  
 197  	view, err := c.GetEntityByName(ctx, conf.ID, viewName, ViewType)
 198  	if err != nil {
 199  		return 0, err
 200  	}
 201  
 202  	return view.ID, nil
 203  }
 204  
 205  // LookupParentZoneID returns the entityId of the parent zone by iterating through the root labels.
 206  // Also return the simple name of the host.
 207  func (c *Client) LookupParentZoneID(ctx context.Context, viewID uint, fqdn string) (uint, string, error) {
 208  	if fqdn == "" {
 209  		return viewID, "", nil
 210  	}
 211  
 212  	zones := strings.Split(strings.Trim(fqdn, "."), ".")
 213  
 214  	name := zones[0]
 215  	parentViewID := viewID
 216  
 217  	for i := len(zones) - 1; i > -1; i-- {
 218  		zone, err := c.GetEntityByName(ctx, parentViewID, zones[i], ZoneType)
 219  		if err != nil {
 220  			return 0, "", fmt.Errorf("could not find zone named %s: %w", name, err)
 221  		}
 222  
 223  		if zone == nil || zone.ID == 0 {
 224  			break
 225  		}
 226  
 227  		if i > 0 {
 228  			name = strings.Join(zones[0:i], ".")
 229  		}
 230  
 231  		parentViewID = zone.ID
 232  	}
 233  
 234  	return parentViewID, name, nil
 235  }
 236  
 237  func (c *Client) createEndpoint(resource string) *url.URL {
 238  	return c.baseURL.JoinPath("Services", "REST", "v1", resource)
 239  }
 240  
 241  func (c *Client) doAuthenticated(ctx context.Context, req *http.Request) (*http.Response, error) {
 242  	tok := getToken(ctx)
 243  	if tok != "" {
 244  		req.Header.Set(authorizationHeader, tok)
 245  	}
 246  
 247  	return c.HTTPClient.Do(req)
 248  }
 249  
 250  func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
 251  	buf := new(bytes.Buffer)
 252  
 253  	if payload != nil {
 254  		err := json.NewEncoder(buf).Encode(payload)
 255  		if err != nil {
 256  			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 257  		}
 258  	}
 259  
 260  	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 261  	if err != nil {
 262  		return nil, fmt.Errorf("unable to create request: %w", err)
 263  	}
 264  
 265  	req.Header.Set("Accept", "application/json")
 266  
 267  	if payload != nil {
 268  		req.Header.Set("Content-Type", "application/json")
 269  	}
 270  
 271  	return req, nil
 272  }
 273