bluecat: rewrite provider implementation (#1627)

This commit is contained in:
Ludovic Fernandez 2022-04-20 01:00:57 +02:00 committed by GitHub
parent 6641400f41
commit 6b8d5a0afc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 466 additions and 323 deletions

View file

@ -18,9 +18,17 @@ Configuration for [Bluecat](https://www.bluecatnetworks.com).
- Code: `bluecat` - Code: `bluecat`
{{% notice note %}} Here is an example bash command using the Bluecat provider:
_Please contribute by adding a CLI example._
{{% /notice %}} ```bash
BLUECAT_PASSWORD=mypassword \
BLUECAT_DNS_VIEW=myview \
BLUECAT_USER_NAME=myusername \
BLUECAT_CONFIG_NAME=myconfig \
BLUECAT_SERVER_URL=https://bam.example.com \
BLUECAT_TTL=30 \
lego --email myemail@example.com --dns bluecat --domains my.example.org run
```
@ -54,6 +62,9 @@ More information [here](/lego/dns/#configuration-and-credentials).
## More information
- [API documentation](https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/REST-API/9.1.0)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. --> <!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/bluecat/bluecat.toml --> <!-- providers/dns/bluecat/bluecat.toml -->

View file

@ -2,23 +2,15 @@
package bluecat package bluecat
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/config/env"
) "github.com/go-acme/lego/v4/providers/dns/bluecat/internal"
const (
configType = "Configuration"
viewType = "View"
zoneType = "Zone"
txtType = "TXTRecord"
) )
// Environment variables names. // Environment variables names.
@ -30,6 +22,7 @@ const (
EnvPassword = envNamespace + "PASSWORD" EnvPassword = envNamespace + "PASSWORD"
EnvConfigName = envNamespace + "CONFIG_NAME" EnvConfigName = envNamespace + "CONFIG_NAME"
EnvDNSView = envNamespace + "DNS_VIEW" EnvDNSView = envNamespace + "DNS_VIEW"
EnvDebug = envNamespace + "DEBUG"
EnvTTL = envNamespace + "TTL" EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@ -48,6 +41,7 @@ type Config struct {
PollingInterval time.Duration PollingInterval time.Duration
TTL int TTL int
HTTPClient *http.Client HTTPClient *http.Client
Debug bool
} }
// NewDefaultConfig returns a default configuration for the DNSProvider. // NewDefaultConfig returns a default configuration for the DNSProvider.
@ -59,20 +53,24 @@ func NewDefaultConfig() *Config {
HTTPClient: &http.Client{ HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
}, },
Debug: env.GetOrDefaultBool(EnvDebug, false),
} }
} }
// DNSProvider implements the challenge.Provider interface. // DNSProvider implements the challenge.Provider interface.
type DNSProvider struct { type DNSProvider struct {
config *Config config *Config
token string client *internal.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for Bluecat DNS. // NewDNSProvider returns a DNSProvider instance configured for Bluecat DNS.
// Credentials must be passed in the environment variables: BLUECAT_SERVER_URL, BLUECAT_USER_NAME and BLUECAT_PASSWORD. // Credentials must be passed in the environment variables:
// BLUECAT_SERVER_URL should have the scheme, hostname, and port (if required) of the authoritative Bluecat BAM server. // - BLUECAT_SERVER_URL
// The REST endpoint will be appended. // It should have the scheme, hostname, and port (if required) of the authoritative Bluecat BAM server.
// In addition, the Configuration name and external DNS View Name must be passed in BLUECAT_CONFIG_NAME and BLUECAT_DNS_VIEW. // The REST endpoint will be appended.
// - BLUECAT_USER_NAME and BLUECAT_PASSWORD
// - BLUECAT_CONFIG_NAME (the Configuration name)
// - BLUECAT_DNS_VIEW (external DNS View Name)
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvServerURL, EnvUserName, EnvPassword, EnvConfigName, EnvDNSView) values, err := env.Get(EnvServerURL, EnvUserName, EnvPassword, EnvConfigName, EnvDNSView)
if err != nil { if err != nil {
@ -99,114 +97,104 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("bluecat: credentials missing") return nil, errors.New("bluecat: credentials missing")
} }
return &DNSProvider{config: config}, nil client := internal.NewClient(config.BaseURL)
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
return &DNSProvider{config: config, client: client}, nil
} }
// Present creates a TXT record using the specified parameters // Present creates a TXT record using the specified parameters
// This will *not* create a subzone to contain the TXT record, // This will *not* create a sub-zone to contain the TXT record,
// so make sure the FQDN specified is within an extant zone. // so make sure the FQDN specified is within an existent zone.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value := dns01.GetRecord(domain, keyAuth) fqdn, value := dns01.GetRecord(domain, keyAuth)
err := d.login() err := d.client.Login(d.config.UserName, d.config.Password)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: login: %w", err)
} }
viewID, err := d.lookupViewID(d.config.DNSView) viewID, err := d.client.LookupViewID(d.config.ConfigName, d.config.DNSView)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: lookupViewID: %w", err)
} }
parentZoneID, name, err := d.lookupParentZoneID(viewID, fqdn) parentZoneID, name, err := d.client.LookupParentZoneID(viewID, fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: lookupParentZoneID: %w", err)
} }
queryArgs := map[string]string{ if d.config.Debug {
"parentId": strconv.FormatUint(uint64(parentZoneID), 10), log.Infof("fqdn: %s; viewID: %d; ZoneID: %d; zone: %s", fqdn, viewID, parentZoneID, name)
} }
body := bluecatEntity{ txtRecord := internal.Entity{
Name: name, Name: name,
Type: "TXTRecord", Type: internal.TXTType,
Properties: fmt.Sprintf("ttl=%d|absoluteName=%s|txt=%s|", d.config.TTL, fqdn, value), Properties: fmt.Sprintf("ttl=%d|absoluteName=%s|txt=%s|", d.config.TTL, fqdn, value),
} }
resp, err := d.sendRequest(http.MethodPost, "addEntity", body, queryArgs) _, err = d.client.AddEntity(parentZoneID, txtRecord)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: add TXT record: %w", err)
}
defer resp.Body.Close()
addTxtBytes, _ := io.ReadAll(resp.Body)
addTxtResp := string(addTxtBytes)
// addEntity responds only with body text containing the ID of the created record
_, err = strconv.ParseUint(addTxtResp, 10, 64)
if err != nil {
return fmt.Errorf("bluecat: addEntity request failed: %s", addTxtResp)
} }
err = d.deploy(parentZoneID) err = d.client.Deploy(parentZoneID)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: deploy: %w", err)
} }
return d.logout() err = d.client.Logout()
if err != nil {
return fmt.Errorf("bluecat: logout: %w", err)
}
return nil
} }
// CleanUp removes the TXT record matching the specified parameters. // CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _ := dns01.GetRecord(domain, keyAuth) fqdn, _ := dns01.GetRecord(domain, keyAuth)
err := d.login() err := d.client.Login(d.config.UserName, d.config.Password)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: login: %w", err)
} }
viewID, err := d.lookupViewID(d.config.DNSView) viewID, err := d.client.LookupViewID(d.config.ConfigName, d.config.DNSView)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: lookupViewID: %w", err)
} }
parentID, name, err := d.lookupParentZoneID(viewID, fqdn) parentZoneID, name, err := d.client.LookupParentZoneID(viewID, fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: lookupParentZoneID: %w", err)
} }
queryArgs := map[string]string{ txtRecord, err := d.client.GetEntityByName(parentZoneID, name, internal.TXTType)
"parentId": strconv.FormatUint(uint64(parentID), 10),
"name": name,
"type": txtType,
}
resp, err := d.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: get TXT record: %w", err)
} }
defer resp.Body.Close()
var txtRec entityResponse err = d.client.Delete(txtRecord.ID)
err = json.NewDecoder(resp.Body).Decode(&txtRec)
if err != nil { if err != nil {
return fmt.Errorf("bluecat: %w", err) return fmt.Errorf("bluecat: delete TXT record: %w", err)
}
queryArgs = map[string]string{
"objectId": strconv.FormatUint(uint64(txtRec.ID), 10),
} }
resp, err = d.sendRequest(http.MethodDelete, http.MethodDelete, nil, queryArgs) err = d.client.Deploy(parentZoneID)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: deploy: %w", err)
} }
defer resp.Body.Close()
err = d.deploy(parentID) err = d.client.Logout()
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: logout: %w", err)
} }
return d.logout() return nil
} }
// Timeout returns the timeout and interval to use when checking for DNS propagation. // Timeout returns the timeout and interval to use when checking for DNS propagation.

View file

@ -4,7 +4,15 @@ URL = "https://www.bluecatnetworks.com"
Code = "bluecat" Code = "bluecat"
Since = "v0.5.0" Since = "v0.5.0"
Example = '''''' Example = '''
BLUECAT_PASSWORD=mypassword \
BLUECAT_DNS_VIEW=myview \
BLUECAT_USER_NAME=myusername \
BLUECAT_CONFIG_NAME=myconfig \
BLUECAT_SERVER_URL=https://bam.example.com \
BLUECAT_TTL=30 \
lego --email myemail@example.com --dns bluecat --domains my.example.org run
'''
[Configuration] [Configuration]
[Configuration.Credentials] [Configuration.Credentials]
@ -18,3 +26,6 @@ Example = ''''''
BLUECAT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" BLUECAT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
BLUECAT_TTL = "The TTL of the TXT record used for the DNS challenge" BLUECAT_TTL = "The TTL of the TXT record used for the DNS challenge"
BLUECAT_HTTP_TIMEOUT = "API request timeout" BLUECAT_HTTP_TIMEOUT = "API request timeout"
[Links]
API = "https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/REST-API/9.1.0"

View file

@ -1,250 +0,0 @@
package bluecat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
)
// JSON body for Bluecat entity requests and responses.
type bluecatEntity struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Properties string `json:"properties"`
}
type entityResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Properties string `json:"properties"`
}
// Starts a new Bluecat API Session.
// Authenticates using customerName, userName, password,
// and receives a token to be used in for subsequent requests.
func (d *DNSProvider) login() error {
queryArgs := map[string]string{
"username": d.config.UserName,
"password": d.config.Password,
}
resp, err := d.sendRequest(http.MethodGet, "login", nil, queryArgs)
if err != nil {
return err
}
defer resp.Body.Close()
authBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("bluecat: %w", err)
}
authResp := string(authBytes)
if strings.Contains(authResp, "Authentication Error") {
msg := strings.Trim(authResp, "\"")
return fmt.Errorf("bluecat: request failed: %s", msg)
}
// Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username"
d.token = regexp.MustCompile("BAMAuthToken: [^ ]+").FindString(authResp)
return nil
}
// Destroys Bluecat Session.
func (d *DNSProvider) logout() error {
if d.token == "" {
// nothing to do
return nil
}
resp, err := d.sendRequest(http.MethodGet, "logout", nil, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("bluecat: request failed to delete session with HTTP status code %d", resp.StatusCode)
}
authBytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
authResp := string(authBytes)
if !strings.Contains(authResp, "successfully") {
msg := strings.Trim(authResp, "\"")
return fmt.Errorf("bluecat: request failed to delete session: %s", msg)
}
d.token = ""
return nil
}
// Lookup the entity ID of the configuration named in our properties.
func (d *DNSProvider) lookupConfID() (uint, error) {
queryArgs := map[string]string{
"parentId": strconv.Itoa(0),
"name": d.config.ConfigName,
"type": configType,
}
resp, err := d.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var conf entityResponse
err = json.NewDecoder(resp.Body).Decode(&conf)
if err != nil {
return 0, fmt.Errorf("bluecat: %w", err)
}
return conf.ID, nil
}
// Find the DNS view with the given name within.
func (d *DNSProvider) lookupViewID(viewName string) (uint, error) {
confID, err := d.lookupConfID()
if err != nil {
return 0, err
}
queryArgs := map[string]string{
"parentId": strconv.FormatUint(uint64(confID), 10),
"name": viewName,
"type": viewType,
}
resp, err := d.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var view entityResponse
err = json.NewDecoder(resp.Body).Decode(&view)
if err != nil {
return 0, fmt.Errorf("bluecat: %w", err)
}
return view.ID, nil
}
// Return the entityId of the parent zone by recursing from the root view.
// Also return the simple name of the host.
func (d *DNSProvider) lookupParentZoneID(viewID uint, fqdn string) (uint, string, error) {
parentViewID := viewID
name := ""
if fqdn != "" {
zones := strings.Split(strings.Trim(fqdn, "."), ".")
last := len(zones) - 1
name = zones[0]
for i := last; i > -1; i-- {
zoneID, err := d.getZone(parentViewID, zones[i])
if err != nil || zoneID == 0 {
return parentViewID, name, err
}
if i > 0 {
name = strings.Join(zones[0:i], ".")
}
parentViewID = zoneID
}
}
return parentViewID, name, nil
}
// Get the DNS zone with the specified name under the parentId.
func (d *DNSProvider) getZone(parentID uint, name string) (uint, error) {
queryArgs := map[string]string{
"parentId": strconv.FormatUint(uint64(parentID), 10),
"name": name,
"type": zoneType,
}
resp, err := d.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs)
// Return an empty zone if the named zone doesn't exist
if resp != nil && resp.StatusCode == http.StatusNotFound {
return 0, fmt.Errorf("bluecat: could not find zone named %s", name)
}
if err != nil {
return 0, err
}
defer resp.Body.Close()
var zone entityResponse
err = json.NewDecoder(resp.Body).Decode(&zone)
if err != nil {
return 0, fmt.Errorf("bluecat: %w", err)
}
return zone.ID, nil
}
// Deploy the DNS config for the specified entity to the authoritative servers.
func (d *DNSProvider) deploy(entityID uint) error {
queryArgs := map[string]string{
"entityId": strconv.FormatUint(uint64(entityID), 10),
}
resp, err := d.sendRequest(http.MethodPost, "quickDeploy", nil, queryArgs)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
// Send a REST request, using query parameters specified.
// The Authorization header will be set if we have an active auth token.
func (d *DNSProvider) sendRequest(method, resource string, payload interface{}, queryArgs map[string]string) (*http.Response, error) {
url := fmt.Sprintf("%s/Services/REST/v1/%s", d.config.BaseURL, resource)
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("bluecat: %w", err)
}
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("bluecat: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if len(d.token) > 0 {
req.Header.Set("Authorization", d.token)
}
// Add all query parameters
q := req.URL.Query()
for argName, argVal := range queryArgs {
q.Add(argName, argVal)
}
req.URL.RawQuery = q.Encode()
resp, err := d.config.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("bluecat: %w", err)
}
if resp.StatusCode >= 400 {
errBytes, _ := io.ReadAll(resp.Body)
errResp := string(errBytes)
return nil, fmt.Errorf("bluecat: request failed with HTTP status code %d\n Full message: %s",
resp.StatusCode, errResp)
}
return resp, nil
}

View file

@ -0,0 +1,313 @@
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)
}

View file

@ -0,0 +1,41 @@
package internal
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_LookupParentZoneID(t *testing.T) {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
client := NewClient(server.URL)
mux.HandleFunc("/Services/REST/v1/getEntityByName", func(rw http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
if query.Get("name") == "com" {
_ = json.NewEncoder(rw).Encode(EntityResponse{
ID: 2,
Name: "com",
Type: ZoneType,
Properties: "test",
})
return
}
http.Error(rw, "{}", http.StatusOK)
})
parentID, name, err := client.LookupParentZoneID(2, "foo.example.com")
require.NoError(t, err)
assert.EqualValues(t, 2, parentID)
assert.Equal(t, "foo.example", name)
}

View file

@ -0,0 +1,29 @@
package internal
import "fmt"
// Entity JSON body for Bluecat entity requests.
type Entity struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Properties string `json:"properties"`
}
// EntityResponse JSON body for Bluecat entity responses.
type EntityResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Properties string `json:"properties"`
}
type APIError struct {
StatusCode int
Resource string
Message string
}
func (a APIError) Error() string {
return fmt.Sprintf("resource: %s, status code: %d, message: %s", a.Resource, a.StatusCode, a.Message)
}