diff --git a/README.md b/README.md index fa9c4c1f..9746e999 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,15 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | [FastDNS (Deprecated)](https://go-acme.github.io/lego/dns/fastdns/) | | [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) | [Gandi](https://go-acme.github.io/lego/dns/gandi/) | [Glesys](https://go-acme.github.io/lego/dns/glesys/) | [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) | | [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | -| [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | -| [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | -| [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | -| [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | -| [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | -| [OVH](https://go-acme.github.io/lego/dns/ovh/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | -| [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | -| [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | -| [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | -| [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | +| [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | +| [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | +| [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | +| [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | +| [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | +| [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | +| [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | +| [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | +| [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | +| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 14440f7a..4398fb8f 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -53,6 +53,7 @@ func allDNSCodes() string { "hetzner", "hostingde", "httpreq", + "hyperone", "iij", "inwx", "joker", @@ -928,6 +929,24 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/httpreq`) + case "hyperone": + // generated from: providers/dns/hyperone/hyperone.toml + ew.writeln(`Configuration for HyperOne.`) + ew.writeln(`Code: 'hyperone'`) + ew.writeln(`Since: ''`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "HYPERONE_API_URL": Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2)`) + ew.writeln(` - "HYPERONE_LOCATION_ID": Specifies location (region) to be used in API calls. (default pl-waw-1)`) + ew.writeln(` - "HYPERONE_PASSPORT_LOCATION": Allows to pass custom passport file location (default ~/.h1/passport.json)`) + ew.writeln(` - "HYPERONE_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "HYPERONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "HYPERONE_TTL": The TTL of the TXT record used for the DNS challenge`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/hyperone`) + case "iij": // generated from: providers/dns/iij/iij.toml ew.writeln(`Configuration for Internet Initiative Japan.`) diff --git a/docs/content/dns/zz_gen_hyperone.md b/docs/content/dns/zz_gen_hyperone.md new file mode 100644 index 00000000..30dd79b9 --- /dev/null +++ b/docs/content/dns/zz_gen_hyperone.md @@ -0,0 +1,78 @@ +--- +title: "HyperOne" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: hyperone +--- + + + + + +Since: + +Configuration for [HyperOne](https://www.hyperone.com). + + + + +- Code: `hyperone` + +Here is an example bash command using the HyperOne provider: + +```bash +lego --dns hyperone --domains my.domain.com --email my@email.com run +``` + + + + + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `HYPERONE_API_URL` | Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2) | +| `HYPERONE_LOCATION_ID` | Specifies location (region) to be used in API calls. (default pl-waw-1) | +| `HYPERONE_PASSPORT_LOCATION` | Allows to pass custom passport file location (default ~/.h1/passport.json) | +| `HYPERONE_POLLING_INTERVAL` | Time between DNS propagation check | +| `HYPERONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `HYPERONE_TTL` | The TTL of the TXT record used for the DNS challenge | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + +## Description + +Default configuration does not require any additional environment variables, +just a passport file in `~/.h1/passport.json` location. + +### Generating passport file using H1 CLI + +To use this application you have to generate passport file for `sa`: + +``` +h1 sa credential generate --name my-passport --sa --passport-output-file ~/.h1/passport.json +``` + +### Required permissions + +Depending of environment variables usage, the application requires different permissions: +- `dns/zone/list` if `HYPERONE_ZONE_URI` is not specified +- `dns/zone.recordset/list` +- `dns/zone.recordset/create` +- `dns/zone.recordset/delete` +- `dns/zone.record/create` +- `dns/zone.record/list` +- `dns/zone.record/delete` + + + +## More information + +- [API documentation](https://api.hyperone.com/v2/docs) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 29739053..d2d7d21c 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -44,6 +44,7 @@ import ( "github.com/go-acme/lego/v3/providers/dns/hetzner" "github.com/go-acme/lego/v3/providers/dns/hostingde" "github.com/go-acme/lego/v3/providers/dns/httpreq" + "github.com/go-acme/lego/v3/providers/dns/hyperone" "github.com/go-acme/lego/v3/providers/dns/iij" "github.com/go-acme/lego/v3/providers/dns/inwx" "github.com/go-acme/lego/v3/providers/dns/joker" @@ -166,6 +167,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return hostingde.NewDNSProvider() case "httpreq": return httpreq.NewDNSProvider() + case "hyperone": + return hyperone.NewDNSProvider() case "iij": return iij.NewDNSProvider() case "inwx": diff --git a/providers/dns/hyperone/hyperone.go b/providers/dns/hyperone/hyperone.go new file mode 100644 index 00000000..19e69131 --- /dev/null +++ b/providers/dns/hyperone/hyperone.go @@ -0,0 +1,203 @@ +// Package hyperone implements a DNS provider for solving the DNS-01 challenge using HyperOne. +package hyperone + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/go-acme/lego/v3/challenge/dns01" + "github.com/go-acme/lego/v3/platform/config/env" + "github.com/go-acme/lego/v3/providers/dns/hyperone/internal" +) + +// Environment variables names. +const ( + envNamespace = "HYPERONE_" + + EnvPassportLocation = envNamespace + "PASSPORT_LOCATION" + EnvAPIUrl = envNamespace + "API_URL" + EnvLocationID = envNamespace + "LOCATION_ID" + + 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 { + APIEndpoint string + LocationID string + PassportLocation string + + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + 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 { + client *internal.Client + config *Config +} + +// NewDNSProvider returns a DNSProvider instance configured for HyperOne. +func NewDNSProvider() (*DNSProvider, error) { + config := NewDefaultConfig() + + config.PassportLocation = env.GetOrFile(EnvPassportLocation) + config.LocationID = env.GetOrFile(EnvLocationID) + config.APIEndpoint = env.GetOrFile(EnvAPIUrl) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for HyperOne. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config.PassportLocation == "" { + var err error + config.PassportLocation, err = GetDefaultPassportLocation() + if err != nil { + return nil, fmt.Errorf("hyperone: %w", err) + } + } + + passport, err := internal.LoadPassportFile(config.PassportLocation) + if err != nil { + return nil, fmt.Errorf("hyperone: %w", err) + } + + client, err := internal.NewClient(config.APIEndpoint, config.LocationID, passport) + if err != nil { + return nil, fmt.Errorf("hyperone: failed to create client: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{client: client, config: config}, 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 +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + zone, err := d.getHostedZone(fqdn) + if err != nil { + return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", fqdn, err) + } + + recordset, err := d.client.FindRecordset(zone.ID, "TXT", fqdn) + if err != nil { + return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", fqdn, zone.ID, err) + } + + if recordset == nil { + _, err = d.client.CreateRecordset(zone.ID, "TXT", fqdn, value, d.config.TTL) + if err != nil { + return fmt.Errorf("hyperone: failed to create recordset: fqdn=%s, zone ID=%s, value=%s: %w", fqdn, zone.ID, value, err) + } + + return nil + } + + _, err = d.client.CreateRecord(zone.ID, recordset.ID, value) + if err != nil { + return fmt.Errorf("hyperone: failed to create record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", fqdn, zone.ID, recordset.ID, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters and recordset if no other records are remaining. +// There is a small possibility that race will cause to delete recordset with records for other DNS Challenges. +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + zone, err := d.getHostedZone(fqdn) + if err != nil { + return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", fqdn, err) + } + + recordset, err := d.client.FindRecordset(zone.ID, "TXT", fqdn) + if err != nil { + return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", fqdn, zone.ID, err) + } + + if recordset == nil { + return fmt.Errorf("hyperone: recordset to remove not found: fqdn=%s", fqdn) + } + + records, err := d.client.GetRecords(zone.ID, recordset.ID) + if err != nil { + return fmt.Errorf("hyperone: %w", err) + } + + if len(records) == 1 { + if records[0].Content != value { + return fmt.Errorf("hyperone: record with content %s not found: fqdn=%s", value, fqdn) + } + + err = d.client.DeleteRecordset(zone.ID, recordset.ID) + if err != nil { + return fmt.Errorf("hyperone: failed to delete record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", fqdn, zone.ID, recordset.ID, err) + } + + return nil + } + + for _, record := range records { + if record.Content == value { + err = d.client.DeleteRecord(zone.ID, recordset.ID, record.ID) + if err != nil { + return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s, recordset ID=%s, record ID=%s: %w", fqdn, zone.ID, recordset.ID, record.ID, err) + } + + return nil + } + } + + return fmt.Errorf("hyperone: fqdn=%s, failed to find record with given value", fqdn) +} + +// getHostedZone gets the hosted zone. +func (d *DNSProvider) getHostedZone(fqdn string) (*internal.Zone, error) { + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return nil, err + } + + return d.client.FindZone(authZone) +} + +func GetDefaultPassportLocation() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + return filepath.Join(homeDir, ".h1", "passport.json"), nil +} diff --git a/providers/dns/hyperone/hyperone.toml b/providers/dns/hyperone/hyperone.toml new file mode 100644 index 00000000..a16f7261 --- /dev/null +++ b/providers/dns/hyperone/hyperone.toml @@ -0,0 +1,46 @@ +Name = "HyperOne" +Description = '''''' +URL = "https://www.hyperone.com" +Code = "hyperone" + +Example = ''' +lego --dns hyperone --domains my.domain.com --email my@email.com run +''' + +Additional = ''' +## Description + +Default configuration does not require any additional environment variables, +just a passport file in `~/.h1/passport.json` location. + +### Generating passport file using H1 CLI + +To use this application you have to generate passport file for `sa`: + +``` +h1 sa credential generate --name my-passport --sa --passport-output-file ~/.h1/passport.json +``` + +### Required permissions + +Depending of environment variables usage, the application requires different permissions: +- `dns/zone/list` if `HYPERONE_ZONE_URI` is not specified +- `dns/zone.recordset/list` +- `dns/zone.recordset/create` +- `dns/zone.recordset/delete` +- `dns/zone.record/create` +- `dns/zone.record/list` +- `dns/zone.record/delete` +''' + +[Configuration] + [Configuration.Additional] + HYPERONE_PASSPORT_LOCATION = "Allows to pass custom passport file location (default ~/.h1/passport.json)" + HYPERONE_API_URL = "Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2)" + HYPERONE_LOCATION_ID = "Specifies location (region) to be used in API calls. (default pl-waw-1)" + HYPERONE_TTL = "The TTL of the TXT record used for the DNS challenge" + HYPERONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + HYPERONE_POLLING_INTERVAL = "Time between DNS propagation check" + +[Links] + API = "https://api.hyperone.com/v2/docs" diff --git a/providers/dns/hyperone/hyperone_test.go b/providers/dns/hyperone/hyperone_test.go new file mode 100644 index 00000000..b47853fa --- /dev/null +++ b/providers/dns/hyperone/hyperone_test.go @@ -0,0 +1,145 @@ +package hyperone + +import ( + "testing" + + "github.com/go-acme/lego/v3/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvPassportLocation, EnvAPIUrl, EnvLocationID). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvPassportLocation: "./internal/fixtures/validPassport.json", + EnvAPIUrl: "", + EnvLocationID: "", + }, + }, + { + desc: "invalid passport", + envVars: map[string]string{ + EnvPassportLocation: "./internal/fixtures/invalidPassport.json", + EnvAPIUrl: "", + EnvLocationID: "", + }, + expected: "hyperone: passport file validation failed: private key is missing", + }, + { + desc: "non existing passport", + envVars: map[string]string{ + EnvPassportLocation: "./internal/fixtures/non-existing.json", + EnvAPIUrl: "", + EnvLocationID: "", + }, + expected: "hyperone: failed to open passport file: open ./internal/fixtures/non-existing.json:", + }, + } + + 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 len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + passportLocation string + apiEndpoint string + locationID string + expected string + }{ + { + desc: "success", + passportLocation: "./internal/fixtures/validPassport.json", + apiEndpoint: "", + locationID: "", + }, + { + desc: "invalid passport", + passportLocation: "./internal/fixtures/invalidPassport.json", + apiEndpoint: "", + locationID: "", + expected: "hyperone: passport file validation failed: private key is missing", + }, + { + desc: "non existing passport", + passportLocation: "./internal/fixtures/non-existing.json", + apiEndpoint: "", + locationID: "", + expected: "hyperone: failed to open passport file: open ./internal/fixtures/non-existing.json:", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.PassportLocation = test.passportLocation + config.APIEndpoint = test.apiEndpoint + config.LocationID = test.locationID + + p, err := NewDNSProviderConfig(config) + + if len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), 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) +} diff --git a/providers/dns/hyperone/internal/client.go b/providers/dns/hyperone/internal/client.go new file mode 100644 index 00000000..c21b7f66 --- /dev/null +++ b/providers/dns/hyperone/internal/client.go @@ -0,0 +1,325 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "path" + "time" +) + +const defaultBaseURL = "https://api.hyperone.com/v2" + +const defaultLocationID = "pl-waw-1" + +type signer interface { + GetJWT() (string, error) +} + +// Client the HyperOne client. +type Client struct { + HTTPClient *http.Client + + apiEndpoint string + locationID string + projectID string + + passport *Passport + signer signer +} + +// NewClient Creates a new HyperOne client. +func NewClient(apiEndpoint, locationID string, passport *Passport) (*Client, error) { + if passport == nil { + return nil, errors.New("the passport is missing") + } + + projectID, err := passport.ExtractProjectID() + if err != nil { + return nil, err + } + + baseURL := defaultBaseURL + if apiEndpoint != "" { + baseURL = apiEndpoint + } + + tokenSigner := &TokenSigner{ + PrivateKey: passport.PrivateKey, + KeyID: passport.CertificateID, + Audience: baseURL, + Issuer: passport.Issuer, + Subject: passport.SubjectID, + } + + client := &Client{ + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + apiEndpoint: baseURL, + locationID: locationID, + passport: passport, + projectID: projectID, + signer: tokenSigner, + } + + if client.locationID == "" { + client.locationID = defaultLocationID + } + + return client, nil +} + +// FindRecordset looks for recordset with given recordType and name and returns it. +// In case if recordset is not found returns nil. +// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_list +func (c *Client) FindRecordset(zoneID, recordType, name string) (*Recordset, error) { + // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset + resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset") + + req, err := c.createRequest(http.MethodGet, resourceURL, nil) + if err != nil { + return nil, err + } + + var recordSets []Recordset + + err = c.do(req, &recordSets) + if err != nil { + return nil, fmt.Errorf("failed to get recordsets from server: %w", err) + } + + for _, v := range recordSets { + if v.RecordType == recordType && v.Name == name { + return &v, nil + } + } + + // when recordset is not present returns nil, but error is not thrown + return nil, nil +} + +// CreateRecordset creates recordset and record with given value within one request. +// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_create +func (c *Client) CreateRecordset(zoneID, recordType, name, recordValue string, ttl int) (*Recordset, error) { + recordsetInput := Recordset{ + RecordType: recordType, + Name: name, + TTL: ttl, + Record: &Record{Content: recordValue}, + } + + requestBody, err := json.Marshal(recordsetInput) + if err != nil { + return nil, fmt.Errorf("failed to marshal recordset: %w", err) + } + + // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset + resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset") + + req, err := c.createRequest(http.MethodPost, resourceURL, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + + var recordsetResponse Recordset + + err = c.do(req, &recordsetResponse) + if err != nil { + return nil, fmt.Errorf("failed to create recordset: %w", err) + } + + return &recordsetResponse, nil +} + +// DeleteRecordset deletes a recordset. +// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_delete +func (c *Client) DeleteRecordset(zoneID string, recordsetID string) error { + // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId} + resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID) + + req, err := c.createRequest(http.MethodDelete, resourceURL, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +// GetRecords gets all records within specified recordset. +// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_list +func (c *Client) GetRecords(zoneID string, recordsetID string) ([]Record, error) { + // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record + resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID, "record") + + req, err := c.createRequest(http.MethodGet, resourceURL, nil) + if err != nil { + return nil, err + } + + var records []Record + + err = c.do(req, &records) + if err != nil { + return nil, fmt.Errorf("failed to get records from server: %w", err) + } + + return records, err +} + +// CreateRecord creates a record. +// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_create +func (c *Client) CreateRecord(zoneID, recordsetID, recordContent string) (*Record, error) { + // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record + resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID, "record") + + requestBody, err := json.Marshal(Record{Content: recordContent}) + if err != nil { + return nil, fmt.Errorf("failed to marshal record: %w", err) + } + + req, err := c.createRequest(http.MethodPost, resourceURL, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + + var recordResponse Record + + err = c.do(req, &recordResponse) + if err != nil { + return nil, fmt.Errorf("failed to set record: %w", err) + } + + return &recordResponse, nil +} + +// DeleteRecord deletes a record. +// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_delete +func (c *Client) DeleteRecord(zoneID, recordsetID, recordID string) error { + // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record/{recordId} + resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID, "record", recordID) + + req, err := c.createRequest(http.MethodDelete, resourceURL, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +// FindZone looks for DNS Zone and returns nil if it does not exist. +func (c *Client) FindZone(name string) (*Zone, error) { + zones, err := c.GetZones() + if err != nil { + return nil, err + } + + for _, zone := range zones { + if zone.DNSName == name { + return &zone, nil + } + } + + return nil, fmt.Errorf("failed to find zone for %s", name) +} + +// GetZones gets all user's zones. +// https://api.hyperone.com/v2/docs#operation/dns_project_zone_list +func (c *Client) GetZones() ([]Zone, error) { + // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone + resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone") + + req, err := c.createRequest(http.MethodGet, resourceURL, nil) + if err != nil { + return nil, err + } + + var zones []Zone + + err = c.do(req, &zones) + if err != nil { + return nil, fmt.Errorf("failed to fetch available zones: %w", err) + } + + return zones, nil +} + +func (c *Client) createRequest(method, uri string, body io.Reader) (*http.Request, error) { + baseURL, err := url.Parse(c.apiEndpoint) + if err != nil { + return nil, err + } + + endpoint, err := baseURL.Parse(path.Join(baseURL.Path, uri)) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, endpoint.String(), body) + if err != nil { + return nil, err + } + + jwt, err := c.signer.GetJWT() + if err != nil { + return nil, fmt.Errorf("failed to sign the request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +func (c *Client) do(req *http.Request, v interface{}) error { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + err = checkResponse(resp) + if err != nil { + return err + } + + if v == nil { + return nil + } + + raw, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read body: %w", err) + } + + if err = json.Unmarshal(raw, v); err != nil { + return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw)) + } + + return nil +} + +func checkResponse(resp *http.Response) error { + if resp.StatusCode/100 == 2 { + return nil + } + + var msg string + if resp.StatusCode == http.StatusForbidden { + msg = "forbidden: check if service account you are trying to use has permissions required for managing DNS" + } else { + msg = fmt.Sprintf("%d: unknown error", resp.StatusCode) + } + + // add response body to error message if not empty + responseBody, _ := ioutil.ReadAll(resp.Body) + if len(responseBody) > 0 { + msg = fmt.Sprintf("%s: %s", msg, string(responseBody)) + } + + return errors.New(msg) +} diff --git a/providers/dns/hyperone/internal/client_test.go b/providers/dns/hyperone/internal/client_test.go new file mode 100644 index 00000000..f411bfa7 --- /dev/null +++ b/providers/dns/hyperone/internal/client_test.go @@ -0,0 +1,219 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type signerMock struct{} + +func (s signerMock) GetJWT() (string, error) { + return "", nil +} + +func TestClient_FindRecordset(t *testing.T) { + client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/zone321/recordset", respFromFile("recordset.json")) + + recordset, err := client.FindRecordset("zone321", "SOA", "example.com.") + require.NoError(t, err) + + expected := &Recordset{ + ID: "123456789abcd", + Name: "example.com.", + RecordType: "SOA", + TTL: 1800, + } + + assert.Equal(t, expected, recordset) +} + +func TestClient_CreateRecordset(t *testing.T) { + expectedReqBody := Recordset{ + RecordType: "TXT", + Name: "test.example.com.", + TTL: 3600, + Record: &Record{Content: "value"}, + } + + client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/zone123/recordset", + hasReqBody(expectedReqBody), respFromFile("createRecordset.json")) + + rs, err := client.CreateRecordset("zone123", "TXT", "test.example.com.", "value", 3600) + require.NoError(t, err) + + expected := &Recordset{RecordType: "TXT", Name: "test.example.com.", TTL: 3600, ID: "1234567890qwertyuiop"} + assert.Equal(t, expected, rs) +} + +func TestClient_DeleteRecordset(t *testing.T) { + client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/zone321/recordset/rs322") + + err := client.DeleteRecordset("zone321", "rs322") + require.NoError(t, err) +} + +func TestClient_GetRecords(t *testing.T) { + client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/321/recordset/322/record", respFromFile("record.json")) + + records, err := client.GetRecords("321", "322") + require.NoError(t, err) + + expected := []Record{ + { + ID: "135128352183572dd", + Content: "pns.hyperone.com. hostmaster.hyperone.com. 1 15 180 1209600 1800", + Enabled: true, + }, + } + + assert.Equal(t, expected, records) +} + +func TestClient_CreateRecord(t *testing.T) { + expectedReqBody := Record{ + Content: "value", + } + + client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/z123/recordset/rs325/record", + hasReqBody(expectedReqBody), respFromFile("createRecord.json")) + + rs, err := client.CreateRecord("z123", "rs325", "value") + require.NoError(t, err) + + expected := &Record{ID: "123321qwerqwewqerq", Content: "value", Enabled: true} + assert.Equal(t, expected, rs) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/321/recordset/322/record/323") + + err := client.DeleteRecord("321", "322", "323") + require.NoError(t, err) +} + +func TestClient_FindZone(t *testing.T) { + client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json")) + + zone, err := client.FindZone("example.com") + require.NoError(t, err) + + expected := &Zone{ + ID: "zoneB", + Name: "example.com", + DNSName: "example.com", + FQDN: "example.com.", + URI: "", + } + + assert.Equal(t, expected, zone) +} + +func TestClient_GetZones(t *testing.T) { + client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json")) + + zones, err := client.GetZones() + require.NoError(t, err) + + expected := []Zone{ + { + ID: "zoneA", + Name: "example.org", + DNSName: "example.org", + FQDN: "example.org.", + URI: "", + }, + { + ID: "zoneB", + Name: "example.com", + DNSName: "example.com", + FQDN: "example.com.", + URI: "", + }, + } + + assert.Equal(t, expected, zones) +} + +func setupTest(t *testing.T, method, path string, handlers ...assertHandler) *Client { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.Handle(path, http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + if len(handlers) != 0 { + for _, handler := range handlers { + code, err := handler(rw, req) + if err != nil { + http.Error(rw, err.Error(), code) + return + } + } + } + })) + + t.Cleanup(server.Close) + + passport := &Passport{ + SubjectID: "/iam/project/proj123/sa/xxxxxxx", + } + + client, err := NewClient(server.URL, "loc123", passport) + require.NoError(t, err) + + client.signer = signerMock{} + + return client +} + +type assertHandler func(http.ResponseWriter, *http.Request) (int, error) + +func hasReqBody(v interface{}) assertHandler { + return func(rw http.ResponseWriter, req *http.Request) (int, error) { + reqBody, err := ioutil.ReadAll(req.Body) + if err != nil { + return http.StatusBadRequest, err + } + + marshal, err := json.Marshal(v) + if err != nil { + return http.StatusInternalServerError, err + } + + if !bytes.Equal(marshal, reqBody) { + return http.StatusBadRequest, fmt.Errorf("invalid request body, got: %s, expect: %s", string(reqBody), string(marshal)) + } + + return http.StatusOK, nil + } +} + +func respFromFile(fixtureName string) assertHandler { + return func(rw http.ResponseWriter, req *http.Request) (int, error) { + file, err := os.Open(filepath.Join(".", "fixtures", fixtureName)) + if err != nil { + return http.StatusInternalServerError, err + } + + _, err = io.Copy(rw, file) + if err != nil { + return http.StatusInternalServerError, err + } + + return http.StatusOK, nil + } +} diff --git a/providers/dns/hyperone/internal/fixtures/createRecord.json b/providers/dns/hyperone/internal/fixtures/createRecord.json new file mode 100644 index 00000000..79071650 --- /dev/null +++ b/providers/dns/hyperone/internal/fixtures/createRecord.json @@ -0,0 +1,5 @@ +{ + "id": "123321qwerqwewqerq", + "content": "value", + "enabled": true +} diff --git a/providers/dns/hyperone/internal/fixtures/createRecordset.json b/providers/dns/hyperone/internal/fixtures/createRecordset.json new file mode 100644 index 00000000..aa230972 --- /dev/null +++ b/providers/dns/hyperone/internal/fixtures/createRecordset.json @@ -0,0 +1,6 @@ +{ + "id": "1234567890qwertyuiop", + "name": "test.example.com.", + "type": "TXT", + "ttl": 3600 +} diff --git a/providers/dns/hyperone/internal/fixtures/invalidPassport.json b/providers/dns/hyperone/internal/fixtures/invalidPassport.json new file mode 100644 index 00000000..bc534cf8 --- /dev/null +++ b/providers/dns/hyperone/internal/fixtures/invalidPassport.json @@ -0,0 +1,5 @@ +{ + "subject_id": "/iam/project/projectId/sa/serviceAccountId", + "certificate_id": "certificateID", + "issuer": "https://api.hyperone.com/v2/iam/project/projectId/sa/serviceAccountId" +} diff --git a/providers/dns/hyperone/internal/fixtures/record.json b/providers/dns/hyperone/internal/fixtures/record.json new file mode 100644 index 00000000..48b3124d --- /dev/null +++ b/providers/dns/hyperone/internal/fixtures/record.json @@ -0,0 +1,7 @@ +[ + { + "id": "135128352183572dd", + "content": "pns.hyperone.com. hostmaster.hyperone.com. 1 15 180 1209600 1800", + "enabled": true + } +] diff --git a/providers/dns/hyperone/internal/fixtures/recordset.json b/providers/dns/hyperone/internal/fixtures/recordset.json new file mode 100644 index 00000000..ebd907c9 --- /dev/null +++ b/providers/dns/hyperone/internal/fixtures/recordset.json @@ -0,0 +1,20 @@ +[ + { + "id": "123456789abcd", + "name": "example.com.", + "type": "SOA", + "ttl": 1800 + }, + { + "id": "123456789abcde", + "name": "example.com.", + "type": "NS", + "ttl": 3600 + }, + { + "id": "123456789abcdf", + "name": "example.com.", + "type": "CNAME", + "ttl": 3600 + } +] diff --git a/providers/dns/hyperone/internal/fixtures/validPassport.json b/providers/dns/hyperone/internal/fixtures/validPassport.json new file mode 100644 index 00000000..ca72a8d1 --- /dev/null +++ b/providers/dns/hyperone/internal/fixtures/validPassport.json @@ -0,0 +1,7 @@ +{ + "subject_id": "/iam/project/projectId/sa/serviceAccountId", + "certificate_id": "certificateID", + "issuer": "https://api.hyperone.com/v2/iam/project/projectId/sa/serviceAccountId", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nlrMAsSjjkKiRxGdgR8p5kZJj0AFgdWYa3OT2snIXnN5+/p7j13PSkseUcrAFyokc\nV9pgeDfitAhb9lpdjxjjuxRcuQjBfmNVLPF9MFyNOvhrprGNukUh/12oSKO9dFEt\ns39F/2h6Ld5IQrGt3gZaBB1aGO+tw3ill1VBy2zGPIDeuSz6DS3GG/oQ2gLSSMP4\nOVfQ32Oajo496iHRkdIh/7Hho7BNzMYr1GxrYTcE9/Znr6xgeSdNT37CCeCH8cmP\naEAUgSMTeIMVSpILwkKeNvBURic1EWaqXRgPRIWK0vNyOCs/+jNoFISnV4pu1ROF\n92vayHDNSVw9wHcdSQ75XSE4Msawqv5U1iI7e2lD64uo1qhmJdrPcXDJQCiDbh+F\nhQhF+wAoLRvMNwwhg+LttL8vXqMDQl3olsWSvWPs6b/MZpB0qwd1bklzA6P+PeAU\nsfOvTqi9edIOfKqvXqTXEhBP8qC7ZtOKLGnryZb7W04SSVrNtuJUFRcLiqu+w/F/\nMSxGSGalYpzIZ1B5HLQqISgWMXdbt39uMeeooeZjkuI3VIllFjtybecjPR9ZYQPt\nFFEP1XqNXjLFmGh84TXtvGLWretWM1OZmN8UKKUeATqrr7zuh5AYGAIbXd8BvweL\nPigl9ei0hTculPqohvkoc5x1srPBvzHrirGlxOYjW3fc4kDgZpy+6ik5k5g7JWQD\nlbXCRz3HGazgUPeiwUr06a52vhgT7QuNIUZqdHb4IfCYs2pQTLHzQjAqvVk1mm2D\nkh4myIcTtf69BFcu/Wuptm3NaKd1nwk1squR6psvcTXOWII81pstnxNYkrokx4r2\n7YVllNruOD+cMDNZbIG2CwT6V9ukIS8tl9EJp8eyb0a1uAEc22BNOjYHPF50beWF\nukf3uc0SA+G3zhmXCM5sMf5OxVjKr5jgcir7kySY5KbmG71omYhczgr4H0qgxYo9\nZyj2wMKrTHLfFOpd4OOEun9Gi3srqlKZep7Hj7gNyUwZu1qiBvElmBVmp0HJxT0N\nmktuaVbaFgBsTS0/us1EqWvCA4REh1Ut/NoA9oG3JFt0lGDstTw1j+orDmIHOmSu\n7FKYzr0uCz14AkLMSOixdPD1F0YyED1NMVnRVXw77HiAFGmb0CDi2KEg70pEKpn3\nksa8oe0MQi6oEwlMsAxVTXOB1wblTBuSBeaECzTzWE+/DHF+QQfQi8kAjjSdmmMJ\nyN+shdBWHYRGYnxRkTatONhcDBIY7sZV7wolYHz/rf7dpYUZf37vdQnYV8FpO1um\nYa0GslyRJ5GqMBfDS1cQKne+FvVHxEE2YqEGBcOYhx/JI2soE8aA8W4XffN+DoEy\nZkinJ/+BOwJ/zUI9GZtwB4JXqbNEE+j7r7/fJO9KxfPp4MPK4YWu0H0EUWONpVwe\nTWtbRhQUCOe4PVSC/Vv1pstvMD/D+E/0L4GQNHxr+xyFxuvILty5lvFTxoAVYpqD\nu8gNhk3NWefTrlSkhY4N+tPP6o7E4t3y40nOA/d9qaqiid+lYcIDB0cJTpZvgeeQ\nijohxY3PHruU4vVZa37ITQnco9az6lsy18vbU0bOyK2fEZ2R9XVO8fH11jiV8oGH\n-----END RSA PRIVATE KEY-----\n", + "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK\n5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa\nvkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0\nFK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC\nVTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M\nr3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s\nYwIDAQAB\n-----END PUBLIC KEY-----\n" +} diff --git a/providers/dns/hyperone/internal/fixtures/zones.json b/providers/dns/hyperone/internal/fixtures/zones.json new file mode 100644 index 00000000..b3787a4d --- /dev/null +++ b/providers/dns/hyperone/internal/fixtures/zones.json @@ -0,0 +1,16 @@ +[ + { + "id": "zoneA", + "name": "example.org", + "dnsName": "example.org", + "fqdn": "example.org.", + "uri": "" + }, + { + "id": "zoneB", + "name": "example.com", + "dnsName": "example.com", + "fqdn": "example.com.", + "uri": "" + } +] \ No newline at end of file diff --git a/providers/dns/hyperone/internal/models.go b/providers/dns/hyperone/internal/models.go new file mode 100644 index 00000000..75059569 --- /dev/null +++ b/providers/dns/hyperone/internal/models.go @@ -0,0 +1,23 @@ +package internal + +type Recordset struct { + RecordType string `json:"type"` + Name string `json:"name"` + TTL int `json:"ttl,omitempty"` + ID string `json:"id,omitempty"` + Record *Record `json:"record,omitempty"` +} + +type Record struct { + ID string `json:"id,omitempty"` + Content string `json:"content"` + Enabled bool `json:"enabled,omitempty"` +} + +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` + DNSName string `json:"dnsName"` + FQDN string `json:"fqdn"` + URI string `json:"uri"` +} diff --git a/providers/dns/hyperone/internal/passport.go b/providers/dns/hyperone/internal/passport.go new file mode 100644 index 00000000..b63236c3 --- /dev/null +++ b/providers/dns/hyperone/internal/passport.go @@ -0,0 +1,70 @@ +package internal + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "regexp" +) + +type Passport struct { + SubjectID string `json:"subject_id"` + CertificateID string `json:"certificate_id"` + Issuer string `json:"issuer"` + PrivateKey string `json:"private_key"` + PublicKey string `json:"public_key"` +} + +func LoadPassportFile(location string) (*Passport, error) { + file, err := os.Open(location) + if err != nil { + return nil, fmt.Errorf("failed to open passport file: %w", err) + } + + defer func() { _ = file.Close() }() + + var passport Passport + err = json.NewDecoder(file).Decode(&passport) + if err != nil { + return nil, fmt.Errorf("failed to parse passport file: %w", err) + } + + err = passport.validate() + if err != nil { + return nil, fmt.Errorf("passport file validation failed: %w", err) + } + + return &passport, nil +} + +func (passport *Passport) validate() error { + if passport.Issuer == "" { + return errors.New("issuer is empty") + } + + if passport.CertificateID == "" { + return errors.New("certificate ID is empty") + } + + if passport.PrivateKey == "" { + return errors.New("private key is missing") + } + + if passport.SubjectID == "" { + return errors.New("subject is empty") + } + + return nil +} + +func (passport *Passport) ExtractProjectID() (string, error) { + re := regexp.MustCompile("iam/project/([a-zA-Z0-9]+)") + + parts := re.FindStringSubmatch(passport.SubjectID) + if len(parts) != 2 { + return "", fmt.Errorf("failed to extract project ID from subject ID: %s", passport.SubjectID) + } + + return parts[1], nil +} diff --git a/providers/dns/hyperone/internal/passport_test.go b/providers/dns/hyperone/internal/passport_test.go new file mode 100644 index 00000000..12fc839f --- /dev/null +++ b/providers/dns/hyperone/internal/passport_test.go @@ -0,0 +1,83 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadPassportFile(t *testing.T) { + passport, err := LoadPassportFile("fixtures/validPassport.json") + require.NoError(t, err) + + expected := &Passport{ + SubjectID: "/iam/project/projectId/sa/serviceAccountId", + CertificateID: "certificateID", + Issuer: "https://api.hyperone.com/v2/iam/project/projectId/sa/serviceAccountId", + PrivateKey: `-----BEGIN RSA PRIVATE KEY----- +lrMAsSjjkKiRxGdgR8p5kZJj0AFgdWYa3OT2snIXnN5+/p7j13PSkseUcrAFyokc +V9pgeDfitAhb9lpdjxjjuxRcuQjBfmNVLPF9MFyNOvhrprGNukUh/12oSKO9dFEt +s39F/2h6Ld5IQrGt3gZaBB1aGO+tw3ill1VBy2zGPIDeuSz6DS3GG/oQ2gLSSMP4 +OVfQ32Oajo496iHRkdIh/7Hho7BNzMYr1GxrYTcE9/Znr6xgeSdNT37CCeCH8cmP +aEAUgSMTeIMVSpILwkKeNvBURic1EWaqXRgPRIWK0vNyOCs/+jNoFISnV4pu1ROF +92vayHDNSVw9wHcdSQ75XSE4Msawqv5U1iI7e2lD64uo1qhmJdrPcXDJQCiDbh+F +hQhF+wAoLRvMNwwhg+LttL8vXqMDQl3olsWSvWPs6b/MZpB0qwd1bklzA6P+PeAU +sfOvTqi9edIOfKqvXqTXEhBP8qC7ZtOKLGnryZb7W04SSVrNtuJUFRcLiqu+w/F/ +MSxGSGalYpzIZ1B5HLQqISgWMXdbt39uMeeooeZjkuI3VIllFjtybecjPR9ZYQPt +FFEP1XqNXjLFmGh84TXtvGLWretWM1OZmN8UKKUeATqrr7zuh5AYGAIbXd8BvweL +Pigl9ei0hTculPqohvkoc5x1srPBvzHrirGlxOYjW3fc4kDgZpy+6ik5k5g7JWQD +lbXCRz3HGazgUPeiwUr06a52vhgT7QuNIUZqdHb4IfCYs2pQTLHzQjAqvVk1mm2D +kh4myIcTtf69BFcu/Wuptm3NaKd1nwk1squR6psvcTXOWII81pstnxNYkrokx4r2 +7YVllNruOD+cMDNZbIG2CwT6V9ukIS8tl9EJp8eyb0a1uAEc22BNOjYHPF50beWF +ukf3uc0SA+G3zhmXCM5sMf5OxVjKr5jgcir7kySY5KbmG71omYhczgr4H0qgxYo9 +Zyj2wMKrTHLfFOpd4OOEun9Gi3srqlKZep7Hj7gNyUwZu1qiBvElmBVmp0HJxT0N +mktuaVbaFgBsTS0/us1EqWvCA4REh1Ut/NoA9oG3JFt0lGDstTw1j+orDmIHOmSu +7FKYzr0uCz14AkLMSOixdPD1F0YyED1NMVnRVXw77HiAFGmb0CDi2KEg70pEKpn3 +ksa8oe0MQi6oEwlMsAxVTXOB1wblTBuSBeaECzTzWE+/DHF+QQfQi8kAjjSdmmMJ +yN+shdBWHYRGYnxRkTatONhcDBIY7sZV7wolYHz/rf7dpYUZf37vdQnYV8FpO1um +Ya0GslyRJ5GqMBfDS1cQKne+FvVHxEE2YqEGBcOYhx/JI2soE8aA8W4XffN+DoEy +ZkinJ/+BOwJ/zUI9GZtwB4JXqbNEE+j7r7/fJO9KxfPp4MPK4YWu0H0EUWONpVwe +TWtbRhQUCOe4PVSC/Vv1pstvMD/D+E/0L4GQNHxr+xyFxuvILty5lvFTxoAVYpqD +u8gNhk3NWefTrlSkhY4N+tPP6o7E4t3y40nOA/d9qaqiid+lYcIDB0cJTpZvgeeQ +ijohxY3PHruU4vVZa37ITQnco9az6lsy18vbU0bOyK2fEZ2R9XVO8fH11jiV8oGH +-----END RSA PRIVATE KEY----- +`, + PublicKey: `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK +5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa +vkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0 +FK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC +VTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M +r3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s +YwIDAQAB +-----END PUBLIC KEY----- +`, + } + + assert.Equal(t, expected, passport) +} + +func TestLoadPassportFile_invalid(t *testing.T) { + passport, err := LoadPassportFile("fixtures/invalidPassport.json") + require.EqualError(t, err, "passport file validation failed: private key is missing") + + assert.Nil(t, passport) +} + +func TestExtractProjectID(t *testing.T) { + passport := Passport{SubjectID: "/iam/project/ddd/sa/5ef759c0ab0acab07xxxxxxx"} + extractedID, err := passport.ExtractProjectID() + require.NoError(t, err) + + assert.Equal(t, "ddd", extractedID) +} + +func TestExtractProjectID_invalid(t *testing.T) { + passport := Passport{SubjectID: "ddddddd"} + + extractedID, err := passport.ExtractProjectID() + require.EqualError(t, err, "failed to extract project ID from subject ID: ddddddd") + + assert.Empty(t, extractedID) +} diff --git a/providers/dns/hyperone/internal/token.go b/providers/dns/hyperone/internal/token.go new file mode 100644 index 00000000..6317b333 --- /dev/null +++ b/providers/dns/hyperone/internal/token.go @@ -0,0 +1,85 @@ +package internal + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "time" + + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +type TokenSigner struct { + PrivateKey string + KeyID string + Audience string + Issuer string + Subject string +} + +func (input *TokenSigner) GetJWT() (string, error) { + signer, err := getRSASigner(input.PrivateKey, input.KeyID) + if err != nil { + return "", err + } + + issuedAt := time.Now() + expiresAt := issuedAt.Add(5 * time.Minute) + + payload := Payload{IssuedAt: issuedAt.Unix(), Expiry: expiresAt.Unix(), Audience: input.Audience, Issuer: input.Issuer, Subject: input.Subject} + token, err := payload.buildToken(&signer) + + return token, err +} + +func getRSASigner(privateKey, keyID string) (jose.Signer, error) { + parsedKey, err := parseRSAKey(privateKey) + if err != nil { + return nil, err + } + + key := jose.SigningKey{Algorithm: jose.RS256, Key: parsedKey} + + signerOpts := jose.SignerOptions{} + signerOpts.WithType("JWT") + signerOpts.WithHeader("kid", keyID) + + rsaSigner, err := jose.NewSigner(key, &signerOpts) + if err != nil { + return nil, fmt.Errorf("failed to create JWS RSA256 signer: %w", err) + } + + return rsaSigner, nil +} + +type Payload struct { + IssuedAt int64 `json:"iat"` + Expiry int64 `json:"exp"` + Audience string `json:"aud"` + Issuer string `json:"iss"` + Subject string `json:"sub"` +} + +func (payload *Payload) buildToken(signer *jose.Signer) (string, error) { + builder := jwt.Signed(*signer).Claims(payload) + + token, err := builder.CompactSerialize() + if err != nil { + return "", fmt.Errorf("failed to build JWT: %w", err) + } + + return token, nil +} + +func parseRSAKey(pemString string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemString)) + + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + return key, nil +} diff --git a/providers/dns/hyperone/internal/token_test.go b/providers/dns/hyperone/internal/token_test.go new file mode 100644 index 00000000..243e015e --- /dev/null +++ b/providers/dns/hyperone/internal/token_test.go @@ -0,0 +1,65 @@ +package internal + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const privateKey = `-----BEGIN RSA PRIVATE KEY----- +MIICWgIBAAKBgGFfgMY+DuO8l0RYrMLhcl6U/NigNIiOVhoo/xnYyoQALpWxBaBR ++iVJiBUYunQjKA33yAiY0AasCfSn1JB6asayQvGGn73xztLjkeCVLT+9e4nJ0A/o +dK8SOKBg9FFe70KJrWjJd626el0aVDJjtCE+QxJExA0UZbQp+XIyveQXAgMBAAEC +gYBHcL1XNWLRPaWx9GlUVfoGYMMd4HSKl/ueF+QKP59dt5B2LTnWhS7FOqzH5auu +17hkfx3ZCNzfeEuZn6T6F4bMtsQ6A5iT/DeRlG8tOPiCVZ/L0j6IFM78iIUT8XyA +miwnSy1xGSBA67yUmsLxFg2DtGCjamAkY0C5pccadaB7oQJBAKsIPpMXMni+Oo1I +kVxRyoIZgDxsMJiihG2YLVqo8rPtdErl+Lyg3ziVyg9KR6lFMaTBkYBTLoCPof3E +AB/jyucCQQCRv1cVnYNx+bfnXsBlcsCFDV2HkEuLTpxj7hauD4P3GcyLidSsUkn1 +PiPunZqKpsQaIoxc/BzTOCcP19ifgqdRAkBJ8Cp9FE4xfKt7YJ/WtVVCoRubA3qO +wdNWPa99vgQOXN0lc/3wLevSXo8XxRjtyIgJndT1EQDNe0qglhcnsiaJAkBziAcR +/VAq0tZys2szf6kYTyXqxfj8Lo5NsHeN9oKXJ346xkEtb/VsT5vQFGJishsU1HoL +Y1W+IO7l4iW3G6xhAkACNwtqxSRRbVsNCUMENpKmYhsyN8QXJ8V+o2A9s+pl21Kz +HIIm179mUYCgO6iAHmkqxlFHFwprUBKdPrmP8qF9 +-----END RSA PRIVATE KEY-----` + +type Header struct { + Algorithm string `json:"alg"` + Type string `json:"typ"` + KeyID string `json:"kid"` +} + +func TestPayload_buildToken(t *testing.T) { + signer, err := getRSASigner(privateKey, "sampleKeyId") + require.NoError(t, err) + + payload := Payload{IssuedAt: 1234, Expiry: 4321, Audience: "api.url", Issuer: "issuer", Subject: "subject"} + + token, err := payload.buildToken(&signer) + require.NoError(t, err) + + segments := strings.Split(token, ".") + require.Len(t, segments, 3) + + headerString, err := base64.RawStdEncoding.DecodeString(segments[0]) + require.NoError(t, err) + + var headerStruct Header + err = json.Unmarshal(headerString, &headerStruct) + require.NoError(t, err) + + payloadString, err := base64.RawStdEncoding.DecodeString(segments[1]) + require.NoError(t, err) + + var payloadStruct Payload + err = json.Unmarshal(payloadString, &payloadStruct) + require.NoError(t, err) + + expectedHeader := Header{Algorithm: "RS256", Type: "JWT", KeyID: "sampleKeyId"} + + assert.Equal(t, expectedHeader, headerStruct) + assert.Equal(t, payload, payloadStruct) +}