diff --git a/cli.go b/cli.go index bd61498e..3878e095 100644 --- a/cli.go +++ b/cli.go @@ -203,6 +203,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w) fmt.Fprintln(w, "\tazure:\tAZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, AZURE_RESOURCE_GROUP") fmt.Fprintln(w, "\tauroradns:\tAURORA_USER_ID, AURORA_KEY, AURORA_ENDPOINT") + fmt.Fprintln(w, "\tbluecat:\tBLUECAT_SERVER_URL, BLUECAT_USER_NAME, BLUECAT_PASSWORD, BLUECAT_CONFIG_NAME, BLUECAT_DNS_VIEW") fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_API_KEY, CLOUDXNS_SECRET_KEY") fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY") fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN") diff --git a/providers/dns/bluecat/bluecat.go b/providers/dns/bluecat/bluecat.go new file mode 100644 index 00000000..92b8a21d --- /dev/null +++ b/providers/dns/bluecat/bluecat.go @@ -0,0 +1,418 @@ +// Package bluecat implements a DNS provider for solving the DNS-01 challenge +// using a self-hosted Bluecat Address Manager. +package bluecat + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/xenolf/lego/acme" + "io/ioutil" +) + +const bluecatUrlTemplate = "%s/Services/REST/v1" +const configType = "Configuration" +const viewType = "View" +const txtType = "TXTRecord" +const zoneType = "Zone" + +type entityResponse struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Properties string `json:"properties"` +} + +// DNSProvider is an implementation of the acme.ChallengeProvider interface that uses +// Bluecat's Address Manager REST API to manage TXT records for a domain. +type DNSProvider struct { + baseUrl string + userName string + password string + configName string + dnsView string + token string + httpClient *http.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 +func NewDNSProvider() (*DNSProvider, error) { + server := os.Getenv("BLUECAT_SERVER_URL") + userName := os.Getenv("BLUECAT_USER_NAME") + password := os.Getenv("BLUECAT_PASSWORD") + configName := os.Getenv("BLUECAT_CONFIG_NAME") + dnsView := os.Getenv("BLUECAT_DNS_VIEW") + httpClient := http.Client{Timeout: time.Duration(30 * time.Second)} + return NewDNSProviderCredentials(server, userName, password, configName, dnsView, httpClient) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Bluecat DNS. +func NewDNSProviderCredentials(server, userName, password, configName, dnsView string, httpClient http.Client) (*DNSProvider, error) { + if server == "" || userName == "" || password == "" || configName == "" || dnsView == "" { + return nil, fmt.Errorf("Bluecat credentials missing") + } + + return &DNSProvider{ + baseUrl: fmt.Sprintf(bluecatUrlTemplate, server), + userName: userName, + password: password, + configName: configName, + dnsView: dnsView, + httpClient: http.DefaultClient, + }, 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/%s", d.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 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.httpClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + errBytes, _ := ioutil.ReadAll(resp.Body) + errResp := string(errBytes) + return nil, fmt.Errorf("Bluecat API request failed with HTTP status code %d\n Full message: %s", + resp.StatusCode, errResp) + } + + return resp, nil +} + +// 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.userName, + "password": d.password, + } + + resp, err := d.sendRequest("GET", "login", nil, queryArgs) + if err != nil { + return err + } + defer resp.Body.Close() + + authBytes, _ := ioutil.ReadAll(resp.Body) + authResp := string(authBytes) + + if strings.Contains(authResp, "Authentication Error") { + msg := strings.Trim(authResp, "\"") + return fmt.Errorf("Bluecat API request failed: %s", msg) + } + // Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username" + re := regexp.MustCompile("BAMAuthToken: [^ ]+") + token := re.FindString(authResp) + d.token = token + return nil +} + +// Destroys Bluecat Session +func (d *DNSProvider) logout() error { + if len(d.token) == 0 { + // nothing to do + return nil + } + + resp, err := d.sendRequest("GET", "logout", nil, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("Bluecat API request failed to delete session with HTTP status code %d", resp.StatusCode) + } else { + authBytes, _ := ioutil.ReadAll(resp.Body) + authResp := string(authBytes) + + if !strings.Contains(authResp, "successfully") { + msg := strings.Trim(authResp, "\"") + return fmt.Errorf("Bluecat API 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.configName, + "type": configType, + } + + resp, err := d.sendRequest("GET", "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, 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": d.dnsView, + "type": viewType, + } + + resp, err := d.sendRequest("GET", "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, 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("GET", "getEntityByName", nil, queryArgs) + // Return an empty zone if the named zone doesn't exist + if resp != nil && resp.StatusCode == 404 { + return 0, fmt.Errorf("Bluecat API 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, err + } + + return zone.Id, 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. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + + err := d.login() + if err != nil { + return err + } + + viewId, err := d.lookupViewId(d.dnsView) + if err != nil { + return err + } + + parentZoneId, name, err := d.lookupParentZoneId(viewId, fqdn) + + queryArgs := map[string]string{ + "parentId": strconv.FormatUint(uint64(parentZoneId), 10), + } + + body := bluecatEntity{ + Name: name, + Type: "TXTRecord", + Properties: fmt.Sprintf("ttl=%d|absoluteName=%s|txt=%s|", ttl, fqdn, value), + } + + resp, err := d.sendRequest("POST", "addEntity", body, queryArgs) + + if err != nil { + return err + } + defer resp.Body.Close() + + addTxtBytes, _ := ioutil.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 API addEntity request failed: %s", addTxtResp) + } + + err = d.deploy(uint(parentZoneId)) + if err != nil { + return err + } + + err = d.logout() + if err != nil { + return err + } + + return 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("POST", "quickDeploy", nil, queryArgs) + + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) + + err := d.login() + if err != nil { + return err + } + + viewId, err := d.lookupViewId(d.dnsView) + if err != nil { + return err + } + + parentId, name, err := d.lookupParentZoneId(viewId, fqdn) + if err != nil { + return err + } + + queryArgs := map[string]string{ + "parentId": strconv.FormatUint(uint64(parentId), 10), + "name": name, + "type": txtType, + } + + resp, err := d.sendRequest("GET", "getEntityByName", nil, queryArgs) + if err != nil { + return err + } + defer resp.Body.Close() + + var txtRec entityResponse + err = json.NewDecoder(resp.Body).Decode(&txtRec) + if err != nil { + return err + } + queryArgs = map[string]string{ + "objectId": strconv.FormatUint(uint64(txtRec.Id), 10), + } + + resp, err = d.sendRequest("DELETE", "delete", nil, queryArgs) + if err != nil { + return err + } + defer resp.Body.Close() + + err = d.deploy(parentId) + if err != nil { + return err + } + + err = d.logout() + if err != nil { + return err + } + + return nil +} + +//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"` +} diff --git a/providers/dns/bluecat/bluecat_test.go b/providers/dns/bluecat/bluecat_test.go new file mode 100644 index 00000000..c1138ffc --- /dev/null +++ b/providers/dns/bluecat/bluecat_test.go @@ -0,0 +1,57 @@ +package bluecat + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "time" +) + +var ( + bluecatLiveTest bool + bluecatServer string + bluecatUserName string + bluecatPassword string + bluecatConfigName string + bluecatDnsView string + bluecatDomain string +) + +func init() { + bluecatServer = os.Getenv("BLUECAT_SERVER_URL") + bluecatUserName = os.Getenv("BLUECAT_USER_NAME") + bluecatPassword = os.Getenv("BLUECAT_PASSWORD") + bluecatDomain = os.Getenv("BLUECAT_DOMAIN") + bluecatConfigName = os.Getenv("BLUECAT_CONFIG_NAME") + bluecatDnsView = os.Getenv("BLUECAT_DNS_VIEW") + if len(bluecatServer) > 0 && len(bluecatDomain) > 0 && len(bluecatUserName) > 0 && len(bluecatPassword) > 0 && len(bluecatConfigName) > 0 && len(bluecatDnsView) > 0 { + bluecatLiveTest = true + } +} + +func TestLiveBluecatPresent(t *testing.T) { + if !bluecatLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.Present(bluecatDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestLiveBluecatCleanUp(t *testing.T) { + if !bluecatLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 1) + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.CleanUp(bluecatDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 931e3a5b..e1052fcf 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -34,6 +34,7 @@ import ( "github.com/xenolf/lego/providers/dns/rfc2136" "github.com/xenolf/lego/providers/dns/route53" "github.com/xenolf/lego/providers/dns/vultr" + "github.com/xenolf/lego/providers/dns/bluecat" ) func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) { @@ -44,6 +45,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) provider, err = azure.NewDNSProvider() case "auroradns": provider, err = auroradns.NewDNSProvider() + case "bluecat": + provider, err = bluecat.NewDNSProvider() case "cloudflare": provider, err = cloudflare.NewDNSProvider() case "cloudxns":