lego/providers/dns/bluecat/internal/client.go
2022-04-20 01:00:57 +02:00

313 lines
7.7 KiB
Go

package internal
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
// Object types.
const (
ConfigType = "Configuration"
ViewType = "View"
ZoneType = "Zone"
TXTType = "TXTRecord"
)
type Client struct {
HTTPClient *http.Client
baseURL string
token string
tokenExp *regexp.Regexp
}
func NewClient(baseURL string) *Client {
return &Client{
HTTPClient: &http.Client{Timeout: 30 * time.Second},
baseURL: baseURL,
tokenExp: regexp.MustCompile("BAMAuthToken: [^ ]+"),
}
}
// Login Logs in as API user.
// Authenticates and receives a token to be used in for subsequent requests.
// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/login/9.1.0
func (c *Client) Login(username, password string) error {
queryArgs := map[string]string{
"username": username,
"password": password,
}
resp, err := c.sendRequest(http.MethodGet, "login", nil, queryArgs)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return &APIError{
StatusCode: resp.StatusCode,
Resource: "login",
Message: string(data),
}
}
authBytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
authResp := string(authBytes)
if strings.Contains(authResp, "Authentication Error") {
return fmt.Errorf("request failed: %s", strings.Trim(authResp, `"`))
}
// Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username"
c.token = c.tokenExp.FindString(authResp)
return nil
}
// Logout Logs out of the current API session.
// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/logout/9.1.0
func (c *Client) Logout() error {
if c.token == "" {
// nothing to do
return nil
}
resp, err := c.sendRequest(http.MethodGet, "logout", nil, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return &APIError{
StatusCode: resp.StatusCode,
Resource: "logout",
Message: string(data),
}
}
authBytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
authResp := string(authBytes)
if !strings.Contains(authResp, "successfully") {
return fmt.Errorf("request failed to delete session: %s", strings.Trim(authResp, `"`))
}
c.token = ""
return nil
}
// Deploy the DNS config for the specified entity to the authoritative servers.
// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/POST/v1/quickDeploy/9.1.0
func (c *Client) Deploy(entityID uint) error {
queryArgs := map[string]string{
"entityId": strconv.FormatUint(uint64(entityID), 10),
}
resp, err := c.sendRequest(http.MethodPost, "quickDeploy", nil, queryArgs)
if err != nil {
return err
}
defer 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 {
data, _ := io.ReadAll(resp.Body)
return &APIError{
StatusCode: resp.StatusCode,
Resource: "quickDeploy",
Message: string(data),
}
}
return nil
}
// AddEntity A generic method for adding configurations, DNS zones, and DNS resource records.
// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/POST/v1/addEntity/9.1.0
func (c *Client) AddEntity(parentID uint, entity Entity) (uint64, error) {
queryArgs := map[string]string{
"parentId": strconv.FormatUint(uint64(parentID), 10),
}
resp, err := c.sendRequest(http.MethodPost, "addEntity", entity, queryArgs)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return 0, &APIError{
StatusCode: resp.StatusCode,
Resource: "addEntity",
Message: string(data),
}
}
addTxtBytes, _ := io.ReadAll(resp.Body)
// addEntity responds only with body text containing the ID of the created record
addTxtResp := string(addTxtBytes)
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-API-Guide/GET/v1/getEntityById/9.1.0
func (c *Client) GetEntityByName(parentID uint, name, objType string) (*EntityResponse, error) {
queryArgs := map[string]string{
"parentId": strconv.FormatUint(uint64(parentID), 10),
"name": name,
"type": objType,
}
resp, err := c.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return nil, &APIError{
StatusCode: resp.StatusCode,
Resource: "getEntityByName",
Message: string(data),
}
}
var txtRec EntityResponse
if err = json.NewDecoder(resp.Body).Decode(&txtRec); err != nil {
return nil, fmt.Errorf("JSON decode: %w", err)
}
return &txtRec, nil
}
// Delete Deletes an object using the generic delete method.
// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/DELETE/v1/delete/9.1.0
func (c *Client) Delete(objectID uint) error {
queryArgs := map[string]string{
"objectId": strconv.FormatUint(uint64(objectID), 10),
}
resp, err := c.sendRequest(http.MethodDelete, "delete", nil, queryArgs)
if err != nil {
return err
}
defer resp.Body.Close()
// The API doc says that 204 is expected but in the reality 200 is return.
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return &APIError{
StatusCode: resp.StatusCode,
Resource: "delete",
Message: string(data),
}
}
return nil
}
// LookupViewID Find the DNS view with the given name within.
func (c *Client) LookupViewID(configName, viewName string) (uint, error) {
// Lookup the entity ID of the configuration named in our properties.
conf, err := c.GetEntityByName(0, configName, ConfigType)
if err != nil {
return 0, err
}
view, err := c.GetEntityByName(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(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(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
}
// Send a REST request, using query parameters specified.
// The Authorization header will be set if we have an active auth token.
func (c *Client) sendRequest(method, resource string, payload interface{}, queryParams map[string]string) (*http.Response, error) {
url := fmt.Sprintf("%s/Services/REST/v1/%s", c.baseURL, resource)
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", c.token)
}
q := req.URL.Query()
for k, v := range queryParams {
q.Set(k, v)
}
req.URL.RawQuery = q.Encode()
return c.HTTPClient.Do(req)
}