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":