lego/providers/dns/bluecat/internal/client.go
2023-05-05 09:49:38 +02:00

271 lines
7 KiB
Go

package internal
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
// Object types.
const (
ConfigType = "Configuration"
ViewType = "View"
ZoneType = "Zone"
TXTType = "TXTRecord"
)
const authorizationHeader = "Authorization"
type Client struct {
username string
password string
tokenExp *regexp.Regexp
baseURL *url.URL
HTTPClient *http.Client
}
func NewClient(baseURL string, username, password string) *Client {
bu, _ := url.Parse(baseURL)
return &Client{
username: username,
password: password,
tokenExp: regexp.MustCompile("BAMAuthToken: [^ ]+"),
baseURL: bu,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
}
}
// Deploy the DNS config for the specified entity to the authoritative servers.
// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/quickDeploy/9.5.0
func (c *Client) Deploy(ctx context.Context, entityID uint) error {
endpoint := c.createEndpoint("quickDeploy")
q := endpoint.Query()
q.Set("entityId", strconv.FormatUint(uint64(entityID), 10))
endpoint.RawQuery = q.Encode()
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return err
}
resp, err := c.doAuthenticated(ctx, req)
if err != nil {
return errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
// The API doc says that 201 is expected but in the reality 200 is return.
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
}
return nil
}
// AddEntity A generic method for adding configurations, DNS zones, and DNS resource records.
// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/addEntity/9.5.0
func (c *Client) AddEntity(ctx context.Context, parentID uint, entity Entity) (uint64, error) {
endpoint := c.createEndpoint("addEntity")
q := endpoint.Query()
q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
endpoint.RawQuery = q.Encode()
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, entity)
if err != nil {
return 0, err
}
resp, err := c.doAuthenticated(ctx, req)
if err != nil {
return 0, errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return 0, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
}
raw, _ := io.ReadAll(resp.Body)
// addEntity responds only with body text containing the ID of the created record
addTxtResp := string(raw)
id, err := strconv.ParseUint(addTxtResp, 10, 64)
if err != nil {
return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp)
}
return id, nil
}
// GetEntityByName Returns objects from the database referenced by their database ID and with its properties fields populated.
// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/getEntityById/9.5.0
func (c *Client) GetEntityByName(ctx context.Context, parentID uint, name, objType string) (*EntityResponse, error) {
endpoint := c.createEndpoint("getEntityByName")
q := endpoint.Query()
q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
q.Set("name", name)
q.Set("type", objType)
endpoint.RawQuery = q.Encode()
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := c.doAuthenticated(ctx, req)
if err != nil {
return nil, errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
}
var entity EntityResponse
err = json.Unmarshal(raw, &entity)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
}
return &entity, nil
}
// Delete Deletes an object using the generic delete method.
// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/DELETE/v1/delete/9.5.0
func (c *Client) Delete(ctx context.Context, objectID uint) error {
endpoint := c.createEndpoint("delete")
q := endpoint.Query()
q.Set("objectId", strconv.FormatUint(uint64(objectID), 10))
endpoint.RawQuery = q.Encode()
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
resp, err := c.doAuthenticated(ctx, req)
if err != nil {
return errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
// The API doc says that 204 is expected but in the reality 200 is returned.
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
}
return nil
}
// LookupViewID Find the DNS view with the given name within.
func (c *Client) LookupViewID(ctx context.Context, configName, viewName string) (uint, error) {
// Lookup the entity ID of the configuration named in our properties.
conf, err := c.GetEntityByName(ctx, 0, configName, ConfigType)
if err != nil {
return 0, err
}
view, err := c.GetEntityByName(ctx, conf.ID, viewName, ViewType)
if err != nil {
return 0, err
}
return view.ID, nil
}
// LookupParentZoneID Return the entityId of the parent zone by recursing from the root view.
// Also return the simple name of the host.
func (c *Client) LookupParentZoneID(ctx context.Context, viewID uint, fqdn string) (uint, string, error) {
if fqdn == "" {
return viewID, "", nil
}
zones := strings.Split(strings.Trim(fqdn, "."), ".")
name := zones[0]
parentViewID := viewID
for i := len(zones) - 1; i > -1; i-- {
zone, err := c.GetEntityByName(ctx, parentViewID, zones[i], ZoneType)
if err != nil {
return 0, "", fmt.Errorf("could not find zone named %s: %w", name, err)
}
if zone == nil || zone.ID == 0 {
break
}
if i > 0 {
name = strings.Join(zones[0:i], ".")
}
parentViewID = zone.ID
}
return parentViewID, name, nil
}
func (c *Client) createEndpoint(resource string) *url.URL {
return c.baseURL.JoinPath("Services", "REST", "v1", resource)
}
func (c *Client) doAuthenticated(ctx context.Context, req *http.Request) (*http.Response, error) {
tok := getToken(ctx)
if tok != "" {
req.Header.Set(authorizationHeader, tok)
}
return c.HTTPClient.Do(req)
}
func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
if payload != nil {
err := json.NewEncoder(buf).Encode(payload)
if err != nil {
return nil, fmt.Errorf("failed to create request JSON body: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
return req, nil
}