diff --git a/README.md b/README.md
index 557542ca0..6324ece67 100644
--- a/README.md
+++ b/README.md
@@ -88,208 +88,213 @@ If your DNS provider is not supported, please open an [issue](https://github.com
| Bindman |
Bluecat |
+ Bluecat v2 |
BookMyName |
- Brandit (deprecated) |
+ | Brandit (deprecated) |
Bunny |
Checkdomain |
Civo |
- Cloud.ru |
+ | Cloud.ru |
CloudDNS |
Cloudflare |
ClouDNS |
- CloudXNS (Deprecated) |
+ | CloudXNS (Deprecated) |
ConoHa v2 |
ConoHa v3 |
Constellix |
- Core-Networks |
+ | Core-Networks |
CPanel/WHM |
DDnss (DynDNS Service) |
Derak Cloud |
- deSEC.io |
+ | deSEC.io |
Designate DNSaaS for Openstack |
Digital Ocean |
DirectAdmin |
- DNS Made Easy |
+ | DNS Made Easy |
DNSExit |
dnsHome.de |
DNSimple |
- DNSPod (deprecated) |
+ | DNSPod (deprecated) |
Domain Offensive (do.de) |
Domeneshop |
DreamHost |
- Duck DNS |
+ | Duck DNS |
Dyn |
DynDnsFree.de |
Dynu |
- EasyDNS |
+ | EasyDNS |
EdgeCenter |
Efficient IP |
Epik |
- Exoscale |
+ | Exoscale |
External program |
F5 XC |
freemyip.com |
- G-Core |
+ | G-Core |
Gandi |
Gandi Live DNS (v5) |
Gigahost.no |
- Glesys |
+ | Glesys |
Go Daddy |
Google Cloud |
Google Domains |
- Gravity |
+ | Gravity |
Hetzner |
Hosting.de |
Hosting.nl |
- Hostinger |
+ | Hostinger |
Hosttech |
HTTP request |
http.net |
- Huawei Cloud |
+ | Huawei Cloud |
Hurricane Electric DNS |
HyperOne |
IBM Cloud (SoftLayer) |
- IIJ DNS Platform Service |
+ | IIJ DNS Platform Service |
Infoblox |
Infomaniak |
Internet Initiative Japan |
- Internet.bs |
+ | Internet.bs |
INWX |
Ionos |
Ionos Cloud |
- IPv64 |
+ | IPv64 |
ISPConfig 3 |
ISPConfig 3 - Dynamic DNS (DDNS) Module |
iwantmyname (Deprecated) |
- JD Cloud |
+ | JD Cloud |
Joker |
Joohoi's ACME-DNS |
KeyHelp |
- Liara |
+ | Liara |
Lima-City |
Linode (v4) |
Liquid Web |
- Loopia |
+ | Loopia |
LuaDNS |
Mail-in-a-Box |
ManageEngine CloudDNS |
- Manual |
+ | Manual |
Metaname |
Metaregistrar |
mijn.host |
- Mittwald |
+ | Mittwald |
myaddr.{tools,dev,io} |
MyDNS.jp |
MythicBeasts |
- Name.com |
+ | Name.com |
Namecheap |
Namesilo |
NearlyFreeSpeech.NET |
- Neodigit |
+ | Neodigit |
Netcup |
Netlify |
Nicmanager |
- NIFCloud |
+ | NIFCloud |
Njalla |
Nodion |
NS1 |
- Octenium |
+ | Octenium |
Open Telekom Cloud |
Oracle Cloud |
OVH |
- plesk.com |
+ | plesk.com |
Porkbun |
PowerDNS |
Rackspace |
- Rain Yun/雨云 |
+ | Rain Yun/雨云 |
RcodeZero |
reg.ru |
Regfish |
- RFC2136 |
+ | RFC2136 |
RimuHosting |
RU CENTER |
Sakura Cloud |
- Scaleway |
+ | Scaleway |
Selectel |
Selectel v2 |
SelfHost.(de|eu) |
- Servercow |
+ | Servercow |
Shellrent |
Simply.com |
Sonic |
- Spaceship |
+ | Spaceship |
Stackpath |
Syse |
Technitium |
- Tencent Cloud DNS |
+ | Tencent Cloud DNS |
Tencent EdgeOne |
Timeweb Cloud |
TodayNIC/时代互联 |
- TransIP |
+ | TransIP |
UKFast SafeDNS |
Ultradns |
United-Domains |
- Variomedia |
+ | Variomedia |
VegaDNS |
Vercel |
Versio.[nl|eu|uk] |
- VinylDNS |
+ | VinylDNS |
Virtualname |
VK Cloud |
Volcano Engine/火山引擎 |
- Vscale |
+ | Vscale |
Vultr |
webnames.ca |
webnames.ru |
- Websupport |
+ | Websupport |
WEDOS |
West.cn/西部数码 |
Yandex 360 |
- Yandex Cloud |
+ | Yandex Cloud |
Yandex PDD |
Zone.ee |
ZoneEdit |
+
| Zonomi |
+ |
+ |
+ |
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index cf2da8563..600e49753 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -31,6 +31,7 @@ func allDNSCodes() string {
"binarylane",
"bindman",
"bluecat",
+ "bluecatv2",
"bookmyname",
"brandit",
"bunny",
@@ -623,6 +624,31 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecat`)
+ case "bluecatv2":
+ // generated from: providers/dns/bluecatv2/bluecatv2.toml
+ ew.writeln(`Configuration for Bluecat v2.`)
+ ew.writeln(`Code: 'bluecatv2'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "BLUECATV2_CONFIG_NAME": Configuration name`)
+ ew.writeln(` - "BLUECATV2_PASSWORD": API password`)
+ ew.writeln(` - "BLUECATV2_USERNAME": API username`)
+ ew.writeln(` - "BLUECATV2_VIEW_NAME": DNS View Name`)
+ ew.writeln(` - "BLUECAT_SERVER_URL": The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "BLUECATV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BLUECATV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BLUECATV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "BLUECATV2_SKIP_DEPLOY": Skip quick deployements`)
+ ew.writeln(` - "BLUECATV2_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecatv2`)
+
case "bookmyname":
// generated from: providers/dns/bookmyname/bookmyname.toml
ew.writeln(`Configuration for BookMyName.`)
diff --git a/docs/content/dns/zz_gen_bluecatv2.md b/docs/content/dns/zz_gen_bluecatv2.md
new file mode 100644
index 000000000..7d748df99
--- /dev/null
+++ b/docs/content/dns/zz_gen_bluecatv2.md
@@ -0,0 +1,76 @@
+---
+title: "Bluecat v2"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: bluecatv2
+dnsprovider:
+ since: "v4.32.0"
+ code: "bluecatv2"
+ url: "https://www.bluecatnetworks.com"
+---
+
+
+
+
+
+
+Configuration for [Bluecat v2](https://www.bluecatnetworks.com).
+
+
+
+
+- Code: `bluecatv2`
+- Since: v4.32.0
+
+
+Here is an example bash command using the Bluecat v2 provider:
+
+```bash
+BLUECATV2_SERVER_URL="https://example.com" \
+BLUECATV2_USERNAME="xxx" \
+BLUECATV2_PASSWORD="yyy" \
+BLUECATV2_CONFIG_NAME="myConfiguration" \
+BLUECATV2_VIEW_NAME="myView" \
+lego --dns bluecatv2 -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `BLUECATV2_CONFIG_NAME` | Configuration name |
+| `BLUECATV2_PASSWORD` | API password |
+| `BLUECATV2_USERNAME` | API username |
+| `BLUECATV2_VIEW_NAME` | DNS View Name |
+| `BLUECAT_SERVER_URL` | The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `BLUECATV2_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `BLUECATV2_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `BLUECATV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `BLUECATV2_SKIP_DEPLOY` | Skip quick deployements |
+| `BLUECATV2_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0)
+
+
+
+
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index 3d3043690..e31633567 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run:
$ lego dnshelp -c code
Supported DNS providers:
- acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
+ acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
More information: https://go-acme.github.io/lego/dns
"""
diff --git a/providers/dns/bluecatv2/bluecatv2.go b/providers/dns/bluecatv2/bluecatv2.go
new file mode 100644
index 000000000..0efe99661
--- /dev/null
+++ b/providers/dns/bluecatv2/bluecatv2.go
@@ -0,0 +1,249 @@
+// Package bluecatv2 implements a DNS provider for solving the DNS-01 challenge using Bluecat v2.
+package bluecatv2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/bluecatv2/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "BLUECATV2_"
+
+ EnvServerURL = envNamespace + "SERVER_URL"
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+ EnvConfigName = envNamespace + "CONFIG_NAME"
+ EnvViewName = envNamespace + "VIEW_NAME"
+ EnvSkipDeploy = envNamespace + "SKIP_DEPLOY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ ServerURL string
+ Username string
+ Password string
+ ConfigName string
+ ViewName string
+ SkipDeploy bool
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ SkipDeploy: env.GetOrDefaultBool(EnvSkipDeploy, false),
+
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ zoneIDs map[string]int64
+ recordIDs map[string]int64
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Bluecat v2.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvServerURL, EnvUsername, EnvPassword, EnvConfigName, EnvViewName)
+ if err != nil {
+ return nil, fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.ServerURL = values[EnvServerURL]
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+ config.ConfigName = values[EnvConfigName]
+ config.ViewName = values[EnvViewName]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Bluecat v2.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("bluecatv2: the configuration of the DNS provider is nil")
+ }
+
+ if config.ServerURL == "" {
+ return nil, errors.New("bluecatv2: missing server URL")
+ }
+
+ if config.ConfigName == "" {
+ return nil, errors.New("bluecatv2: missing configuration name")
+ }
+
+ if config.ViewName == "" {
+ return nil, errors.New("bluecatv2: missing view name")
+ }
+
+ client, err := internal.NewClient(config.ServerURL, config.Username, config.Password)
+ if err != nil {
+ return nil, fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]int64),
+ zoneIDs: make(map[string]int64),
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctx, err := d.client.CreateAuthenticatedContext(context.Background())
+ if err != nil {
+ return fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ zone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.AbsoluteName)
+ if err != nil {
+ return fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ record := internal.RecordTXT{
+ CommonResource: internal.CommonResource{
+ Type: "TXTRecord",
+ Name: subDomain,
+ },
+ Text: info.Value,
+ TTL: d.config.TTL,
+ RecordType: "TXT",
+ }
+
+ newRecord, err := d.client.CreateZoneResourceRecord(ctx, zone.ID, record)
+ if err != nil {
+ return fmt.Errorf("bluecatv2: create resource record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.zoneIDs[token] = zone.ID
+ d.recordIDs[token] = newRecord.ID
+ d.recordIDsMu.Unlock()
+
+ if d.config.SkipDeploy {
+ return nil
+ }
+
+ _, err = d.client.CreateZoneDeployment(ctx, zone.ID)
+ if err != nil {
+ return fmt.Errorf("bluecat: deploy zone: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ d.recordIDsMu.Lock()
+ recordID, recordOK := d.recordIDs[token]
+ zoneID, zoneOK := d.zoneIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !recordOK {
+ return fmt.Errorf("bluecatv2: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ if !zoneOK {
+ return fmt.Errorf("bluecatv2: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ ctx, err := d.client.CreateAuthenticatedContext(context.Background())
+ if err != nil {
+ return fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ err = d.client.DeleteResourceRecord(ctx, recordID)
+ if err != nil {
+ return fmt.Errorf("bluecatv2: delete resource record: %w", err)
+ }
+
+ if d.config.SkipDeploy {
+ return nil
+ }
+
+ _, err = d.client.CreateZoneDeployment(ctx, zoneID)
+ if err != nil {
+ return fmt.Errorf("bluecat: deploy zone: %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.ZoneResource, error) {
+ for name := range dns01.UnFqdnDomainsSeq(fqdn) {
+ opts := &internal.CollectionOptions{
+ Fields: "id,absoluteName,configuration.id,configuration.name,view.id,view.name",
+ Filter: internal.And(
+ internal.Eq("absoluteName", name),
+ internal.Eq("configuration.name", d.config.ConfigName),
+ internal.Eq("view.name", d.config.ViewName),
+ ).String(),
+ }
+
+ zones, err := d.client.RetrieveZones(ctx, opts)
+ if err != nil {
+ // TODO(ldez) maybe add a log in v5.
+ continue
+ }
+
+ for _, zone := range zones {
+ if zone.AbsoluteName == name {
+ return &zone, nil
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("no zone found for fqdn: %s", fqdn)
+}
diff --git a/providers/dns/bluecatv2/bluecatv2.toml b/providers/dns/bluecatv2/bluecatv2.toml
new file mode 100644
index 000000000..6ec3781c6
--- /dev/null
+++ b/providers/dns/bluecatv2/bluecatv2.toml
@@ -0,0 +1,33 @@
+Name = "Bluecat v2"
+Description = ''''''
+URL = "https://www.bluecatnetworks.com"
+Code = "bluecatv2"
+Since = "v4.32.0"
+
+Example = '''
+BLUECATV2_SERVER_URL="https://example.com" \
+BLUECATV2_USERNAME="xxx" \
+BLUECATV2_PASSWORD="yyy" \
+BLUECATV2_CONFIG_NAME="myConfiguration" \
+BLUECATV2_VIEW_NAME="myView" \
+lego --dns bluecatv2 -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ BLUECAT_SERVER_URL = "The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve"
+ BLUECATV2_USERNAME = "API username"
+ BLUECATV2_PASSWORD = "API password"
+ BLUECATV2_CONFIG_NAME = "Configuration name"
+ BLUECATV2_VIEW_NAME = "DNS View Name"
+ [Configuration.Additional]
+ BLUECATV2_SKIP_DEPLOY = "Skip quick deployements"
+ BLUECATV2_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ BLUECATV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ BLUECATV2_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ BLUECATV2_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0"
+ Swagger = "http://{Address_Manager_IP}/api/openapi.json"
+ SwaggerDump = "https://github.com/go-acme/lego/discussions/2218#discussioncomment-13060545"
diff --git a/providers/dns/bluecatv2/bluecatv2_test.go b/providers/dns/bluecatv2/bluecatv2_test.go
new file mode 100644
index 000000000..d852f0e18
--- /dev/null
+++ b/providers/dns/bluecatv2/bluecatv2_test.go
@@ -0,0 +1,414 @@
+package bluecatv2
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/bluecatv2/internal"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvServerURL,
+ EnvUsername,
+ EnvPassword,
+ EnvConfigName,
+ EnvViewName,
+ EnvSkipDeploy,
+).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "userA",
+ EnvPassword: "secret",
+ EnvConfigName: "myConfig",
+ EnvViewName: "myView",
+ },
+ },
+ {
+ desc: "missing server URL",
+ envVars: map[string]string{
+ EnvServerURL: "",
+ EnvUsername: "userA",
+ EnvPassword: "secret",
+ EnvConfigName: "myConfig",
+ EnvViewName: "myView",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL",
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "",
+ EnvPassword: "secret",
+ EnvConfigName: "myConfig",
+ EnvViewName: "myView",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "userA",
+ EnvPassword: "",
+ EnvConfigName: "myConfig",
+ EnvViewName: "myView",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_PASSWORD",
+ },
+ {
+ desc: "missing configuration name",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "userA",
+ EnvPassword: "secret",
+ EnvConfigName: "",
+ EnvViewName: "myView",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_CONFIG_NAME",
+ },
+ {
+ desc: "missing view name",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "userA",
+ EnvPassword: "secret",
+ EnvConfigName: "myConfig",
+ EnvViewName: "",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_VIEW_NAME",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL,BLUECATV2_USERNAME,BLUECATV2_PASSWORD,BLUECATV2_CONFIG_NAME,BLUECATV2_VIEW_NAME",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ serverURL string
+ username string
+ password string
+ configName string
+ viewName string
+ expected string
+ }{
+ {
+ desc: "success",
+ serverURL: "https://example.com/",
+ username: "userA",
+ password: "secret",
+ configName: "myConfig",
+ viewName: "myView",
+ },
+ {
+ desc: "missing server URL",
+ username: "userA",
+ password: "secret",
+ configName: "myConfig",
+ viewName: "myView",
+ expected: "bluecatv2: missing server URL",
+ },
+ {
+ desc: "missing username",
+ serverURL: "https://example.com/",
+ password: "secret",
+ configName: "myConfig",
+ viewName: "myView",
+ expected: "bluecatv2: credentials missing",
+ },
+ {
+ desc: "missing password",
+ serverURL: "https://example.com/",
+ username: "userA",
+ configName: "myConfig",
+ viewName: "myView",
+ expected: "bluecatv2: credentials missing",
+ },
+ {
+ desc: "missing configuration name",
+ serverURL: "https://example.com/",
+ username: "userA",
+ password: "secret",
+ viewName: "myView",
+ expected: "bluecatv2: missing configuration name",
+ },
+ {
+ desc: "missing view name",
+ serverURL: "https://example.com/",
+ username: "userA",
+ password: "secret",
+ configName: "myConfig",
+ expected: "bluecatv2: missing view name",
+ },
+ {
+ desc: "missing credentials",
+ expected: "bluecatv2: missing server URL",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.ServerURL = test.serverURL
+ config.Username = test.username
+ config.Password = test.password
+ config.ConfigName = test.configName
+ config.ViewName = test.viewName
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestLiveCleanUp(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+
+ config.ServerURL = server.URL
+ config.Username = "userA"
+ config.Password = "secret"
+ config.ConfigName = "myConfiguration"
+ config.ViewName = "myView"
+
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromInternal("postSession.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
+ ).
+ Route("GET /api/v2/configurations",
+ servermock.ResponseFromInternal("configurations.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "name:eq('myConfiguration')"),
+ ).
+ Route("GET /api/v2/configurations/12345/views",
+ servermock.ResponseFromInternal("views.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "name:eq('myView')"),
+ ).
+ Route("GET /api/v2/zones",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ filter := req.URL.Query().Get("filter")
+
+ if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) {
+ servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req)
+
+ return
+ }
+
+ servermock.ResponseFromInternal("error.json").
+ WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req)
+ }),
+ ).
+ Route("POST /api/v2/zones/12345/resourceRecords",
+ servermock.ResponseFromInternal("postZoneResourceRecord.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"),
+ ).
+ Route("POST /api/v2/zones/12345/deployments",
+ servermock.ResponseFromInternal("postZoneDeployment.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_Present_skipDeploy(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(map[string]string{
+ EnvSkipDeploy: "true",
+ })
+
+ provider := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromInternal("postSession.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
+ ).
+ Route("GET /api/v2/configurations",
+ servermock.ResponseFromInternal("configurations.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "name:eq('myConfiguration')"),
+ ).
+ Route("GET /api/v2/configurations/12345/views",
+ servermock.ResponseFromInternal("views.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "name:eq('myView')"),
+ ).
+ Route("GET /api/v2/zones",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ filter := req.URL.Query().Get("filter")
+
+ if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) {
+ servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req)
+
+ return
+ }
+
+ servermock.ResponseFromInternal("error.json").
+ WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req)
+ }),
+ ).
+ Route("POST /api/v2/zones/12345/resourceRecords",
+ servermock.ResponseFromInternal("postZoneResourceRecord.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"),
+ ).
+ Route("POST /api/v2/zones/456789/deployments",
+ servermock.Noop().
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromInternal("postSession.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
+ ).
+ Route("DELETE /api/v2/resourceRecords/12345",
+ servermock.ResponseFromInternal("deleteResourceRecord.json"),
+ ).
+ Route("POST /api/v2/zones/456789/deployments",
+ servermock.ResponseFromInternal("postZoneDeployment.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"),
+ ).
+ Build(t)
+
+ provider.zoneIDs["abc"] = 456789
+ provider.recordIDs["abc"] = 12345
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_skipDeploy(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(map[string]string{
+ EnvSkipDeploy: "true",
+ })
+
+ provider := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromInternal("postSession.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
+ ).
+ Route("DELETE /api/v2/resourceRecords/12345",
+ servermock.ResponseFromInternal("deleteResourceRecord.json"),
+ ).
+ Route("POST /api/v2/zones/456789/deployments",
+ servermock.Noop().
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ provider.zoneIDs["abc"] = 456789
+ provider.recordIDs["abc"] = 12345
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/bluecatv2/internal/client.go b/providers/dns/bluecatv2/internal/client.go
new file mode 100644
index 000000000..d3c801154
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/client.go
@@ -0,0 +1,221 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ querystring "github.com/google/go-querystring/query"
+)
+
+// Client the Bluecat v2 API client.
+type Client struct {
+ username string
+ password string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(serverURL, username, password string) (*Client, error) {
+ if serverURL == "" {
+ return nil, errors.New("server URL missing")
+ }
+
+ if username == "" || password == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, err := url.Parse(serverURL)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ username: username,
+ password: password,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// RetrieveZones retrieves all zones.
+func (c *Client) RetrieveZones(ctx context.Context, opts *CollectionOptions) ([]ZoneResource, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "zones")
+
+ collection, err := retrieveCollection[ZoneResource](ctx, c, endpoint, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return collection.Data, nil
+}
+
+// RetrieveZoneDeployments retrieves all deployments for a zone.
+func (c *Client) RetrieveZoneDeployments(ctx context.Context, zoneID int64, opts *CollectionOptions) ([]QuickDeployment, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments")
+
+ collection, err := retrieveCollection[QuickDeployment](ctx, c, endpoint, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return collection.Data, nil
+}
+
+// CreateZoneDeployment creates a new deployment for a zone.
+func (c *Client) CreateZoneDeployment(ctx context.Context, zoneID int64) (*QuickDeployment, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments")
+
+ payload := CommonResource{
+ Type: "QuickDeployment",
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
+ if err != nil {
+ return nil, err
+ }
+
+ result := new(QuickDeployment)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// CreateZoneResourceRecord creates a new TXT record in a zone.
+func (c *Client) CreateZoneResourceRecord(ctx context.Context, zoneID int64, record RecordTXT) (*RecordTXT, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "resourceRecords")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return nil, err
+ }
+
+ result := new(RecordTXT)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// DeleteResourceRecord deletes a resource record.
+func (c *Client) DeleteResourceRecord(ctx context.Context, recordID int64) error {
+ endpoint := c.baseURL.JoinPath("api", "v2", "resourceRecords", strconv.FormatInt(recordID, 10))
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.doAuthenticated(ctx, req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func retrieveCollection[T any](ctx context.Context, client *Client, endpoint *url.URL, opts *CollectionOptions) (*Collection[T], error) {
+ if opts != nil {
+ values, err := querystring.Values(opts)
+ if err != nil {
+ return nil, err
+ }
+
+ endpoint.RawQuery = values.Encode()
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &Collection[T]{}
+
+ err = client.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/bluecatv2/internal/client_test.go b/providers/dns/bluecatv2/internal/client_test.go
new file mode 100644
index 000000000..2559af66e
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/client_test.go
@@ -0,0 +1,208 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilderAuthenticated() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "userA", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ servermock.CheckHeader().
+ WithAuthorization("Basic secretToken"),
+ )
+}
+
+func TestClient_RetrieveZones(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("GET /api/v2/zones",
+ servermock.ResponseFromFixture("zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With(
+ "filter",
+ "absoluteName:eq('example.com') and configuration.name:eq('myConfiguration') and view.name:eq('myView')",
+ ),
+ ).
+ Build(t)
+
+ opts := &CollectionOptions{
+ Filter: And(
+ Eq("absoluteName", "example.com"),
+ Eq("configuration.name", "myConfiguration"),
+ Eq("view.name", "myView"),
+ ).String(),
+ }
+
+ result, err := client.RetrieveZones(mockToken(t.Context()), opts)
+ require.NoError(t, err)
+
+ expected := []ZoneResource{
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "ENUMZone", Name: "5678"},
+ AbsoluteName: "string",
+ },
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "ExternalHostsZone", Name: "name"},
+ },
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "InternalRootZone", Name: "name"},
+ },
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "ResponsePolicyZone", Name: "name"},
+ },
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "Zone", Name: "example.com"},
+ AbsoluteName: "example.com",
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_RetrieveZones_error(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("GET /api/v2/zones",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ opts := &CollectionOptions{
+ Filter: And(
+ Eq("absoluteName", "example.com"),
+ Eq("configuration.name", "myConfiguration"),
+ Eq("view.name", "myView"),
+ ).String(),
+ }
+
+ _, err := client.RetrieveZones(mockToken(t.Context()), opts)
+ require.EqualError(t, err, "401: Unauthorized: InvalidAuthorizationToken: The provided authorization token is invalid")
+}
+
+func TestClient_RetrieveZoneDeployments(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("GET /api/v2/zones/456789/deployments",
+ servermock.ResponseFromFixture("getZoneDeployments.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "id:eq('12345')"),
+ ).
+ Build(t)
+
+ opts := &CollectionOptions{
+ Filter: Eq("id", "12345").String(),
+ }
+
+ result, err := client.RetrieveZoneDeployments(mockToken(t.Context()), 456789, opts)
+ require.NoError(t, err)
+
+ expected := []QuickDeployment{
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment", Name: ""},
+ State: "PENDING",
+ Status: "CANCEL",
+ Message: "string",
+ PercentComplete: 50,
+ CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC),
+ StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC),
+ CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC),
+ Method: "SCHEDULED",
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_CreateZoneDeployment(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("POST /api/v2/zones/12345/deployments",
+ servermock.ResponseFromFixture("postZoneDeployment.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromFixture("postZoneDeployment-request.json"),
+ ).
+ Build(t)
+
+ quickDeployment, err := client.CreateZoneDeployment(mockToken(t.Context()), 12345)
+ require.NoError(t, err)
+
+ expected := &QuickDeployment{
+ CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment"},
+ State: "PENDING",
+ Status: "CANCEL",
+ Message: "string",
+ PercentComplete: 50,
+ CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC),
+ StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC),
+ CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC),
+ Method: "SCHEDULED",
+ }
+
+ assert.Equal(t, expected, quickDeployment)
+}
+
+func TestClient_CreateZoneResourceRecord(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("POST /api/v2/zones/12345/resourceRecords",
+ servermock.ResponseFromFixture("postZoneResourceRecord.json"),
+ servermock.CheckRequestJSONBodyFromFixture("postZoneResourceRecord-request.json"),
+ ).
+ Build(t)
+
+ record := RecordTXT{
+ CommonResource: CommonResource{
+ Type: "TXTRecord",
+ Name: "_acme-challenge",
+ },
+ Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ RecordType: "TXT",
+ }
+
+ result, err := client.CreateZoneResourceRecord(mockToken(t.Context()), 12345, record)
+ require.NoError(t, err)
+
+ expected := &RecordTXT{
+ CommonResource: CommonResource{
+ ID: 12345,
+ Type: "ResourceRecord",
+ Name: "name",
+ },
+ TTL: 3600,
+ AbsoluteName: "host1.example.com",
+ Comment: "Sample comment.",
+ Dynamic: true,
+ RecordType: "CNAME",
+ Text: "",
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_DeleteResourceRecord(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("DELETE /api/v2/resourceRecords/12345",
+ servermock.ResponseFromFixture("deleteResourceRecord.json"),
+ ).
+ Build(t)
+
+ err := client.DeleteResourceRecord(mockToken(t.Context()), 12345)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json b/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json
new file mode 100644
index 000000000..38ae2db6e
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json
@@ -0,0 +1,75 @@
+{
+ "id": 12345,
+ "type": "WorkflowRequest",
+ "state": "APPROVED",
+ "operation": "ADD_ALIAS_RECORD",
+ "creator": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "resourceId": 0,
+ "resourceType": "ACL",
+ "fieldUpdates": [
+ {
+ "name": "string",
+ "value": {},
+ "previousValue": {}
+ }
+ ],
+ "dependentRequest": "string",
+ "modifier": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "creationDateTime": "2022-10-17T19:11:45Z",
+ "modificationDateTime": "2022-10-18T19:11:45Z",
+ "comment": "Sample comment."
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/error.json b/providers/dns/bluecatv2/internal/fixtures/error.json
new file mode 100644
index 000000000..d3d2b8b5f
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/error.json
@@ -0,0 +1,6 @@
+{
+ "status": 401,
+ "reason": "Unauthorized",
+ "code": "InvalidAuthorizationToken",
+ "message": "The provided authorization token is invalid"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json b/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json
new file mode 100644
index 000000000..b1a4938ad
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json
@@ -0,0 +1,46 @@
+{
+ "count": 0,
+ "totalCount": 0,
+ "data": [
+ {
+ "id": 12345,
+ "type": "QuickDeployment",
+ "state": "PENDING",
+ "status": "CANCEL",
+ "message": "string",
+ "percentComplete": 50,
+ "creationDateTime": "2022-11-23T02:53:00Z",
+ "startDateTime": "2022-11-23T02:53:03Z",
+ "completionDateTime": "2022-11-23T02:54:05Z",
+ "user": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "method": "SCHEDULED"
+ }
+ ]
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postSession-request.json b/providers/dns/bluecatv2/internal/fixtures/postSession-request.json
new file mode 100644
index 000000000..e62048eb9
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postSession-request.json
@@ -0,0 +1,4 @@
+{
+ "username": "userA",
+ "password": "secret"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postSession.json b/providers/dns/bluecatv2/internal/fixtures/postSession.json
new file mode 100644
index 000000000..4599ad0ad
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postSession.json
@@ -0,0 +1,50 @@
+{
+ "id": 12345,
+ "type": "UserSession",
+ "apiToken": "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez",
+ "apiTokenExpirationDateTime": "2022-09-15T17:52:07Z",
+ "basicAuthenticationCredentials": "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=",
+ "remoteAddress": "192.168.1.1",
+ "readOnly": true,
+ "loginDateTime": "2022-09-14T17:45:03Z",
+ "logoutDateTime": "2022-09-14T19:45:03Z",
+ "state": "LOGGED_IN",
+ "response": "Authentication Error: Ensure that your username and password are correct.",
+ "user": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ }
+ }
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json
new file mode 100644
index 000000000..099573a84
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json
@@ -0,0 +1,3 @@
+{
+ "type": "QuickDeployment"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json
new file mode 100644
index 000000000..fd26781fb
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json
@@ -0,0 +1,40 @@
+{
+ "id": 12345,
+ "type": "QuickDeployment",
+ "state": "PENDING",
+ "status": "CANCEL",
+ "message": "string",
+ "percentComplete": 50,
+ "creationDateTime": "2022-11-23T02:53:00Z",
+ "startDateTime": "2022-11-23T02:53:03Z",
+ "completionDateTime": "2022-11-23T02:54:05Z",
+ "user": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "method": "SCHEDULED"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json
new file mode 100644
index 000000000..2de733c71
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json
@@ -0,0 +1,7 @@
+{
+ "type": "TXTRecord",
+ "name": "_acme-challenge",
+ "ttl": 120,
+ "recordType": "TXT",
+ "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json
new file mode 100644
index 000000000..78d028ee3
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json
@@ -0,0 +1,25 @@
+{
+ "id": 12345,
+ "type": "ResourceRecord",
+ "name": "name",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "ttl": 3600,
+ "absoluteName": "host1.example.com",
+ "comment": "Sample comment.",
+ "dynamic": true,
+ "recordType": "CNAME",
+ "linkedRecord": {
+ "id": 12345,
+ "type": "ResourceRecord",
+ "name": "name",
+ "absoluteName": "host1.example.com"
+ }
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/zones.json b/providers/dns/bluecatv2/internal/fixtures/zones.json
new file mode 100644
index 000000000..b9f2dfa8f
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/zones.json
@@ -0,0 +1,185 @@
+{
+ "count": 0,
+ "totalCount": 0,
+ "data": [
+ {
+ "id": 12345,
+ "type": "ENUMZone",
+ "name": "5678",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ },
+ "deploymentEnabled": true,
+ "absoluteName": "string"
+ },
+ {
+ "id": 12345,
+ "type": "ExternalHostsZone",
+ "name": "name",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ }
+ },
+ {
+ "id": 12345,
+ "type": "InternalRootZone",
+ "name": "name",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ },
+ "deploymentEnabled": true
+ },
+ {
+ "id": 12345,
+ "type": "ResponsePolicyZone",
+ "name": "name",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ },
+ "responsePolicyZoneType": "LOCAL",
+ "responsePolicy": {
+ "id": 12345,
+ "type": "ResponsePolicy",
+ "name": "Block Response Policy"
+ },
+ "overridePolicyType": "ALLOWLIST",
+ "overrideRefreshTime": "string",
+ "redirectTarget": "string",
+ "feedCategories": [
+ "string"
+ ]
+ },
+ {
+ "id": 12345,
+ "type": "Zone",
+ "name": "example.com",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ },
+ "deploymentEnabled": true,
+ "dynamicUpdateEnabled": true,
+ "template": {
+ "id": 12345,
+ "type": "ZoneTemplate",
+ "name": "name"
+ },
+ "signed": true,
+ "signingPolicy": {
+ "id": 12345,
+ "type": "DNSSECSigningPolicy",
+ "name": "name"
+ },
+ "absoluteName": "example.com"
+ }
+ ]
+}
diff --git a/providers/dns/bluecatv2/internal/identity.go b/providers/dns/bluecatv2/internal/identity.go
new file mode 100644
index 000000000..af9355ab2
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/identity.go
@@ -0,0 +1,60 @@
+package internal
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+)
+
+type token string
+
+const tokenKey token = "token"
+
+const authorizationHeader = "Authorization"
+
+// CreateSession creates a new session.
+func (c *Client) CreateSession(ctx context.Context, info LoginInfo) (*Session, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "sessions")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, info)
+ if err != nil {
+ return nil, err
+ }
+
+ result := new(Session)
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// CreateAuthenticatedContext creates a new authenticated context.
+func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
+ tok, err := c.CreateSession(ctx, LoginInfo{Username: c.username, Password: c.password})
+ if err != nil {
+ return nil, fmt.Errorf("create session: %w", err)
+ }
+
+ return context.WithValue(ctx, tokenKey, tok.BasicAuthenticationCredentials), nil
+}
+
+func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result any) error {
+ tok := getToken(ctx)
+ if tok != "" {
+ req.Header.Set(authorizationHeader, "Basic "+tok)
+ }
+
+ return c.do(req, result)
+}
+
+func getToken(ctx context.Context) string {
+ tok, ok := ctx.Value(tokenKey).(string)
+ if !ok {
+ return ""
+ }
+
+ return tok
+}
diff --git a/providers/dns/bluecatv2/internal/identity_test.go b/providers/dns/bluecatv2/internal/identity_test.go
new file mode 100644
index 000000000..3a1c4d2a2
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/identity_test.go
@@ -0,0 +1,82 @@
+package internal
+
+import (
+ "context"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "userA", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func mockToken(ctx context.Context) context.Context {
+ return context.WithValue(ctx, tokenKey, "secretToken")
+}
+
+func TestClient_CreateSession(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromFixture("postSession.json"),
+ servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"),
+ ).
+ Build(t)
+
+ info := LoginInfo{
+ Username: "userA",
+ Password: "secret",
+ }
+
+ result, err := client.CreateSession(mockToken(t.Context()), info)
+ require.NoError(t, err)
+
+ expected := &Session{
+ ID: 12345,
+ Type: "UserSession",
+ APIToken: "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez",
+ APITokenExpirationDateTime: time.Date(2022, time.September, 15, 17, 52, 7, 0, time.UTC),
+ BasicAuthenticationCredentials: "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=",
+ RemoteAddress: "192.168.1.1",
+ ReadOnly: true,
+ LoginDateTime: time.Date(2022, time.September, 14, 17, 45, 3, 0, time.UTC),
+ LogoutDateTime: time.Date(2022, time.September, 14, 19, 45, 3, 0, time.UTC),
+ State: "LOGGED_IN",
+ Response: "Authentication Error: Ensure that your username and password are correct.",
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_CreateAuthenticatedContext(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromFixture("postSession.json"),
+ servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"),
+ ).
+ Build(t)
+
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
+ require.NoError(t, err)
+
+ assert.Equal(t, "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", getToken(ctx))
+}
diff --git a/providers/dns/bluecatv2/internal/predicates.go b/providers/dns/bluecatv2/internal/predicates.go
new file mode 100644
index 000000000..8ed6f714b
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/predicates.go
@@ -0,0 +1,64 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type Predicate struct {
+ field string
+ operator string
+ values []string
+}
+
+func (p *Predicate) String() string {
+ var values []string
+ for _, v := range p.values {
+ values = append(values, fmt.Sprintf("'%s'", v))
+ }
+
+ return fmt.Sprintf("%s:%s(%s)", p.field, p.operator, strings.Join(values, ", "))
+}
+
+func Eq(field, value string) *Predicate {
+ return &Predicate{field: field, operator: "eq", values: []string{value}}
+}
+
+func Contains(field, value string) *Predicate {
+ return &Predicate{field: field, operator: "contains", values: []string{value}}
+}
+
+func StartsWith(field, value string) *Predicate {
+ return &Predicate{field: field, operator: "startsWith", values: []string{value}}
+}
+
+func EndsWith(field, value string) *Predicate {
+ return &Predicate{field: field, operator: "endsWith", values: []string{value}}
+}
+
+func In(field string, values ...string) *Predicate {
+ return &Predicate{field: field, operator: "in", values: values}
+}
+
+type Combined struct {
+ predicates []*Predicate
+ operator string
+}
+
+func (o *Combined) String() string {
+ var parts []string
+
+ for _, predicate := range o.predicates {
+ parts = append(parts, predicate.String())
+ }
+
+ return strings.Join(parts, " "+o.operator+" ")
+}
+
+func And(predicates ...*Predicate) *Combined {
+ return &Combined{predicates: predicates, operator: "and"}
+}
+
+func Or(predicates ...*Predicate) *Combined {
+ return &Combined{predicates: predicates, operator: "or"}
+}
diff --git a/providers/dns/bluecatv2/internal/predicates_test.go b/providers/dns/bluecatv2/internal/predicates_test.go
new file mode 100644
index 000000000..6913e8729
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/predicates_test.go
@@ -0,0 +1,78 @@
+package internal
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPredicate(t *testing.T) {
+ testCases := []struct {
+ desc string
+ predicate fmt.Stringer
+ expected string
+ }{
+ {
+ desc: "Equals",
+ predicate: Eq("foo", "bar"),
+ expected: "foo:eq('bar')",
+ },
+ {
+ desc: "Contains",
+ predicate: Contains("foo", "bar"),
+ expected: "foo:contains('bar')",
+ },
+ {
+ desc: "Starts with",
+ predicate: StartsWith("foo", "bar"),
+ expected: "foo:startsWith('bar')",
+ },
+ {
+ desc: "Ends with",
+ predicate: EndsWith("foo", "bar"),
+ expected: "foo:endsWith('bar')",
+ },
+ {
+ desc: "Match a list of values",
+ predicate: In("foo", "bar", "bir"),
+ expected: "foo:in('bar', 'bir')",
+ },
+ {
+ desc: "Combined: and",
+ predicate: And(Eq("foo", "bar"), Eq("fii", "bir")),
+ expected: "foo:eq('bar') and fii:eq('bir')",
+ },
+ {
+ desc: "Combined: multiple and",
+ predicate: And(
+ Eq("foo", "bar"),
+ Eq("fii", "bir"),
+ Eq("fuu", "bur"),
+ ),
+ expected: "foo:eq('bar') and fii:eq('bir') and fuu:eq('bur')",
+ },
+ {
+ desc: "Combined: or",
+ predicate: Or(Eq("foo", "bar"), Eq("foo", "bir")),
+ expected: "foo:eq('bar') or foo:eq('bir')",
+ },
+ {
+ desc: "Combined: multiple or",
+ predicate: Or(
+ Eq("foo", "bar"),
+ Eq("foo", "bir"),
+ Eq("foo", "bur"),
+ ),
+ expected: "foo:eq('bar') or foo:eq('bir') or foo:eq('bur')",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, test.expected, test.predicate.String())
+ })
+ }
+}
diff --git a/providers/dns/bluecatv2/internal/types.go b/providers/dns/bluecatv2/internal/types.go
new file mode 100644
index 000000000..562fd60b0
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/types.go
@@ -0,0 +1,122 @@
+package internal
+
+import (
+ "fmt"
+ "time"
+)
+
+// Quick deployment states.
+//
+//nolint:misspell // US vs UK
+const (
+ QDStatePending = "PENDING"
+ QDStateQueued = "QUEUED"
+ QDStateRunning = "RUNNING"
+ QDStateCancelled = "CANCELLED"
+ QDStateCancelling = "CANCELLING"
+ QDStateCompleted = "COMPLETED"
+ QDStateCompletedWithErrors = "COMPLETED_WITH_ERRORS"
+ QDStateCompletedWithWarnings = "COMPLETED_WITH_WARNINGS"
+ QDStateFailed = "FAILED"
+ QDStateUnknown = "UNKNOWN"
+)
+
+// APIError represents an error.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Errors/9.6.0
+type APIError struct {
+ Status int `json:"status"`
+ Reason string `json:"reason"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%d: %s: %s: %s", a.Status, a.Reason, a.Code, a.Message)
+}
+
+// CommonResource represents the common resource fields.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Resources/9.6.0
+type CommonResource struct {
+ ID int64 `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+}
+
+// Collection represents a collection of resources.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Collections/9.6.0
+type Collection[T any] struct {
+ Count int64 `json:"count"`
+ TotalCount int64 `json:"totalCount"`
+ Data []T `json:"data"`
+}
+
+type CollectionOptions struct {
+ // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Fields/9.6.0
+ Fields string `url:"fields,omitempty"`
+
+ // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Pagination/9.6.0
+ Limit int `url:"limit,omitempty"`
+ Offset int `url:"offset,omitempty"`
+
+ // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Filter/9.6.0
+ Filter string `url:"filter,omitempty"`
+
+ // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Ordering/9.6.0
+ OrderBy string `url:"orderBy,omitempty"`
+
+ // Should return or not the total number of resources matching the query.
+ Total bool `url:"total,omitempty"`
+}
+
+type RecordTXT struct {
+ CommonResource
+
+ TTL int `json:"ttl,omitempty"`
+ AbsoluteName string `json:"absoluteName,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ Dynamic bool `json:"dynamic,omitempty"`
+ RecordType string `json:"recordType,omitempty"`
+ Text string `json:"text,omitempty"`
+}
+
+type ZoneResource struct {
+ CommonResource
+
+ AbsoluteName string `json:"absoluteName,omitempty"`
+}
+
+type QuickDeployment struct {
+ CommonResource
+
+ State string `json:"state,omitempty"`
+ Status string `json:"status,omitempty"`
+ Message string `json:"message,omitempty"`
+ PercentComplete int `json:"percentComplete,omitempty"`
+ CreationDateTime time.Time `json:"creationDateTime,omitzero"`
+ StartDateTime time.Time `json:"startDateTime,omitzero"`
+ CompletionDateTime time.Time `json:"completionDateTime,omitzero"`
+ Method string `json:"method,omitempty"`
+}
+
+// LoginInfo represents the login information.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0
+type LoginInfo struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+// Session represents the session.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0
+type Session struct {
+ ID int `json:"id"`
+ Type string `json:"type"`
+ APIToken string `json:"apiToken"`
+ APITokenExpirationDateTime time.Time `json:"apiTokenExpirationDateTime"`
+ BasicAuthenticationCredentials string `json:"basicAuthenticationCredentials"`
+ RemoteAddress string `json:"remoteAddress"`
+ ReadOnly bool `json:"readOnly"`
+ LoginDateTime time.Time `json:"loginDateTime"`
+ LogoutDateTime time.Time `json:"logoutDateTime"`
+ State string `json:"state"`
+ Response string `json:"response"`
+}
diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go
index b4b98e23f..ae41f6a20 100644
--- a/providers/dns/zz_gen_dns_providers.go
+++ b/providers/dns/zz_gen_dns_providers.go
@@ -25,6 +25,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/binarylane"
"github.com/go-acme/lego/v4/providers/dns/bindman"
"github.com/go-acme/lego/v4/providers/dns/bluecat"
+ "github.com/go-acme/lego/v4/providers/dns/bluecatv2"
"github.com/go-acme/lego/v4/providers/dns/bookmyname"
"github.com/go-acme/lego/v4/providers/dns/brandit"
"github.com/go-acme/lego/v4/providers/dns/bunny"
@@ -233,6 +234,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return bindman.NewDNSProvider()
case "bluecat":
return bluecat.NewDNSProvider()
+ case "bluecatv2":
+ return bluecatv2.NewDNSProvider()
case "bookmyname":
return bookmyname.NewDNSProvider()
case "brandit":