forked from TrueCloudLab/lego
bluecat: rewrite provider implementation (#1627)
This commit is contained in:
parent
6641400f41
commit
6b8d5a0afc
7 changed files with 466 additions and 323 deletions
|
@ -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 -->
|
||||||
|
|
|
@ -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
|
||||||
|
// It should have the scheme, hostname, and port (if required) of the authoritative Bluecat BAM server.
|
||||||
// The REST endpoint will be appended.
|
// The REST endpoint will be appended.
|
||||||
// In addition, the Configuration name and external DNS View Name must be passed in BLUECAT_CONFIG_NAME and BLUECAT_DNS_VIEW.
|
// - 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.
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
313
providers/dns/bluecat/internal/client.go
Normal file
313
providers/dns/bluecat/internal/client.go
Normal 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)
|
||||||
|
}
|
41
providers/dns/bluecat/internal/client_test.go
Normal file
41
providers/dns/bluecat/internal/client_test.go
Normal 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)
|
||||||
|
}
|
29
providers/dns/bluecat/internal/types.go
Normal file
29
providers/dns/bluecat/internal/types.go
Normal 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)
|
||||||
|
}
|
Loading…
Reference in a new issue