diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index d28400fd..fda2a845 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -2099,9 +2099,11 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`Credentials:`) - ew.writeln(` - "OVH_APPLICATION_KEY": Application key`) - ew.writeln(` - "OVH_APPLICATION_SECRET": Application secret`) - ew.writeln(` - "OVH_CONSUMER_KEY": Consumer key`) + ew.writeln(` - "OVH_APPLICATION_KEY": Application key (Application Key authentication)`) + ew.writeln(` - "OVH_APPLICATION_SECRET": Application secret (Application Key authentication)`) + ew.writeln(` - "OVH_CLIENT_ID": Client ID (OAuth2)`) + ew.writeln(` - "OVH_CLIENT_SECRET": Client secret (OAuth2)`) + ew.writeln(` - "OVH_CONSUMER_KEY": Consumer key (Application Key authentication)`) ew.writeln(` - "OVH_ENDPOINT": Endpoint URL (ovh-eu or ovh-ca)`) ew.writeln() diff --git a/docs/content/dns/zz_gen_ovh.md b/docs/content/dns/zz_gen_ovh.md index 5d7e2317..724fb0cb 100644 --- a/docs/content/dns/zz_gen_ovh.md +++ b/docs/content/dns/zz_gen_ovh.md @@ -26,11 +26,20 @@ Configuration for [OVH](https://www.ovh.com/). Here is an example bash command using the OVH provider: ```bash +# Application Key authentication: + OVH_APPLICATION_KEY=1234567898765432 \ OVH_APPLICATION_SECRET=b9841238feb177a84330febba8a832089 \ OVH_CONSUMER_KEY=256vfsd347245sdfg \ OVH_ENDPOINT=ovh-eu \ lego --email you@example.com --dns ovh --domains my.example.org run + +# Or OAuth2: + +OVH_CLIENT_ID=yyy \ +OVH_CLIENT_SECRET=xxx \ +OVH_ENDPOINT=ovh-eu \ +lego --email you@example.com --dns ovh --domains my.example.org run ``` @@ -40,9 +49,11 @@ lego --email you@example.com --dns ovh --domains my.example.org run | Environment Variable Name | Description | |-----------------------|-------------| -| `OVH_APPLICATION_KEY` | Application key | -| `OVH_APPLICATION_SECRET` | Application secret | -| `OVH_CONSUMER_KEY` | Consumer key | +| `OVH_APPLICATION_KEY` | Application key (Application Key authentication) | +| `OVH_APPLICATION_SECRET` | Application secret (Application Key authentication) | +| `OVH_CLIENT_ID` | Client ID (OAuth2) | +| `OVH_CLIENT_SECRET` | Client secret (OAuth2) | +| `OVH_CONSUMER_KEY` | Consumer key (Application Key authentication) | | `OVH_ENDPOINT` | Endpoint URL (ovh-eu or ovh-ca) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. @@ -82,6 +93,22 @@ When requesting the consumer key, the following configuration can be used to def } ``` +## OAuth2 Client Credentials + +Another method for authentication is by using OAuth2 client credentials. + +An IAM policy and service account can be created by following the [OVH guide](https://help.ovhcloud.com/csm/en-manage-service-account?id=kb_article_view&sysparm_article=KB0059343). + +Following IAM policies need to be authorized for the affected domain: + +* dnsZone:apiovh:record/create +* dnsZone:apiovh:record/delete +* dnsZone:apiovh:refresh + +## Important Note + +Both authentication methods cannot be used at the same time. + ## More information diff --git a/go.mod b/go.mod index cbfea1bc..f770fe42 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( github.com/nrdcg/porkbun v0.3.0 github.com/nzdjb/go-metaname v1.0.0 github.com/oracle/oci-go-sdk/v65 v65.63.1 - github.com/ovh/go-ovh v1.4.3 + github.com/ovh/go-ovh v1.5.1 github.com/pquerna/otp v1.4.0 github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 github.com/sacloud/api-client-go v0.2.10 diff --git a/go.sum b/go.sum index 3f8cdfa0..277c8f8e 100644 --- a/go.sum +++ b/go.sum @@ -524,8 +524,8 @@ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:Ff github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= github.com/oracle/oci-go-sdk/v65 v65.63.1 h1:dYL7sk9L1+C9LCmoq+zjPMNteuJJfk54YExq/4pV9xQ= github.com/oracle/oci-go-sdk/v65 v65.63.1/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0= -github.com/ovh/go-ovh v1.4.3 h1:Gs3V823zwTFpzgGLZNI6ILS4rmxZgJwJCz54Er9LwD0= -github.com/ovh/go-ovh v1.4.3/go.mod h1:AkPXVtgwB6xlKblMjRKJJmjRp+ogrE7fz2lVgcQY8SY= +github.com/ovh/go-ovh v1.5.1 h1:P8O+7H+NQuFK9P/j4sFW5C0fvSS2DnHYGPwdVCp45wI= +github.com/ovh/go-ovh v1.5.1/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= diff --git a/providers/dns/ovh/ovh.go b/providers/dns/ovh/ovh.go index a98c2b0d..edc3e6e3 100644 --- a/providers/dns/ovh/ovh.go +++ b/providers/dns/ovh/ovh.go @@ -14,16 +14,14 @@ import ( ) // OVH API reference: https://eu.api.ovh.com/ -// Create a Token: https://eu.api.ovh.com/createToken/ +// Create a Token: https://eu.api.ovh.com/createToken/ +// Create a OAuth2 client: https://eu.api.ovh.com/console-preview/?section=%2Fme&branch=v1#post-/me/api/oauth2/client // Environment variables names. const ( envNamespace = "OVH_" - EnvEndpoint = envNamespace + "ENDPOINT" - EnvApplicationKey = envNamespace + "APPLICATION_KEY" - EnvApplicationSecret = envNamespace + "APPLICATION_SECRET" - EnvConsumerKey = envNamespace + "CONSUMER_KEY" + EnvEndpoint = envNamespace + "ENDPOINT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -31,6 +29,19 @@ const ( EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +// Authenticate using application key. +const ( + EnvApplicationKey = envNamespace + "APPLICATION_KEY" + EnvApplicationSecret = envNamespace + "APPLICATION_SECRET" + EnvConsumerKey = envNamespace + "CONSUMER_KEY" +) + +// Authenticate using OAuth2 client. +const ( + EnvClientID = envNamespace + "CLIENT_ID" + EnvClientSecret = envNamespace + "CLIENT_SECRET" +) + // Record a DNS record. type Record struct { ID int64 `json:"id,omitempty"` @@ -41,18 +52,32 @@ type Record struct { Zone string `json:"zone,omitempty"` } +// OAuth2Config the OAuth2 specific configuration. +type OAuth2Config struct { + ClientID string + ClientSecret string +} + // Config is used to configure the creation of the DNSProvider. type Config struct { - APIEndpoint string - ApplicationKey string - ApplicationSecret string - ConsumerKey string + APIEndpoint string + + ApplicationKey string + ApplicationSecret string + ConsumerKey string + + OAuth2Config *OAuth2Config + PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } +func (c *Config) hasAppKeyAuth() bool { + return c.ApplicationKey != "" || c.ApplicationSecret != "" || c.ConsumerKey != "" +} + // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ @@ -77,17 +102,11 @@ type DNSProvider struct { // Credentials must be passed in the environment variables: // OVH_ENDPOINT (must be either "ovh-eu" or "ovh-ca"), OVH_APPLICATION_KEY, OVH_APPLICATION_SECRET, OVH_CONSUMER_KEY. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvEndpoint, EnvApplicationKey, EnvApplicationSecret, EnvConsumerKey) + config, err := createConfigFromEnvVars() if err != nil { return nil, fmt.Errorf("ovh: %w", err) } - config := NewDefaultConfig() - config.APIEndpoint = values[EnvEndpoint] - config.ApplicationKey = values[EnvApplicationKey] - config.ApplicationSecret = values[EnvApplicationSecret] - config.ConsumerKey = values[EnvConsumerKey] - return NewDNSProviderConfig(config) } @@ -97,16 +116,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("ovh: the configuration of the DNS provider is nil") } - if config.APIEndpoint == "" || config.ApplicationKey == "" || config.ApplicationSecret == "" || config.ConsumerKey == "" { - return nil, errors.New("ovh: credentials missing") + if config.OAuth2Config != nil && config.hasAppKeyAuth() { + return nil, errors.New("ovh: can't use both authentication systems (ApplicationKey and OAuth2)") } - client, err := ovh.NewClient( - config.APIEndpoint, - config.ApplicationKey, - config.ApplicationSecret, - config.ConsumerKey, - ) + client, err := newClient(config) if err != nil { return nil, fmt.Errorf("ovh: %w", err) } @@ -207,3 +221,95 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } + +func createConfigFromEnvVars() (*Config, error) { + firstAppKeyEnvVar := findFirstValuedEnvVar(EnvApplicationKey, EnvApplicationSecret, EnvConsumerKey) + firstOAuth2EnvVar := findFirstValuedEnvVar(EnvClientID, EnvClientSecret) + + if firstAppKeyEnvVar != "" && firstOAuth2EnvVar != "" { + return nil, fmt.Errorf("can't use both %s and %s at the same time", firstAppKeyEnvVar, firstOAuth2EnvVar) + } + + config := NewDefaultConfig() + + if firstOAuth2EnvVar != "" { + values, err := env.Get(EnvEndpoint, EnvClientID, EnvClientSecret) + if err != nil { + return nil, err + } + + config.APIEndpoint = values[EnvEndpoint] + config.OAuth2Config = &OAuth2Config{ + ClientID: values[EnvClientID], + ClientSecret: values[EnvClientSecret], + } + + return config, nil + } + + values, err := env.Get(EnvEndpoint, EnvApplicationKey, EnvApplicationSecret, EnvConsumerKey) + if err != nil { + return nil, err + } + + config.APIEndpoint = values[EnvEndpoint] + + config.ApplicationKey = values[EnvApplicationKey] + config.ApplicationSecret = values[EnvApplicationSecret] + config.ConsumerKey = values[EnvConsumerKey] + + return config, nil +} + +func findFirstValuedEnvVar(envVars ...string) string { + for _, envVar := range envVars { + if env.GetOrFile(envVar) != "" { + return envVar + } + } + + return "" +} + +func newClient(config *Config) (*ovh.Client, error) { + if config.OAuth2Config == nil { + return newClientApplicationKey(config) + } + + return newClientOAuth2(config) +} + +func newClientApplicationKey(config *Config) (*ovh.Client, error) { + if config.APIEndpoint == "" || config.ApplicationKey == "" || config.ApplicationSecret == "" || config.ConsumerKey == "" { + return nil, errors.New("credentials are missing") + } + + client, err := ovh.NewClient( + config.APIEndpoint, + config.ApplicationKey, + config.ApplicationSecret, + config.ConsumerKey, + ) + if err != nil { + return nil, fmt.Errorf("new client: %w", err) + } + + return client, nil +} + +func newClientOAuth2(config *Config) (*ovh.Client, error) { + if config.APIEndpoint == "" || config.OAuth2Config.ClientID == "" || config.OAuth2Config.ClientSecret == "" { + return nil, errors.New("credentials are missing") + } + + client, err := ovh.NewOAuth2Client( + config.APIEndpoint, + config.OAuth2Config.ClientID, + config.OAuth2Config.ClientSecret, + ) + if err != nil { + return nil, fmt.Errorf("new OAuth2 client: %w", err) + } + + return client, nil +} diff --git a/providers/dns/ovh/ovh.toml b/providers/dns/ovh/ovh.toml index ddd51d2c..1597d280 100644 --- a/providers/dns/ovh/ovh.toml +++ b/providers/dns/ovh/ovh.toml @@ -5,11 +5,20 @@ Code = "ovh" Since = "v0.4.0" Example = ''' +# Application Key authentication: + OVH_APPLICATION_KEY=1234567898765432 \ OVH_APPLICATION_SECRET=b9841238feb177a84330febba8a832089 \ OVH_CONSUMER_KEY=256vfsd347245sdfg \ OVH_ENDPOINT=ovh-eu \ lego --email you@example.com --dns ovh --domains my.example.org run + +# Or OAuth2: + +OVH_CLIENT_ID=yyy \ +OVH_CLIENT_SECRET=xxx \ +OVH_ENDPOINT=ovh-eu \ +lego --email you@example.com --dns ovh --domains my.example.org run ''' Additional = ''' @@ -33,14 +42,32 @@ When requesting the consumer key, the following configuration can be used to def ] } ``` + +## OAuth2 Client Credentials + +Another method for authentication is by using OAuth2 client credentials. + +An IAM policy and service account can be created by following the [OVH guide](https://help.ovhcloud.com/csm/en-manage-service-account?id=kb_article_view&sysparm_article=KB0059343). + +Following IAM policies need to be authorized for the affected domain: + +* dnsZone:apiovh:record/create +* dnsZone:apiovh:record/delete +* dnsZone:apiovh:refresh + +## Important Note + +Both authentication methods cannot be used at the same time. ''' [Configuration] [Configuration.Credentials] OVH_ENDPOINT = "Endpoint URL (ovh-eu or ovh-ca)" - OVH_APPLICATION_KEY = "Application key" - OVH_APPLICATION_SECRET = "Application secret" - OVH_CONSUMER_KEY = "Consumer key" + OVH_APPLICATION_KEY = "Application key (Application Key authentication)" + OVH_APPLICATION_SECRET = "Application secret (Application Key authentication)" + OVH_CONSUMER_KEY = "Consumer key (Application Key authentication)" + OVH_CLIENT_ID = "Client ID (OAuth2)" + OVH_CLIENT_SECRET = "Client secret (OAuth2)" [Configuration.Additional] OVH_POLLING_INTERVAL = "Time between DNS propagation check" OVH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" diff --git a/providers/dns/ovh/ovh_test.go b/providers/dns/ovh/ovh_test.go index d38de1f2..cac88e90 100644 --- a/providers/dns/ovh/ovh_test.go +++ b/providers/dns/ovh/ovh_test.go @@ -14,7 +14,9 @@ var envTest = tester.NewEnvTest( EnvEndpoint, EnvApplicationKey, EnvApplicationSecret, - EnvConsumerKey). + EnvConsumerKey, + EnvClientID, + EnvClientSecret). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { @@ -24,7 +26,7 @@ func TestNewDNSProvider(t *testing.T) { expected string }{ { - desc: "success", + desc: "application key: success", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "B", @@ -33,17 +35,7 @@ func TestNewDNSProvider(t *testing.T) { }, }, { - desc: "missing credentials", - envVars: map[string]string{ - EnvEndpoint: "", - EnvApplicationKey: "", - EnvApplicationSecret: "", - EnvConsumerKey: "", - }, - expected: "ovh: some credentials information are missing: OVH_ENDPOINT,OVH_APPLICATION_KEY,OVH_APPLICATION_SECRET,OVH_CONSUMER_KEY", - }, - { - desc: "missing endpoint", + desc: "application key: missing endpoint", envVars: map[string]string{ EnvEndpoint: "", EnvApplicationKey: "B", @@ -53,17 +45,17 @@ func TestNewDNSProvider(t *testing.T) { expected: "ovh: some credentials information are missing: OVH_ENDPOINT", }, { - desc: "missing invalid endpoint", + desc: "application key: missing invalid endpoint", envVars: map[string]string{ EnvEndpoint: "foobar", EnvApplicationKey: "B", EnvApplicationSecret: "C", EnvConsumerKey: "D", }, - expected: "ovh: unknown endpoint 'foobar', consider checking 'Endpoints' list of using an URL", + expected: "ovh: new client: unknown endpoint 'foobar', consider checking 'Endpoints' list or using an URL", }, { - desc: "missing application key", + desc: "application key: missing application key", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "", @@ -73,7 +65,7 @@ func TestNewDNSProvider(t *testing.T) { expected: "ovh: some credentials information are missing: OVH_APPLICATION_KEY", }, { - desc: "missing application secret", + desc: "application key: missing application secret", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "B", @@ -83,7 +75,7 @@ func TestNewDNSProvider(t *testing.T) { expected: "ovh: some credentials information are missing: OVH_APPLICATION_SECRET", }, { - desc: "missing consumer key", + desc: "application key: missing consumer key", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "B", @@ -92,6 +84,56 @@ func TestNewDNSProvider(t *testing.T) { }, expected: "ovh: some credentials information are missing: OVH_CONSUMER_KEY", }, + { + desc: "oauth2: success", + envVars: map[string]string{ + EnvEndpoint: "ovh-eu", + EnvClientID: "E", + EnvClientSecret: "F", + }, + }, + { + desc: "oauth2: missing client secret", + envVars: map[string]string{ + EnvEndpoint: "ovh-eu", + EnvClientID: "E", + EnvClientSecret: "", + }, + expected: "ovh: some credentials information are missing: OVH_CLIENT_SECRET", + }, + { + desc: "oauth2: missing client ID", + envVars: map[string]string{ + EnvEndpoint: "ovh-eu", + EnvClientID: "", + EnvClientSecret: "F", + }, + expected: "ovh: some credentials information are missing: OVH_CLIENT_ID", + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvEndpoint: "", + EnvApplicationKey: "", + EnvApplicationSecret: "", + EnvConsumerKey: "", + EnvClientID: "", + EnvClientSecret: "", + }, + expected: "ovh: some credentials information are missing: OVH_ENDPOINT,OVH_APPLICATION_KEY,OVH_APPLICATION_SECRET,OVH_CONSUMER_KEY", + }, + { + desc: "mixed auth", + envVars: map[string]string{ + EnvEndpoint: "ovh-eu", + EnvApplicationKey: "B", + EnvApplicationSecret: "C", + EnvConsumerKey: "D", + EnvClientID: "E", + EnvClientSecret: "F", + }, + expected: "ovh: can't use both OVH_APPLICATION_KEY and OVH_CLIENT_ID at the same time", + }, } for _, test := range testCases { @@ -123,61 +165,111 @@ func TestNewDNSProviderConfig(t *testing.T) { applicationKey string applicationSecret string consumerKey string + clientID string + clientSecret string expected string }{ { - desc: "success", + desc: "application key: success", apiEndpoint: "ovh-eu", applicationKey: "B", applicationSecret: "C", consumerKey: "D", }, { - desc: "missing credentials", - expected: "ovh: credentials missing", - }, - { - desc: "missing api endpoint", + desc: "application key: missing api endpoint", apiEndpoint: "", applicationKey: "B", applicationSecret: "C", consumerKey: "D", - expected: "ovh: credentials missing", + expected: "ovh: credentials are missing", }, { - desc: "missing invalid api endpoint", + desc: "application key: invalid api endpoint", apiEndpoint: "foobar", applicationKey: "B", applicationSecret: "C", consumerKey: "D", - expected: "ovh: unknown endpoint 'foobar', consider checking 'Endpoints' list of using an URL", + expected: "ovh: new client: unknown endpoint 'foobar', consider checking 'Endpoints' list or using an URL", }, { - desc: "missing application key", + desc: "application key: missing application key", apiEndpoint: "ovh-eu", applicationKey: "", applicationSecret: "C", consumerKey: "D", - expected: "ovh: credentials missing", + expected: "ovh: credentials are missing", }, { - desc: "missing application secret", + desc: "application key: missing application secret", apiEndpoint: "ovh-eu", applicationKey: "B", applicationSecret: "", consumerKey: "D", - expected: "ovh: credentials missing", + expected: "ovh: credentials are missing", }, { - desc: "missing consumer key", + desc: "application key: missing consumer key", apiEndpoint: "ovh-eu", applicationKey: "B", applicationSecret: "C", consumerKey: "", - expected: "ovh: credentials missing", + expected: "ovh: credentials are missing", + }, + { + desc: "oauth2: success", + apiEndpoint: "ovh-eu", + clientID: "B", + clientSecret: "C", + }, + { + desc: "oauth2: missing api endpoint", + apiEndpoint: "", + clientID: "B", + clientSecret: "C", + expected: "ovh: credentials are missing", + }, + { + desc: "oauth2: invalid api endpoint", + apiEndpoint: "foobar", + clientID: "B", + clientSecret: "C", + expected: "ovh: new OAuth2 client: unknown endpoint 'foobar', consider checking 'Endpoints' list or using an URL", + }, + { + desc: "oauth2: missing client id", + apiEndpoint: "ovh-eu", + clientID: "", + clientSecret: "C", + expected: "ovh: credentials are missing", + }, + { + desc: "oauth2: missing client secret", + apiEndpoint: "ovh-eu", + clientID: "B", + clientSecret: "", + expected: "ovh: credentials are missing", + }, + { + desc: "missing credentials", + expected: "ovh: credentials are missing", + }, + { + desc: "mixed auth", + apiEndpoint: "ovh-eu", + applicationKey: "B", + applicationSecret: "C", + consumerKey: "D", + clientID: "B", + clientSecret: "C", + expected: "ovh: can't use both authentication systems (ApplicationKey and OAuth2)", }, } + // The OVH client use the same env vars than lego, so it requires to clean them. + defer envTest.RestoreEnv() + envTest.ClearEnv() + for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() @@ -186,6 +278,13 @@ func TestNewDNSProviderConfig(t *testing.T) { config.ApplicationSecret = test.applicationSecret config.ConsumerKey = test.consumerKey + if test.clientID != "" || test.clientSecret != "" { + config.OAuth2Config = &OAuth2Config{ + ClientID: test.clientID, + ClientSecret: test.clientSecret, + } + } + p, err := NewDNSProviderConfig(config) if test.expected == "" {