From 6b8d5a0afc57565361921f38a4fc6c8617caa508 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Wed, 20 Apr 2022 01:00:57 +0200 Subject: [PATCH] bluecat: rewrite provider implementation (#1627) --- docs/content/dns/zz_gen_bluecat.md | 17 +- providers/dns/bluecat/bluecat.go | 126 ++++--- providers/dns/bluecat/bluecat.toml | 13 +- providers/dns/bluecat/client.go | 250 -------------- providers/dns/bluecat/internal/client.go | 313 ++++++++++++++++++ providers/dns/bluecat/internal/client_test.go | 41 +++ providers/dns/bluecat/internal/types.go | 29 ++ 7 files changed, 466 insertions(+), 323 deletions(-) delete mode 100644 providers/dns/bluecat/client.go create mode 100644 providers/dns/bluecat/internal/client.go create mode 100644 providers/dns/bluecat/internal/client_test.go create mode 100644 providers/dns/bluecat/internal/types.go diff --git a/docs/content/dns/zz_gen_bluecat.md b/docs/content/dns/zz_gen_bluecat.md index 3e53edc3..381819ca 100644 --- a/docs/content/dns/zz_gen_bluecat.md +++ b/docs/content/dns/zz_gen_bluecat.md @@ -18,9 +18,17 @@ Configuration for [Bluecat](https://www.bluecatnetworks.com). - Code: `bluecat` -{{% notice note %}} -_Please contribute by adding a CLI example._ -{{% /notice %}} +Here is an example bash command using the Bluecat provider: + +```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) diff --git a/providers/dns/bluecat/bluecat.go b/providers/dns/bluecat/bluecat.go index afc864dd..75ee3c48 100644 --- a/providers/dns/bluecat/bluecat.go +++ b/providers/dns/bluecat/bluecat.go @@ -2,23 +2,15 @@ package bluecat import ( - "encoding/json" "errors" "fmt" - "io" "net/http" - "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" -) - -const ( - configType = "Configuration" - viewType = "View" - zoneType = "Zone" - txtType = "TXTRecord" + "github.com/go-acme/lego/v4/providers/dns/bluecat/internal" ) // Environment variables names. @@ -30,6 +22,7 @@ const ( EnvPassword = envNamespace + "PASSWORD" EnvConfigName = envNamespace + "CONFIG_NAME" EnvDNSView = envNamespace + "DNS_VIEW" + EnvDebug = envNamespace + "DEBUG" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -48,6 +41,7 @@ type Config struct { PollingInterval time.Duration TTL int HTTPClient *http.Client + Debug bool } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -59,20 +53,24 @@ func NewDefaultConfig() *Config { HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, + Debug: env.GetOrDefaultBool(EnvDebug, false), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config - token string + client *internal.Client } // 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. -// BLUECAT_SERVER_URL should have the scheme, hostname, and port (if required) of the authoritative Bluecat BAM server. -// 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. +// Credentials must be passed in the environment variables: +// - 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. +// - BLUECAT_USER_NAME and BLUECAT_PASSWORD +// - BLUECAT_CONFIG_NAME (the Configuration name) +// - BLUECAT_DNS_VIEW (external DNS View Name) func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvServerURL, EnvUserName, EnvPassword, EnvConfigName, EnvDNSView) if err != nil { @@ -99,114 +97,104 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { 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 -// This will *not* create a subzone to contain the TXT record, -// so make sure the FQDN specified is within an extant zone. +// This will *not* create a sub-zone to contain the TXT record, +// so make sure the FQDN specified is within an existent zone. func (d *DNSProvider) Present(domain, token, keyAuth string) error { fqdn, value := dns01.GetRecord(domain, keyAuth) - err := d.login() + err := d.client.Login(d.config.UserName, d.config.Password) 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 { - 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 { - return err + return fmt.Errorf("bluecat: lookupParentZoneID: %w", err) } - queryArgs := map[string]string{ - "parentId": strconv.FormatUint(uint64(parentZoneID), 10), + if d.config.Debug { + log.Infof("fqdn: %s; viewID: %d; ZoneID: %d; zone: %s", fqdn, viewID, parentZoneID, name) } - body := bluecatEntity{ + txtRecord := internal.Entity{ Name: name, - Type: "TXTRecord", + Type: internal.TXTType, 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 { - return 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) + return fmt.Errorf("bluecat: add TXT record: %w", err) } - err = d.deploy(parentZoneID) + err = d.client.Deploy(parentZoneID) 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. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, _ := dns01.GetRecord(domain, keyAuth) - err := d.login() + err := d.client.Login(d.config.UserName, d.config.Password) 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 { - 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 { - return err + return fmt.Errorf("bluecat: lookupParentZoneID: %w", err) } - queryArgs := map[string]string{ - "parentId": strconv.FormatUint(uint64(parentID), 10), - "name": name, - "type": txtType, - } - - resp, err := d.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs) + txtRecord, err := d.client.GetEntityByName(parentZoneID, name, internal.TXTType) if err != nil { - return err + return fmt.Errorf("bluecat: get TXT record: %w", err) } - defer resp.Body.Close() - var txtRec entityResponse - err = json.NewDecoder(resp.Body).Decode(&txtRec) + err = d.client.Delete(txtRecord.ID) if err != nil { - return fmt.Errorf("bluecat: %w", err) - } - queryArgs = map[string]string{ - "objectId": strconv.FormatUint(uint64(txtRec.ID), 10), + return fmt.Errorf("bluecat: delete TXT record: %w", err) } - resp, err = d.sendRequest(http.MethodDelete, http.MethodDelete, nil, queryArgs) + err = d.client.Deploy(parentZoneID) 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 { - 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. diff --git a/providers/dns/bluecat/bluecat.toml b/providers/dns/bluecat/bluecat.toml index a6a91fa0..091d312c 100644 --- a/providers/dns/bluecat/bluecat.toml +++ b/providers/dns/bluecat/bluecat.toml @@ -4,7 +4,15 @@ URL = "https://www.bluecatnetworks.com" Code = "bluecat" 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.Credentials] @@ -18,3 +26,6 @@ Example = '''''' BLUECAT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" BLUECAT_TTL = "The TTL of the TXT record used for the DNS challenge" BLUECAT_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/REST-API/9.1.0" diff --git a/providers/dns/bluecat/client.go b/providers/dns/bluecat/client.go deleted file mode 100644 index 5f6aff3f..00000000 --- a/providers/dns/bluecat/client.go +++ /dev/null @@ -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 -} diff --git a/providers/dns/bluecat/internal/client.go b/providers/dns/bluecat/internal/client.go new file mode 100644 index 00000000..bb61f9da --- /dev/null +++ b/providers/dns/bluecat/internal/client.go @@ -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) +} diff --git a/providers/dns/bluecat/internal/client_test.go b/providers/dns/bluecat/internal/client_test.go new file mode 100644 index 00000000..072f6254 --- /dev/null +++ b/providers/dns/bluecat/internal/client_test.go @@ -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) +} diff --git a/providers/dns/bluecat/internal/types.go b/providers/dns/bluecat/internal/types.go new file mode 100644 index 00000000..b3b7b412 --- /dev/null +++ b/providers/dns/bluecat/internal/types.go @@ -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) +}