mirror of
				https://github.com/go-acme/lego.git
				synced 2025-10-31 16:37:41 +02:00 
			
		
		
		
	chore: refactor clients (#1868)
This commit is contained in:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							0c1303c1bd
						
					
				
				
					commit
					aeec5be129
				
			| @@ -161,7 +161,7 @@ issues: | ||||
|       linters: | ||||
|         - gocyclo | ||||
|         - funlen | ||||
|     - path: providers/dns/checkdomain/client.go | ||||
|     - path: providers/dns/checkdomain/internal/types.go | ||||
|       text: '`payed` is a misspelling of `paid`' | ||||
|     - path: providers/dns/namecheap/namecheap_test.go | ||||
|       text: 'cognitive complexity (\d+) of func `TestDNSProvider_getHosts` is high' | ||||
| @@ -174,7 +174,7 @@ issues: | ||||
|       text: 'yodaStyleExpr' | ||||
|     - path: providers/dns/dns_providers.go | ||||
|       text: 'Function name: NewDNSChallengeProviderByName,' | ||||
|     - path: providers/dns/sakuracloud/client.go | ||||
|     - path: providers/dns/sakuracloud/wrapper.go | ||||
|       text: 'mu is a global variable' | ||||
|     - path: providers/dns/hosttech/internal/client_test.go | ||||
|       text: 'Duplicate words \(0\) found' | ||||
|   | ||||
| @@ -51,7 +51,7 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | ||||
| |---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------| | ||||
| | [Akamai EdgeDNS](https://go-acme.github.io/lego/dns/edgedns/)                   | [Alibaba Cloud DNS](https://go-acme.github.io/lego/dns/alidns/)                 | [all-inkl](https://go-acme.github.io/lego/dns/allinkl/)                         | [Amazon Lightsail](https://go-acme.github.io/lego/dns/lightsail/)               | | ||||
| | [Amazon Route 53](https://go-acme.github.io/lego/dns/route53/)                  | [ArvanCloud](https://go-acme.github.io/lego/dns/arvancloud/)                    | [Aurora DNS](https://go-acme.github.io/lego/dns/auroradns/)                     | [Autodns](https://go-acme.github.io/lego/dns/autodns/)                          | | ||||
| | [Azure](https://go-acme.github.io/lego/dns/azure/)                              | [Bindman](https://go-acme.github.io/lego/dns/bindman/)                          | [Bluecat](https://go-acme.github.io/lego/dns/bluecat/)                          | [BRANDIT](https://go-acme.github.io/lego/dns/brandit/)                          | | ||||
| | [Azure](https://go-acme.github.io/lego/dns/azure/)                              | [Bindman](https://go-acme.github.io/lego/dns/bindman/)                          | [Bluecat](https://go-acme.github.io/lego/dns/bluecat/)                          | [Brandit](https://go-acme.github.io/lego/dns/brandit/)                          | | ||||
| | [Bunny](https://go-acme.github.io/lego/dns/bunny/)                              | [Checkdomain](https://go-acme.github.io/lego/dns/checkdomain/)                  | [Civo](https://go-acme.github.io/lego/dns/civo/)                                | [CloudDNS](https://go-acme.github.io/lego/dns/clouddns/)                        | | ||||
| | [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/)                    | [ClouDNS](https://go-acme.github.io/lego/dns/cloudns/)                          | [CloudXNS](https://go-acme.github.io/lego/dns/cloudxns/)                        | [ConoHa](https://go-acme.github.io/lego/dns/conoha/)                            | | ||||
| | [Constellix](https://go-acme.github.io/lego/dns/constellix/)                    | [deSEC.io](https://go-acme.github.io/lego/dns/desec/)                           | [Designate DNSaaS for Openstack](https://go-acme.github.io/lego/dns/designate/) | [Digital Ocean](https://go-acme.github.io/lego/dns/digitalocean/)               | | ||||
|   | ||||
| @@ -335,7 +335,7 @@ func displayDNSHelp(w io.Writer, name string) error { | ||||
|  | ||||
| 	case "brandit": | ||||
| 		// generated from: providers/dns/brandit/brandit.toml | ||||
| 		ew.writeln(`Configuration for BRANDIT.`) | ||||
| 		ew.writeln(`Configuration for Brandit.`) | ||||
| 		ew.writeln(`Code:	'brandit'`) | ||||
| 		ew.writeln(`Since:	'v4.11.0'`) | ||||
| 		ew.writeln() | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| --- | ||||
| title: "BRANDIT" | ||||
| title: "Brandit" | ||||
| date: 2019-03-03T16:39:46+01:00 | ||||
| draft: false | ||||
| slug: brandit | ||||
| @@ -14,7 +14,7 @@ dnsprovider: | ||||
| <!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. --> | ||||
|  | ||||
|  | ||||
| Configuration for [BRANDIT](https://www.brandit.com/). | ||||
| Configuration for [Brandit](https://www.brandit.com/). | ||||
|  | ||||
|  | ||||
| <!--more--> | ||||
| @@ -23,7 +23,7 @@ Configuration for [BRANDIT](https://www.brandit.com/). | ||||
| - Since: v4.11.0 | ||||
|  | ||||
|  | ||||
| Here is an example bash command using the BRANDIT provider: | ||||
| Here is an example bash command using the Brandit provider: | ||||
|  | ||||
| ```bash | ||||
| BRANDIT_API_KEY=xxxxxxxxxxxxxxxxxxxxx \ | ||||
|   | ||||
| @@ -61,7 +61,7 @@ More information [here]({{< ref "dns#configuration-and-credentials" >}}). | ||||
|  | ||||
| ## More information | ||||
|  | ||||
| - [API documentation](https://docs.otc.t-systems.com/en-us/dns/index.html) | ||||
| - [API documentation](https://docs.otc.t-systems.com/domain-name-service/api-ref/index.html) | ||||
|  | ||||
| <!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. --> | ||||
| <!-- providers/dns/otc/otc.toml --> | ||||
|   | ||||
							
								
								
									
										14
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								go.mod
									
									
									
									
									
								
							| @@ -63,9 +63,9 @@ require ( | ||||
| 	github.com/vultr/govultr/v2 v2.17.2 | ||||
| 	github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f | ||||
| 	github.com/yandex-cloud/go-sdk v0.0.0-20220805164847-cf028e604997 | ||||
| 	golang.org/x/crypto v0.5.0 | ||||
| 	golang.org/x/net v0.7.0 | ||||
| 	golang.org/x/oauth2 v0.5.0 | ||||
| 	golang.org/x/crypto v0.7.0 | ||||
| 	golang.org/x/net v0.8.0 | ||||
| 	golang.org/x/oauth2 v0.6.0 | ||||
| 	golang.org/x/time v0.3.0 | ||||
| 	google.golang.org/api v0.111.0 | ||||
| 	gopkg.in/ns1/ns1-go.v2 v2.6.5 | ||||
| @@ -126,10 +126,10 @@ require ( | ||||
| 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect | ||||
| 	go.opencensus.io v0.24.0 // indirect | ||||
| 	go.uber.org/ratelimit v0.2.0 // indirect | ||||
| 	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect | ||||
| 	golang.org/x/sys v0.5.0 // indirect | ||||
| 	golang.org/x/text v0.7.0 // indirect | ||||
| 	golang.org/x/tools v0.1.12 // indirect | ||||
| 	golang.org/x/mod v0.8.0 // indirect | ||||
| 	golang.org/x/sys v0.6.0 // indirect | ||||
| 	golang.org/x/text v0.8.0 // indirect | ||||
| 	golang.org/x/tools v0.6.0 // indirect | ||||
| 	google.golang.org/appengine v1.6.7 // indirect | ||||
| 	google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 // indirect | ||||
| 	google.golang.org/grpc v1.53.0 // indirect | ||||
|   | ||||
							
								
								
									
										30
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								go.sum
									
									
									
									
									
								
							| @@ -595,8 +595,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y | ||||
| golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= | ||||
| golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= | ||||
| golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= | ||||
| golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= | ||||
| golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= | ||||
| golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= | ||||
| golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= | ||||
| golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | ||||
| golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | ||||
| golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= | ||||
| @@ -619,8 +619,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= | ||||
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= | ||||
| golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= | ||||
| golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | ||||
| golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| @@ -650,14 +650,14 @@ golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qx | ||||
| golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||
| golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||
| golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | ||||
| golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= | ||||
| golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= | ||||
| golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= | ||||
| golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= | ||||
| golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||||
| golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s= | ||||
| golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= | ||||
| golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= | ||||
| golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= | ||||
| golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| @@ -712,12 +712,12 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc | ||||
| golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= | ||||
| golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= | ||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= | ||||
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | ||||
| golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= | ||||
| golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= | ||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= | ||||
| @@ -726,8 +726,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= | ||||
| golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= | ||||
| golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= | ||||
| golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= | ||||
| golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= | ||||
| golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| @@ -758,8 +758,8 @@ golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4X | ||||
| golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= | ||||
| golang.org/x/tools v0.0.0-20210114065538-d78b04bdf963/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= | ||||
| golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= | ||||
| golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= | ||||
| golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= | ||||
| golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= | ||||
| golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= | ||||
| golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| package wait | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"time" | ||||
|  | ||||
| @@ -18,9 +17,9 @@ func For(msg string, timeout, interval time.Duration, f func() (bool, error)) er | ||||
| 		select { | ||||
| 		case <-timeUp: | ||||
| 			if lastErr == nil { | ||||
| 				return errors.New("time limit exceeded") | ||||
| 				return fmt.Errorf("%s: time limit exceeded", msg) | ||||
| 			} | ||||
| 			return fmt.Errorf("time limit exceeded: last error: %w", lastErr) | ||||
| 			return fmt.Errorf("%s: time limit exceeded: last error: %w", msg, lastErr) | ||||
| 		default: | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -198,7 +198,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(domain) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 		return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) | ||||
| 	} | ||||
|  | ||||
| 	var hostedZone alidns.DomainInDescribeDomains | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| package allinkl | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -49,7 +50,9 @@ func NewDefaultConfig() *Config { | ||||
| // DNSProvider implements the challenge.Provider interface. | ||||
| type DNSProvider struct { | ||||
| 	config *Config | ||||
| 	client *internal.Client | ||||
|  | ||||
| 	identifier *internal.Identifier | ||||
| 	client     *internal.Client | ||||
|  | ||||
| 	recordIDs   map[string]string | ||||
| 	recordIDsMu sync.Mutex | ||||
| @@ -80,16 +83,23 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 		return nil, errors.New("allinkl: missing credentials") | ||||
| 	} | ||||
|  | ||||
| 	client := internal.NewClient(config.Login, config.Password) | ||||
| 	identifier := internal.NewIdentifier(config.Login, config.Password) | ||||
|  | ||||
| 	if config.HTTPClient != nil { | ||||
| 		identifier.HTTPClient = config.HTTPClient | ||||
| 	} | ||||
|  | ||||
| 	client := internal.NewClient(config.Login) | ||||
|  | ||||
| 	if config.HTTPClient != nil { | ||||
| 		client.HTTPClient = config.HTTPClient | ||||
| 	} | ||||
|  | ||||
| 	return &DNSProvider{ | ||||
| 		config:    config, | ||||
| 		client:    client, | ||||
| 		recordIDs: make(map[string]string), | ||||
| 		config:     config, | ||||
| 		identifier: identifier, | ||||
| 		client:     client, | ||||
| 		recordIDs:  make(map[string]string), | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| @@ -105,14 +115,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("allinkl: could not determine zone for domain %q: %w", domain, err) | ||||
| 		return fmt.Errorf("allinkl: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	credential, err := d.client.Authentication(60, true) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	credential, err := d.identifier.Authentication(ctx, 60, true) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("allinkl: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	ctx = internal.WithContext(ctx, credential) | ||||
|  | ||||
| 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("allinkl: %w", err) | ||||
| @@ -125,7 +139,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 		RecordData: info.Value, | ||||
| 	} | ||||
|  | ||||
| 	recordID, err := d.client.AddDNSSettings(credential, record) | ||||
| 	recordID, err := d.client.AddDNSSettings(ctx, record) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("allinkl: %w", err) | ||||
| 	} | ||||
| @@ -141,11 +155,15 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	credential, err := d.client.Authentication(60, true) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	credential, err := d.identifier.Authentication(ctx, 60, true) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("allinkl: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	ctx = internal.WithContext(ctx, credential) | ||||
|  | ||||
| 	// gets the record's unique ID from when we created it | ||||
| 	d.recordIDsMu.Lock() | ||||
| 	recordID, ok := d.recordIDs[token] | ||||
| @@ -154,7 +172,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 		return fmt.Errorf("allinkl: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) | ||||
| 	} | ||||
|  | ||||
| 	_, err = d.client.DeleteDNSSettings(credential, recordID) | ||||
| 	_, err = d.client.DeleteDNSSettings(ctx, recordID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("allinkl: %w", err) | ||||
| 	} | ||||
|   | ||||
| @@ -2,126 +2,64 @@ package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"encoding/xml" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| 	"github.com/mitchellh/mapstructure" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	authEndpoint = "https://kasapi.kasserver.com/soap/KasAuth.php" | ||||
| 	apiEndpoint  = "https://kasapi.kasserver.com/soap/KasApi.php" | ||||
| ) | ||||
| const apiEndpoint = "https://kasapi.kasserver.com/soap/KasApi.php" | ||||
|  | ||||
| type Authentication interface { | ||||
| 	Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error) | ||||
| } | ||||
|  | ||||
| // Client a KAS server client. | ||||
| type Client struct { | ||||
| 	login    string | ||||
| 	password string | ||||
| 	login string | ||||
|  | ||||
| 	authEndpoint string | ||||
| 	apiEndpoint  string | ||||
| 	HTTPClient   *http.Client | ||||
| 	floodTime    time.Time | ||||
| 	floodTime   time.Time | ||||
| 	muFloodTime sync.Mutex | ||||
|  | ||||
| 	baseURL    string | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient creates a new Client. | ||||
| func NewClient(login string, password string) *Client { | ||||
| func NewClient(login string) *Client { | ||||
| 	return &Client{ | ||||
| 		login:        login, | ||||
| 		password:     password, | ||||
| 		authEndpoint: authEndpoint, | ||||
| 		apiEndpoint:  apiEndpoint, | ||||
| 		HTTPClient:   &http.Client{Timeout: 10 * time.Second}, | ||||
| 		login:      login, | ||||
| 		baseURL:    apiEndpoint, | ||||
| 		HTTPClient: &http.Client{Timeout: 10 * time.Second}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Authentication Creates a credential token. | ||||
| // - sessionLifetime: Validity of the token in seconds. | ||||
| // - sessionUpdateLifetime: with `true` the session is extended with every request. | ||||
| func (c Client) Authentication(sessionLifetime int, sessionUpdateLifetime bool) (string, error) { | ||||
| 	sul := "N" | ||||
| 	if sessionUpdateLifetime { | ||||
| 		sul = "Y" | ||||
| 	} | ||||
|  | ||||
| 	ar := AuthRequest{ | ||||
| 		Login:                 c.login, | ||||
| 		AuthData:              c.password, | ||||
| 		AuthType:              "plain", | ||||
| 		SessionLifetime:       sessionLifetime, | ||||
| 		SessionUpdateLifetime: sul, | ||||
| 	} | ||||
|  | ||||
| 	body, err := json.Marshal(ar) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("request marshal: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body))) | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodPost, c.authEndpoint, bytes.NewReader(payload)) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("request creation: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("request execution: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		data, _ := io.ReadAll(resp.Body) | ||||
| 		return "", fmt.Errorf("invalid status code: %d %s", resp.StatusCode, string(data)) | ||||
| 	} | ||||
|  | ||||
| 	data, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("response read: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var e KasAuthEnvelope | ||||
| 	decoder := xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(data))}) | ||||
| 	err = decoder.Decode(&e) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("response xml decode: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if e.Body.Fault != nil { | ||||
| 		return "", e.Body.Fault | ||||
| 	} | ||||
|  | ||||
| 	return e.Body.KasAuthResponse.Return.Text, nil | ||||
| } | ||||
|  | ||||
| // GetDNSSettings Reading out the DNS settings of a zone. | ||||
| // - zone: host zone. | ||||
| // - recordID: the ID of the resource record (optional). | ||||
| func (c *Client) GetDNSSettings(credentialToken, zone, recordID string) ([]ReturnInfo, error) { | ||||
| func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]ReturnInfo, error) { | ||||
| 	requestParams := map[string]string{"zone_host": zone} | ||||
|  | ||||
| 	if recordID != "" { | ||||
| 		requestParams["record_id"] = recordID | ||||
| 	} | ||||
|  | ||||
| 	item, err := c.do(credentialToken, "get_dns_settings", requestParams) | ||||
| 	req, err := c.newRequest(ctx, "get_dns_settings", requestParams) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	raw := getValue(item) | ||||
|  | ||||
| 	var g GetDNSSettingsAPIResponse | ||||
| 	err = mapstructure.Decode(raw, &g) | ||||
| 	err = c.do(req, &g) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("response struct decode: %w", err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	c.updateFloodTime(g.Response.KasFloodDelay) | ||||
| @@ -130,18 +68,16 @@ func (c *Client) GetDNSSettings(credentialToken, zone, recordID string) ([]Retur | ||||
| } | ||||
|  | ||||
| // AddDNSSettings Creation of a DNS resource record. | ||||
| func (c *Client) AddDNSSettings(credentialToken string, record DNSRequest) (string, error) { | ||||
| 	item, err := c.do(credentialToken, "add_dns_settings", record) | ||||
| func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, error) { | ||||
| 	req, err := c.newRequest(ctx, "add_dns_settings", record) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	raw := getValue(item) | ||||
|  | ||||
| 	var g AddDNSSettingsAPIResponse | ||||
| 	err = mapstructure.Decode(raw, &g) | ||||
| 	err = c.do(req, &g) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("response struct decode: %w", err) | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	c.updateFloodTime(g.Response.KasFloodDelay) | ||||
| @@ -150,20 +86,18 @@ func (c *Client) AddDNSSettings(credentialToken string, record DNSRequest) (stri | ||||
| } | ||||
|  | ||||
| // DeleteDNSSettings Deleting a DNS Resource Record. | ||||
| func (c *Client) DeleteDNSSettings(credentialToken, recordID string) (bool, error) { | ||||
| func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (bool, error) { | ||||
| 	requestParams := map[string]string{"record_id": recordID} | ||||
|  | ||||
| 	item, err := c.do(credentialToken, "delete_dns_settings", requestParams) | ||||
| 	req, err := c.newRequest(ctx, "delete_dns_settings", requestParams) | ||||
| 	if err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
|  | ||||
| 	raw := getValue(item) | ||||
|  | ||||
| 	var g DeleteDNSSettingsAPIResponse | ||||
| 	err = mapstructure.Decode(raw, &g) | ||||
| 	err = c.do(req, &g) | ||||
| 	if err != nil { | ||||
| 		return false, fmt.Errorf("response struct decode: %w", err) | ||||
| 		return false, err | ||||
| 	} | ||||
|  | ||||
| 	c.updateFloodTime(g.Response.KasFloodDelay) | ||||
| @@ -171,65 +105,72 @@ func (c *Client) DeleteDNSSettings(credentialToken, recordID string) (bool, erro | ||||
| 	return g.Response.ReturnInfo, nil | ||||
| } | ||||
|  | ||||
| func (c Client) do(credentialToken, action string, requestParams interface{}) (*Item, error) { | ||||
| 	time.Sleep(time.Until(c.floodTime)) | ||||
|  | ||||
| func (c *Client) newRequest(ctx context.Context, action string, requestParams any) (*http.Request, error) { | ||||
| 	ar := KasRequest{ | ||||
| 		Login:         c.login, | ||||
| 		AuthType:      "session", | ||||
| 		AuthData:      credentialToken, | ||||
| 		AuthData:      getToken(ctx), | ||||
| 		Action:        action, | ||||
| 		RequestParams: requestParams, | ||||
| 	} | ||||
|  | ||||
| 	body, err := json.Marshal(ar) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("request marshal: %w", err) | ||||
| 		return nil, fmt.Errorf("failed to create request JSON body: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAPIEnvelope, body))) | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(payload)) | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("request creation: %w", err) | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return req, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) do(req *http.Request, result any) error { | ||||
| 	c.muFloodTime.Lock() | ||||
| 	time.Sleep(time.Until(c.floodTime)) | ||||
| 	c.muFloodTime.Unlock() | ||||
|  | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("request execution: %w", err) | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		data, _ := io.ReadAll(resp.Body) | ||||
| 		return nil, fmt.Errorf("invalid status code: %d %s", resp.StatusCode, string(data)) | ||||
| 		return errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	data, err := io.ReadAll(resp.Body) | ||||
| 	envlp, err := decodeXML[KasAPIResponseEnvelope](resp.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("response read: %w", err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	var e KasAPIResponseEnvelope | ||||
| 	decoder := xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(data))}) | ||||
| 	err = decoder.Decode(&e) | ||||
| 	if envlp.Body.Fault != nil { | ||||
| 		return envlp.Body.Fault | ||||
| 	} | ||||
|  | ||||
| 	raw := getValue(envlp.Body.KasAPIResponse.Return) | ||||
|  | ||||
| 	err = mapstructure.Decode(raw, result) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("response xml decode: %w", err) | ||||
| 		return fmt.Errorf("response struct decode: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if e.Body.Fault != nil { | ||||
| 		return nil, e.Body.Fault | ||||
| 	} | ||||
|  | ||||
| 	return e.Body.KasAPIResponse.Return, nil | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (c *Client) updateFloodTime(delay float64) { | ||||
| 	c.muFloodTime.Lock() | ||||
| 	c.floodTime = time.Now().Add(time.Duration(delay * float64(time.Second))) | ||||
| 	c.muFloodTime.Unlock() | ||||
| } | ||||
|  | ||||
| func getValue(item *Item) interface{} { | ||||
| func getValue(item *Item) any { | ||||
| 	switch { | ||||
| 	case item.Raw != "": | ||||
| 		v, _ := strconv.ParseBool(item.Raw) | ||||
| @@ -253,7 +194,7 @@ func getValue(item *Item) interface{} { | ||||
| 		return getValue(item.Value) | ||||
|  | ||||
| 	case len(item.Items) > 0 && item.Type == "SOAP-ENC:Array": | ||||
| 		var v []interface{} | ||||
| 		var v []any | ||||
| 		for _, i := range item.Items { | ||||
| 			v = append(v, getValue(i)) | ||||
| 		} | ||||
| @@ -261,7 +202,7 @@ func getValue(item *Item) interface{} { | ||||
| 		return v | ||||
|  | ||||
| 	case len(item.Items) > 0: | ||||
| 		v := map[string]interface{}{} | ||||
| 		v := map[string]any{} | ||||
| 		for _, i := range item.Items { | ||||
| 			v[getKey(i)] = getValue(i) | ||||
| 		} | ||||
|   | ||||
| @@ -13,36 +13,6 @@ import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestClient_Authentication(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	mux.HandleFunc("/", testHandler("auth.xml")) | ||||
|  | ||||
| 	client := NewClient("user", "secret") | ||||
| 	client.authEndpoint = server.URL | ||||
|  | ||||
| 	credentialToken, err := client.Authentication(60, false) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken) | ||||
| } | ||||
|  | ||||
| func TestClient_Authentication_error(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	mux.HandleFunc("/", testHandler("auth_fault.xml")) | ||||
|  | ||||
| 	client := NewClient("user", "secret") | ||||
| 	client.authEndpoint = server.URL | ||||
|  | ||||
| 	_, err := client.Authentication(60, false) | ||||
| 	require.Error(t, err) | ||||
| } | ||||
|  | ||||
| func TestClient_GetDNSSettings(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| @@ -50,12 +20,10 @@ func TestClient_GetDNSSettings(t *testing.T) { | ||||
|  | ||||
| 	mux.HandleFunc("/", testHandler("get_dns_settings.xml")) | ||||
|  | ||||
| 	client := NewClient("user", "secret") | ||||
| 	client.apiEndpoint = server.URL | ||||
| 	client := NewClient("user") | ||||
| 	client.baseURL = server.URL | ||||
|  | ||||
| 	token := "sha1secret" | ||||
|  | ||||
| 	records, err := client.GetDNSSettings(token, "example.com", "") | ||||
| 	records, err := client.GetDNSSettings(mockContext(), "example.com", "") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := []ReturnInfo{ | ||||
| @@ -134,10 +102,8 @@ func TestClient_AddDNSSettings(t *testing.T) { | ||||
|  | ||||
| 	mux.HandleFunc("/", testHandler("add_dns_settings.xml")) | ||||
|  | ||||
| 	client := NewClient("user", "secret") | ||||
| 	client.apiEndpoint = server.URL | ||||
|  | ||||
| 	token := "sha1secret" | ||||
| 	client := NewClient("user") | ||||
| 	client.baseURL = server.URL | ||||
|  | ||||
| 	record := DNSRequest{ | ||||
| 		ZoneHost:   "42cnc.de.", | ||||
| @@ -146,7 +112,7 @@ func TestClient_AddDNSSettings(t *testing.T) { | ||||
| 		RecordData: "abcdefgh", | ||||
| 	} | ||||
|  | ||||
| 	recordID, err := client.AddDNSSettings(token, record) | ||||
| 	recordID, err := client.AddDNSSettings(mockContext(), record) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	assert.Equal(t, "57347444", recordID) | ||||
| @@ -159,12 +125,10 @@ func TestClient_DeleteDNSSettings(t *testing.T) { | ||||
|  | ||||
| 	mux.HandleFunc("/", testHandler("delete_dns_settings.xml")) | ||||
|  | ||||
| 	client := NewClient("user", "secret") | ||||
| 	client.apiEndpoint = server.URL | ||||
| 	client := NewClient("user") | ||||
| 	client.baseURL = server.URL | ||||
|  | ||||
| 	token := "sha1secret" | ||||
|  | ||||
| 	r, err := client.DeleteDNSSettings(token, "57347450") | ||||
| 	r, err := client.DeleteDNSSettings(mockContext(), "57347450") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	assert.True(t, r) | ||||
|   | ||||
							
								
								
									
										104
									
								
								providers/dns/allinkl/internal/identity.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								providers/dns/allinkl/internal/identity.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| // authEndpoint represents the Identity API endpoint to call. | ||||
| const authEndpoint = "https://kasapi.kasserver.com/soap/KasAuth.php" | ||||
|  | ||||
| type token string | ||||
|  | ||||
| const tokenKey token = "token" | ||||
|  | ||||
| // Identifier generates credential tokens. | ||||
| type Identifier struct { | ||||
| 	login    string | ||||
| 	password string | ||||
|  | ||||
| 	authEndpoint string | ||||
| 	HTTPClient   *http.Client | ||||
| } | ||||
|  | ||||
| // NewIdentifier creates a new Identifier. | ||||
| func NewIdentifier(login string, password string) *Identifier { | ||||
| 	return &Identifier{ | ||||
| 		login:        login, | ||||
| 		password:     password, | ||||
| 		authEndpoint: authEndpoint, | ||||
| 		HTTPClient:   &http.Client{Timeout: 10 * time.Second}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Authentication Creates a credential token. | ||||
| // - sessionLifetime: Validity of the token in seconds. | ||||
| // - sessionUpdateLifetime: with `true` the session is extended with every request. | ||||
| func (c *Identifier) Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error) { | ||||
| 	sul := "N" | ||||
| 	if sessionUpdateLifetime { | ||||
| 		sul = "Y" | ||||
| 	} | ||||
|  | ||||
| 	ar := AuthRequest{ | ||||
| 		Login:                 c.login, | ||||
| 		AuthData:              c.password, | ||||
| 		AuthType:              "plain", | ||||
| 		SessionLifetime:       sessionLifetime, | ||||
| 		SessionUpdateLifetime: sul, | ||||
| 	} | ||||
|  | ||||
| 	body, err := json.Marshal(ar) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("failed to create request JSON body: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body))) | ||||
|  | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authEndpoint, bytes.NewReader(payload)) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return "", errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return "", errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	envlp, err := decodeXML[KasAuthEnvelope](resp.Body) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	if envlp.Body.Fault != nil { | ||||
| 		return "", envlp.Body.Fault | ||||
| 	} | ||||
|  | ||||
| 	return envlp.Body.KasAuthResponse.Return.Text, nil | ||||
| } | ||||
|  | ||||
| func WithContext(ctx context.Context, credential string) context.Context { | ||||
| 	return context.WithValue(ctx, tokenKey, credential) | ||||
| } | ||||
|  | ||||
| func getToken(ctx context.Context) string { | ||||
| 	credential, ok := ctx.Value(tokenKey).(string) | ||||
| 	if !ok { | ||||
| 		return "" | ||||
| 	} | ||||
|  | ||||
| 	return credential | ||||
| } | ||||
							
								
								
									
										45
									
								
								providers/dns/allinkl/internal/identity_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								providers/dns/allinkl/internal/identity_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func mockContext() context.Context { | ||||
| 	return context.WithValue(context.Background(), tokenKey, "593959ca04f0de9689b586c6a647d15d") | ||||
| } | ||||
|  | ||||
| func TestIdentifier_Authentication(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	mux.HandleFunc("/", testHandler("auth.xml")) | ||||
|  | ||||
| 	client := NewIdentifier("user", "secret") | ||||
| 	client.authEndpoint = server.URL | ||||
|  | ||||
| 	credentialToken, err := client.Authentication(context.Background(), 60, false) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken) | ||||
| } | ||||
|  | ||||
| func TestIdentifier_Authentication_error(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	mux.HandleFunc("/", testHandler("auth_fault.xml")) | ||||
|  | ||||
| 	client := NewIdentifier("user", "secret") | ||||
| 	client.authEndpoint = server.URL | ||||
|  | ||||
| 	_, err := client.Authentication(context.Background(), 60, false) | ||||
| 	require.Error(t, err) | ||||
| } | ||||
| @@ -4,6 +4,7 @@ import ( | ||||
| 	"bytes" | ||||
| 	"encoding/xml" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| ) | ||||
|  | ||||
| // Trimmer trim all XML fields. | ||||
| @@ -44,3 +45,18 @@ type Item struct { | ||||
| 	Value *Item   `xml:"value" json:"value,omitempty"` | ||||
| 	Items []*Item `xml:"item" json:"item,omitempty"` | ||||
| } | ||||
|  | ||||
| func decodeXML[T any](reader io.Reader) (*T, error) { | ||||
| 	raw, err := io.ReadAll(reader) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("read response body: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var result T | ||||
| 	err = xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(raw))}).Decode(&result) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("decode XML response: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return &result, nil | ||||
| } | ||||
|   | ||||
| @@ -35,7 +35,7 @@ type KasRequest struct { | ||||
| 	// Action API function. | ||||
| 	Action string `json:"kas_action,omitempty"` | ||||
| 	// RequestParams Parameters to the API function. | ||||
| 	RequestParams interface{} `json:"KasRequestParams,omitempty"` | ||||
| 	RequestParams any `json:"KasRequestParams,omitempty"` | ||||
| } | ||||
|  | ||||
| type DNSRequest struct { | ||||
| @@ -64,13 +64,13 @@ type GetDNSSettingsResponse struct { | ||||
| } | ||||
|  | ||||
| type ReturnInfo struct { | ||||
| 	ID         interface{} `json:"record_id,omitempty" mapstructure:"record_id"` | ||||
| 	Zone       string      `json:"record_zone,omitempty" mapstructure:"record_zone"` | ||||
| 	Name       string      `json:"record_name,omitempty" mapstructure:"record_name"` | ||||
| 	Type       string      `json:"record_type,omitempty" mapstructure:"record_type"` | ||||
| 	Data       string      `json:"record_data,omitempty" mapstructure:"record_data"` | ||||
| 	Changeable string      `json:"record_changeable,omitempty" mapstructure:"record_changeable"` | ||||
| 	Aux        int         `json:"record_aux,omitempty" mapstructure:"record_aux"` | ||||
| 	ID         any    `json:"record_id,omitempty" mapstructure:"record_id"` | ||||
| 	Zone       string `json:"record_zone,omitempty" mapstructure:"record_zone"` | ||||
| 	Name       string `json:"record_name,omitempty" mapstructure:"record_name"` | ||||
| 	Type       string `json:"record_type,omitempty" mapstructure:"record_type"` | ||||
| 	Data       string `json:"record_data,omitempty" mapstructure:"record_data"` | ||||
| 	Changeable string `json:"record_changeable,omitempty" mapstructure:"record_changeable"` | ||||
| 	Aux        int    `json:"record_aux,omitempty" mapstructure:"record_aux"` | ||||
| } | ||||
|  | ||||
| type AddDNSSettingsAPIResponse struct { | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| package arvancloud | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -108,11 +109,13 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { | ||||
| func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	authZone, err := getZone(info.EffectiveFQDN) | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("arvancloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	authZone = dns01.UnFqdn(authZone) | ||||
|  | ||||
| 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("arvancloud: %w", err) | ||||
| @@ -131,7 +134,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	newRecord, err := d.client.CreateRecord(authZone, record) | ||||
| 	newRecord, err := d.client.CreateRecord(context.Background(), authZone, record) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("arvancloud: failed to add TXT record: fqdn=%s: %w", info.EffectiveFQDN, err) | ||||
| 	} | ||||
| @@ -147,11 +150,13 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	authZone, err := getZone(info.EffectiveFQDN) | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("arvancloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	authZone = dns01.UnFqdn(authZone) | ||||
|  | ||||
| 	// gets the record's unique ID from when we created it | ||||
| 	d.recordIDsMu.Lock() | ||||
| 	recordID, ok := d.recordIDs[token] | ||||
| @@ -160,7 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 		return fmt.Errorf("arvancloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) | ||||
| 	} | ||||
|  | ||||
| 	if err := d.client.DeleteRecord(authZone, recordID); err != nil { | ||||
| 	if err := d.client.DeleteRecord(context.Background(), authZone, recordID); err != nil { | ||||
| 		return fmt.Errorf("arvancloud: failed to delate TXT record: id=%s: %w", recordID, err) | ||||
| 	} | ||||
|  | ||||
| @@ -171,12 +176,3 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func getZone(fqdn string) (string, error) { | ||||
| 	authZone, err := dns01.FindZoneByFqdn(fqdn) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	return dns01.UnFqdn(authZone), nil | ||||
| } | ||||
|   | ||||
| @@ -2,39 +2,45 @@ package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| // defaultBaseURL represents the API endpoint to call. | ||||
| const defaultBaseURL = "https://napi.arvancloud.ir" | ||||
|  | ||||
| const authHeader = "Authorization" | ||||
| const authorizationHeader = "Authorization" | ||||
|  | ||||
| // Client the ArvanCloud client. | ||||
| type Client struct { | ||||
| 	HTTPClient *http.Client | ||||
| 	BaseURL    string | ||||
|  | ||||
| 	apiKey string | ||||
|  | ||||
| 	baseURL    *url.URL | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient Creates a new ArvanCloud client. | ||||
| // NewClient Creates a new Client. | ||||
| func NewClient(apiKey string) *Client { | ||||
| 	baseURL, _ := url.Parse(defaultBaseURL) | ||||
|  | ||||
| 	return &Client{ | ||||
| 		HTTPClient: http.DefaultClient, | ||||
| 		BaseURL:    defaultBaseURL, | ||||
| 		apiKey:     apiKey, | ||||
| 		baseURL:    baseURL, | ||||
| 		HTTPClient: &http.Client{Timeout: 5 * time.Second}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // GetTxtRecord gets a TXT record. | ||||
| func (c *Client) GetTxtRecord(domain, name, value string) (*DNSRecord, error) { | ||||
| 	records, err := c.getRecords(domain, name) | ||||
| func (c *Client) GetTxtRecord(ctx context.Context, domain, name, value string) (*DNSRecord, error) { | ||||
| 	records, err := c.getRecords(ctx, domain, name) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| @@ -49,11 +55,8 @@ func (c *Client) GetTxtRecord(domain, name, value string) (*DNSRecord, error) { | ||||
| } | ||||
|  | ||||
| // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.list | ||||
| func (c *Client) getRecords(domain, search string) ([]DNSRecord, error) { | ||||
| 	endpoint, err := c.createEndpoint("cdn", "4.0", "domains", domain, "dns-records") | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create endpoint: %w", err) | ||||
| 	} | ||||
| func (c *Client) getRecords(ctx context.Context, domain, search string) ([]DNSRecord, error) { | ||||
| 	endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records") | ||||
|  | ||||
| 	if search != "" { | ||||
| 		query := endpoint.Query() | ||||
| @@ -61,123 +64,110 @@ func (c *Client) getRecords(domain, search string) ([]DNSRecord, error) { | ||||
| 		endpoint.RawQuery = query.Encode() | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.do(http.MethodGet, endpoint.String(), nil) | ||||
| 	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	body, err := io.ReadAll(resp.Body) | ||||
| 	response := &apiResponse[[]DNSRecord]{} | ||||
| 	err = c.do(req, http.StatusOK, response) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to read response body: %w", err) | ||||
| 		return nil, fmt.Errorf("could not get records %s: Domain: %s: %w", search, domain, err) | ||||
| 	} | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return nil, fmt.Errorf("could not get records %s: Domain: %s; Status: %s; Body: %s", | ||||
| 			search, domain, resp.Status, string(body)) | ||||
| 	} | ||||
|  | ||||
| 	response := &apiResponse{} | ||||
| 	err = json.Unmarshal(body, response) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to decode response body: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var records []DNSRecord | ||||
| 	err = json.Unmarshal(response.Data, &records) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to decode records: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return records, nil | ||||
| 	return response.Data, nil | ||||
| } | ||||
|  | ||||
| // CreateRecord creates a DNS record. | ||||
| // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.create | ||||
| func (c *Client) CreateRecord(domain string, record DNSRecord) (*DNSRecord, error) { | ||||
| 	reqBody, err := json.Marshal(record) | ||||
| func (c *Client) CreateRecord(ctx context.Context, domain string, record DNSRecord) (*DNSRecord, error) { | ||||
| 	endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	endpoint, err := c.createEndpoint("cdn", "4.0", "domains", domain, "dns-records") | ||||
| 	response := &apiResponse[*DNSRecord]{} | ||||
| 	err = c.do(req, http.StatusCreated, response) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create endpoint: %w", err) | ||||
| 		return nil, fmt.Errorf("could not create record; Domain: %s: %w", domain, err) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.do(http.MethodPost, endpoint.String(), bytes.NewReader(reqBody)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	body, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to read response body: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusCreated { | ||||
| 		return nil, fmt.Errorf("could not create record %s; Domain: %s; Status: %s; Body: %s", string(reqBody), domain, resp.Status, string(body)) | ||||
| 	} | ||||
|  | ||||
| 	response := &apiResponse{} | ||||
| 	err = json.Unmarshal(body, response) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to decode response body: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var newRecord DNSRecord | ||||
| 	err = json.Unmarshal(response.Data, &newRecord) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to decode record: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return &newRecord, nil | ||||
| 	return response.Data, nil | ||||
| } | ||||
|  | ||||
| // DeleteRecord deletes a DNS record. | ||||
| // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.remove | ||||
| func (c *Client) DeleteRecord(domain, id string) error { | ||||
| 	endpoint, err := c.createEndpoint("cdn", "4.0", "domains", domain, "dns-records", id) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to create endpoint: %w", err) | ||||
| 	} | ||||
| func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error { | ||||
| 	endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records", id) | ||||
|  | ||||
| 	resp, err := c.do(http.MethodDelete, endpoint.String(), nil) | ||||
| 	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		body, _ := io.ReadAll(resp.Body) | ||||
| 		return fmt.Errorf("could not delete record %s; Domain: %s; Status: %s; Body: %s", id, domain, resp.Status, string(body)) | ||||
| 	err = c.do(req, http.StatusOK, nil) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("could not delete record %s; Domain: %s: %w", id, domain, err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (c *Client) do(method, endpoint string, body io.Reader) (*http.Response, error) { | ||||
| 	req, err := http.NewRequest(method, endpoint, body) | ||||
| func (c *Client) do(req *http.Request, expectedStatus int, result any) error { | ||||
| 	req.Header.Set(authorizationHeader, c.apiKey) | ||||
|  | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != expectedStatus { | ||||
| 		return errutils.NewUnexpectedResponseStatusCodeError(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 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 body != nil { | ||||
|  | ||||
| 	if payload != nil { | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 	} | ||||
| 	req.Header.Set(authHeader, c.apiKey) | ||||
|  | ||||
| 	return c.HTTPClient.Do(req) | ||||
| } | ||||
|  | ||||
| func (c *Client) createEndpoint(parts ...string) (*url.URL, error) { | ||||
| 	baseURL, err := url.Parse(c.BaseURL) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return baseURL.JoinPath(parts...), nil | ||||
| 	return req, nil | ||||
| } | ||||
|  | ||||
| func equalsTXTRecord(record DNSRecord, name, value string) bool { | ||||
| @@ -189,7 +179,7 @@ func equalsTXTRecord(record DNSRecord, name, value string) bool { | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	data, ok := record.Value.(map[string]interface{}) | ||||
| 	data, ok := record.Value.(map[string]any) | ||||
| 	if !ok { | ||||
| 		return false | ||||
| 	} | ||||
|   | ||||
| @@ -1,10 +1,12 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"testing" | ||||
|  | ||||
| @@ -12,21 +14,34 @@ import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestClient_GetTxtRecord(t *testing.T) { | ||||
| func setupTest(t *testing.T, apiKey string) (*Client, *http.ServeMux) { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	const domain = "example.com" | ||||
| 	client := NewClient(apiKey) | ||||
| 	client.baseURL, _ = url.Parse(server.URL) | ||||
| 	client.HTTPClient = server.Client() | ||||
|  | ||||
| 	return client, mux | ||||
| } | ||||
|  | ||||
| func TestClient_GetTxtRecord(t *testing.T) { | ||||
| 	const apiKey = "myKeyA" | ||||
|  | ||||
| 	client, mux := setupTest(t, apiKey) | ||||
|  | ||||
| 	const domain = "example.com" | ||||
|  | ||||
| 	mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodGet { | ||||
| 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		auth := req.Header.Get(authHeader) | ||||
| 		auth := req.Header.Get(authorizationHeader) | ||||
| 		if auth != apiKey { | ||||
| 			http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) | ||||
| 			return | ||||
| @@ -46,20 +61,16 @@ func TestClient_GetTxtRecord(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	client := NewClient(apiKey) | ||||
| 	client.BaseURL = server.URL | ||||
|  | ||||
| 	_, err := client.GetTxtRecord(domain, "_acme-challenge", "txtxtxt") | ||||
| 	_, err := client.GetTxtRecord(context.Background(), domain, "_acme-challenge", "txtxtxt") | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
|  | ||||
| func TestClient_CreateRecord(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
| 	const apiKey = "myKeyB" | ||||
|  | ||||
| 	client, mux := setupTest(t, apiKey) | ||||
|  | ||||
| 	const domain = "example.com" | ||||
| 	const apiKey = "myKeyB" | ||||
|  | ||||
| 	mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodPost { | ||||
| @@ -67,7 +78,7 @@ func TestClient_CreateRecord(t *testing.T) { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		auth := req.Header.Get(authHeader) | ||||
| 		auth := req.Header.Get(authorizationHeader) | ||||
| 		if auth != apiKey { | ||||
| 			http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) | ||||
| 			return | ||||
| @@ -88,9 +99,6 @@ func TestClient_CreateRecord(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	client := NewClient(apiKey) | ||||
| 	client.BaseURL = server.URL | ||||
|  | ||||
| 	record := DNSRecord{ | ||||
| 		Name:  "_acme-challenge", | ||||
| 		Type:  "txt", | ||||
| @@ -98,7 +106,7 @@ func TestClient_CreateRecord(t *testing.T) { | ||||
| 		TTL:   600, | ||||
| 	} | ||||
|  | ||||
| 	newRecord, err := client.CreateRecord(domain, record) | ||||
| 	newRecord, err := client.CreateRecord(context.Background(), domain, record) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &DNSRecord{ | ||||
| @@ -119,12 +127,11 @@ func TestClient_CreateRecord(t *testing.T) { | ||||
| } | ||||
|  | ||||
| func TestClient_DeleteRecord(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
| 	const apiKey = "myKeyC" | ||||
|  | ||||
| 	client, mux := setupTest(t, apiKey) | ||||
|  | ||||
| 	const domain = "example.com" | ||||
| 	const apiKey = "myKeyC" | ||||
| 	const recordID = "recordId" | ||||
|  | ||||
| 	mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records/"+recordID, func(rw http.ResponseWriter, req *http.Request) { | ||||
| @@ -133,16 +140,13 @@ func TestClient_DeleteRecord(t *testing.T) { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		auth := req.Header.Get(authHeader) | ||||
| 		auth := req.Header.Get(authorizationHeader) | ||||
| 		if auth != apiKey { | ||||
| 			http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	client := NewClient(apiKey) | ||||
| 	client.BaseURL = server.URL | ||||
|  | ||||
| 	err := client.DeleteRecord(domain, recordID) | ||||
| 	err := client.DeleteRecord(context.Background(), domain, recordID) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
|   | ||||
| @@ -1,17 +1,15 @@ | ||||
| package internal | ||||
| 
 | ||||
| import "encoding/json" | ||||
| 
 | ||||
| type apiResponse struct { | ||||
| 	Message string          `json:"message"` | ||||
| 	Data    json.RawMessage `json:"data"` | ||||
| type apiResponse[T any] struct { | ||||
| 	Message string `json:"message"` | ||||
| 	Data    T      `json:"data"` | ||||
| } | ||||
| 
 | ||||
| // DNSRecord a DNS record. | ||||
| type DNSRecord struct { | ||||
| 	ID            string        `json:"id,omitempty"` | ||||
| 	Type          string        `json:"type"` | ||||
| 	Value         interface{}   `json:"value,omitempty"` | ||||
| 	Value         any           `json:"value,omitempty"` | ||||
| 	Name          string        `json:"name,omitempty"` | ||||
| 	TTL           int           `json:"ttl,omitempty"` | ||||
| 	UpstreamHTTPS string        `json:"upstream_https,omitempty"` | ||||
| @@ -108,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("aurora: could not determine zone for domain %q: %w", domain, err) | ||||
| 		return fmt.Errorf("aurora: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	// 1. Aurora will happily create the TXT record when it is provided a fqdn, | ||||
| @@ -155,24 +155,24 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	d.recordIDsMu.Unlock() | ||||
|  | ||||
| 	if !ok { | ||||
| 		return fmt.Errorf("unknown recordID for %q", info.EffectiveFQDN) | ||||
| 		return fmt.Errorf("aurora: unknown recordID for %q", info.EffectiveFQDN) | ||||
| 	} | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("could not determine zone for domain %q: %w", domain, err) | ||||
| 		return fmt.Errorf("aurora: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	authZone = dns01.UnFqdn(authZone) | ||||
|  | ||||
| 	zone, err := d.getZoneInformationByName(authZone) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("aurora: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	_, _, err = d.client.DeleteRecord(zone.ID, recordID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("aurora: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	d.recordIDsMu.Lock() | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| package autodns | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -10,6 +11,7 @@ import ( | ||||
|  | ||||
| 	"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/autodns/internal" | ||||
| ) | ||||
|  | ||||
| // Environment variables names. | ||||
| @@ -27,11 +29,6 @@ const ( | ||||
| 	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	defaultEndpointContext int = 4 | ||||
| 	defaultTTL             int = 600 | ||||
| ) | ||||
|  | ||||
| // Config is used to configure the creation of the DNSProvider. | ||||
| type Config struct { | ||||
| 	Endpoint           *url.URL | ||||
| @@ -46,12 +43,12 @@ type Config struct { | ||||
|  | ||||
| // NewDefaultConfig returns a default configuration for the DNSProvider. | ||||
| func NewDefaultConfig() *Config { | ||||
| 	endpoint, _ := url.Parse(env.GetOrDefaultString(EnvAPIEndpoint, defaultEndpoint)) | ||||
| 	endpoint, _ := url.Parse(env.GetOrDefaultString(EnvAPIEndpoint, internal.DefaultEndpoint)) | ||||
|  | ||||
| 	return &Config{ | ||||
| 		Endpoint:           endpoint, | ||||
| 		Context:            env.GetOrDefaultInt(EnvAPIEndpointContext, defaultEndpointContext), | ||||
| 		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL), | ||||
| 		Context:            env.GetOrDefaultInt(EnvAPIEndpointContext, internal.DefaultEndpointContext), | ||||
| 		TTL:                env.GetOrDefaultInt(EnvTTL, 600), | ||||
| 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), | ||||
| 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), | ||||
| 		HTTPClient: &http.Client{ | ||||
| @@ -63,6 +60,7 @@ func NewDefaultConfig() *Config { | ||||
| // DNSProvider implements the challenge.Provider interface. | ||||
| type DNSProvider struct { | ||||
| 	config *Config | ||||
| 	client *internal.Client | ||||
| } | ||||
|  | ||||
| // NewDNSProvider returns a DNSProvider instance configured for autoDNS. | ||||
| @@ -94,7 +92,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 		return nil, errors.New("autodns: missing password") | ||||
| 	} | ||||
|  | ||||
| 	return &DNSProvider{config: config}, nil | ||||
| 	client := internal.NewClient(config.Username, config.Password, config.Context) | ||||
|  | ||||
| 	if config.Endpoint != nil { | ||||
| 		client.BaseURL = config.Endpoint | ||||
| 	} | ||||
|  | ||||
| 	if config.HTTPClient != nil { | ||||
| 		client.HTTPClient = config.HTTPClient | ||||
| 	} | ||||
|  | ||||
| 	return &DNSProvider{config: config, client: client}, nil | ||||
| } | ||||
|  | ||||
| // Timeout returns the timeout and interval to use when checking for DNS propagation. | ||||
| @@ -107,7 +115,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { | ||||
| func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	records := []*ResourceRecord{{ | ||||
| 	records := []*internal.ResourceRecord{{ | ||||
| 		Name:  info.EffectiveFQDN, | ||||
| 		TTL:   int64(d.config.TTL), | ||||
| 		Type:  "TXT", | ||||
| @@ -115,7 +123,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	}} | ||||
|  | ||||
| 	// TODO(ldez) replace domain by FQDN to follow CNAME. | ||||
| 	_, err := d.addTxtRecord(domain, records) | ||||
| 	_, err := d.client.AddTxtRecords(context.Background(), domain, records) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("autodns: %w", err) | ||||
| 	} | ||||
| @@ -127,7 +135,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	records := []*ResourceRecord{{ | ||||
| 	records := []*internal.ResourceRecord{{ | ||||
| 		Name:  info.EffectiveFQDN, | ||||
| 		TTL:   int64(d.config.TTL), | ||||
| 		Type:  "TXT", | ||||
| @@ -135,7 +143,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	}} | ||||
|  | ||||
| 	// TODO(ldez) replace domain by FQDN to follow CNAME. | ||||
| 	if err := d.removeTXTRecord(domain, records); err != nil { | ||||
| 	if err := d.client.RemoveTXTRecords(context.Background(), domain, records); err != nil { | ||||
| 		return fmt.Errorf("autodns: %w", err) | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -1,159 +0,0 @@ | ||||
| package autodns | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	defaultEndpoint = "https://api.autodns.com/v1/" | ||||
| ) | ||||
|  | ||||
| type ResponseMessage struct { | ||||
| 	Text     string   `json:"text"` | ||||
| 	Messages []string `json:"messages"` | ||||
| 	Objects  []string `json:"objects"` | ||||
| 	Code     string   `json:"code"` | ||||
| 	Status   string   `json:"status"` | ||||
| } | ||||
|  | ||||
| type ResponseStatus struct { | ||||
| 	Code string `json:"code"` | ||||
| 	Text string `json:"text"` | ||||
| 	Type string `json:"type"` | ||||
| } | ||||
|  | ||||
| type ResponseObject struct { | ||||
| 	Type    string `json:"type"` | ||||
| 	Value   string `json:"value"` | ||||
| 	Summary int32  `json:"summary"` | ||||
| 	Data    string | ||||
| } | ||||
|  | ||||
| type DataZoneResponse struct { | ||||
| 	STID     string             `json:"stid"` | ||||
| 	CTID     string             `json:"ctid"` | ||||
| 	Messages []*ResponseMessage `json:"messages"` | ||||
| 	Status   *ResponseStatus    `json:"status"` | ||||
| 	Object   interface{}        `json:"object"` | ||||
| 	Data     []*Zone            `json:"data"` | ||||
| } | ||||
|  | ||||
| // ResourceRecord holds a resource record. | ||||
| type ResourceRecord struct { | ||||
| 	Name  string `json:"name"` | ||||
| 	TTL   int64  `json:"ttl"` | ||||
| 	Type  string `json:"type"` | ||||
| 	Value string `json:"value"` | ||||
| 	Pref  int32  `json:"pref,omitempty"` | ||||
| } | ||||
|  | ||||
| // Zone is an autodns zone record with all for us relevant fields. | ||||
| type Zone struct { | ||||
| 	Name              string            `json:"origin"` | ||||
| 	ResourceRecords   []*ResourceRecord `json:"resourceRecords"` | ||||
| 	Action            string            `json:"action"` | ||||
| 	VirtualNameServer string            `json:"virtualNameServer"` | ||||
| } | ||||
|  | ||||
| type ZoneStream struct { | ||||
| 	Adds    []*ResourceRecord `json:"adds"` | ||||
| 	Removes []*ResourceRecord `json:"rems"` | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) addTxtRecord(domain string, records []*ResourceRecord) (*Zone, error) { | ||||
| 	zoneStream := &ZoneStream{Adds: records} | ||||
|  | ||||
| 	return d.makeZoneUpdateRequest(zoneStream, domain) | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) removeTXTRecord(domain string, records []*ResourceRecord) error { | ||||
| 	zoneStream := &ZoneStream{Removes: records} | ||||
|  | ||||
| 	_, err := d.makeZoneUpdateRequest(zoneStream, domain) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) makeZoneUpdateRequest(zoneStream *ZoneStream, domain string) (*Zone, error) { | ||||
| 	reqBody := &bytes.Buffer{} | ||||
| 	if err := json.NewEncoder(reqBody).Encode(zoneStream); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	endpoint := d.config.Endpoint.JoinPath("zone", domain, "_stream") | ||||
|  | ||||
| 	req, err := d.makeRequest(http.MethodPost, endpoint.String(), reqBody) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	var resp *Zone | ||||
| 	if err := d.sendRequest(req, &resp); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return resp, nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) makeRequest(method, endpoint string, body io.Reader) (*http.Request, error) { | ||||
| 	req, err := http.NewRequest(method, endpoint, body) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	req.Header.Set("Content-Type", "application/json") | ||||
| 	req.Header.Set("X-Domainrobot-Context", strconv.Itoa(d.config.Context)) | ||||
| 	req.SetBasicAuth(d.config.Username, d.config.Password) | ||||
|  | ||||
| 	return req, nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) sendRequest(req *http.Request, result interface{}) error { | ||||
| 	resp, err := d.config.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err = checkResponse(resp); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if result == nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	err = json.Unmarshal(raw, result) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("unmarshaling %T error [status code=%d]: %w: %s", result, resp.StatusCode, err, string(raw)) | ||||
| 	} | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func checkResponse(resp *http.Response) error { | ||||
| 	if resp.StatusCode < http.StatusBadRequest { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if resp.Body == nil { | ||||
| 		return fmt.Errorf("response body is nil, status code=%d", resp.StatusCode) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("unable to read body: status code=%d, error=%w", resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	return fmt.Errorf("status code=%d: %s", resp.StatusCode, string(raw)) | ||||
| } | ||||
							
								
								
									
										132
									
								
								providers/dns/autodns/internal/client.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								providers/dns/autodns/internal/client.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| // DefaultEndpoint default API endpoint. | ||||
| const DefaultEndpoint = "https://api.autodns.com/v1/" | ||||
|  | ||||
| // DefaultEndpointContext default API endpoint context. | ||||
| const DefaultEndpointContext int = 4 | ||||
|  | ||||
| // Client the Autodns API client. | ||||
| type Client struct { | ||||
| 	username string | ||||
| 	password string | ||||
| 	context  int | ||||
|  | ||||
| 	BaseURL    *url.URL | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient creates a new Client. | ||||
| func NewClient(username string, password string, clientContext int) *Client { | ||||
| 	baseURL, _ := url.Parse(DefaultEndpoint) | ||||
|  | ||||
| 	return &Client{ | ||||
| 		username:   username, | ||||
| 		password:   password, | ||||
| 		context:    clientContext, | ||||
| 		BaseURL:    baseURL, | ||||
| 		HTTPClient: &http.Client{Timeout: 5 * time.Second}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // AddTxtRecords adds TXT records. | ||||
| func (c *Client) AddTxtRecords(ctx context.Context, domain string, records []*ResourceRecord) (*Zone, error) { | ||||
| 	zoneStream := &ZoneStream{Adds: records} | ||||
|  | ||||
| 	return c.updateZone(ctx, domain, zoneStream) | ||||
| } | ||||
|  | ||||
| // RemoveTXTRecords removes TXT records. | ||||
| func (c *Client) RemoveTXTRecords(ctx context.Context, domain string, records []*ResourceRecord) error { | ||||
| 	zoneStream := &ZoneStream{Removes: records} | ||||
|  | ||||
| 	_, err := c.updateZone(ctx, domain, zoneStream) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // https://github.com/InterNetX/domainrobot-api/blob/bdc8fe92a2f32fcbdb29e30bf6006ab446f81223/src/domainrobot.json#L21090 | ||||
| func (c *Client) updateZone(ctx context.Context, domain string, zoneStream *ZoneStream) (*Zone, error) { | ||||
| 	endpoint := c.BaseURL.JoinPath("zone", domain, "_stream") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zoneStream) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	var zone *Zone | ||||
| 	if err := c.do(req, &zone); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return zone, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) do(req *http.Request, result any) error { | ||||
| 	req.Header.Set("X-Domainrobot-Context", strconv.Itoa(c.context)) | ||||
| 	req.SetBasicAuth(c.username, c.password) | ||||
|  | ||||
| 	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 errutils.NewUnexpectedResponseStatusCodeError(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 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 | ||||
| } | ||||
							
								
								
									
										96
									
								
								providers/dns/autodns/internal/client_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								providers/dns/autodns/internal/client_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != method { | ||||
| 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		apiUser, apiKey, ok := req.BasicAuth() | ||||
| 		if apiUser != "user" || apiKey != "secret" || !ok { | ||||
| 			http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if file == "" { | ||||
| 			rw.WriteHeader(status) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		open, err := os.Open(filepath.Join("fixtures", file)) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		defer func() { _ = open.Close() }() | ||||
|  | ||||
| 		rw.WriteHeader(status) | ||||
| 		_, err = io.Copy(rw, open) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	client := NewClient("user", "secret", 123) | ||||
| 	client.HTTPClient = server.Client() | ||||
| 	client.BaseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 	return client | ||||
| } | ||||
|  | ||||
| func TestClient_AddTxtRecords(t *testing.T) { | ||||
| 	client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json") | ||||
|  | ||||
| 	records := []*ResourceRecord{{}} | ||||
|  | ||||
| 	zone, err := client.AddTxtRecords(context.Background(), "example.com", records) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &Zone{ | ||||
| 		Name: "example.com", | ||||
| 		ResourceRecords: []*ResourceRecord{{ | ||||
| 			Name:  "example.com", | ||||
| 			TTL:   120, | ||||
| 			Type:  "TXT", | ||||
| 			Value: "txt", | ||||
| 			Pref:  1, | ||||
| 		}}, | ||||
| 		Action:            "xxx", | ||||
| 		VirtualNameServer: "yyy", | ||||
| 	} | ||||
|  | ||||
| 	assert.Equal(t, expected, zone) | ||||
| } | ||||
|  | ||||
| func TestClient_RemoveTXTRecords(t *testing.T) { | ||||
| 	client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json") | ||||
|  | ||||
| 	records := []*ResourceRecord{{}} | ||||
|  | ||||
| 	err := client.RemoveTXTRecords(context.Background(), "example.com", records) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
							
								
								
									
										14
									
								
								providers/dns/autodns/internal/fixtures/add-record.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								providers/dns/autodns/internal/fixtures/add-record.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| { | ||||
|   "origin": "example.com", | ||||
|   "resourceRecords": [ | ||||
|     { | ||||
|       "name": "example.com", | ||||
|       "ttl": 120, | ||||
|       "type": "TXT", | ||||
|       "value": "txt", | ||||
|       "pref": 1 | ||||
|     } | ||||
|   ], | ||||
|   "action": "xxx", | ||||
|   "virtualNameServer": "yyy" | ||||
| } | ||||
							
								
								
									
										14
									
								
								providers/dns/autodns/internal/fixtures/remove-record.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								providers/dns/autodns/internal/fixtures/remove-record.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| { | ||||
|   "origin": "example.com", | ||||
|   "resourceRecords": [ | ||||
|     { | ||||
|       "name": "example.com", | ||||
|       "ttl": 120, | ||||
|       "type": "TXT", | ||||
|       "value": "txt", | ||||
|       "pref": 1 | ||||
|     } | ||||
|   ], | ||||
|   "action": "xxx", | ||||
|   "virtualNameServer": "yyy" | ||||
| } | ||||
							
								
								
									
										57
									
								
								providers/dns/autodns/internal/types.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								providers/dns/autodns/internal/types.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| package internal | ||||
|  | ||||
| type ResponseMessage struct { | ||||
| 	Text     string   `json:"text"` | ||||
| 	Messages []string `json:"messages"` | ||||
| 	Objects  []string `json:"objects"` | ||||
| 	Code     string   `json:"code"` | ||||
| 	Status   string   `json:"status"` | ||||
| } | ||||
|  | ||||
| type ResponseStatus struct { | ||||
| 	Code string `json:"code"` | ||||
| 	Text string `json:"text"` | ||||
| 	Type string `json:"type"` | ||||
| } | ||||
|  | ||||
| type ResponseObject struct { | ||||
| 	Type    string `json:"type"` | ||||
| 	Value   string `json:"value"` | ||||
| 	Summary int32  `json:"summary"` | ||||
| 	Data    string | ||||
| } | ||||
|  | ||||
| type DataZoneResponse struct { | ||||
| 	STID     string             `json:"stid"` | ||||
| 	CTID     string             `json:"ctid"` | ||||
| 	Messages []*ResponseMessage `json:"messages"` | ||||
| 	Status   *ResponseStatus    `json:"status"` | ||||
| 	Object   any                `json:"object"` | ||||
| 	Data     []*Zone            `json:"data"` | ||||
| } | ||||
|  | ||||
| // ResourceRecord holds a resource record. | ||||
| // https://help.internetx.com/display/APIXMLEN/Resource+Record+Object | ||||
| type ResourceRecord struct { | ||||
| 	Name  string `json:"name"` | ||||
| 	TTL   int64  `json:"ttl"` | ||||
| 	Type  string `json:"type"` | ||||
| 	Value string `json:"value"` | ||||
| 	Pref  int32  `json:"pref,omitempty"` | ||||
| } | ||||
|  | ||||
| // Zone is an autodns zone record with all for us relevant fields. | ||||
| // https://help.internetx.com/display/APIXMLEN/Zone+Object | ||||
| type Zone struct { | ||||
| 	Name              string            `json:"origin"` | ||||
| 	ResourceRecords   []*ResourceRecord `json:"resourceRecords"` | ||||
| 	Action            string            `json:"action"` | ||||
| 	VirtualNameServer string            `json:"virtualNameServer"` | ||||
| } | ||||
|  | ||||
| // ZoneStream body of the requests. | ||||
| // https://github.com/InterNetX/domainrobot-api/blob/bdc8fe92a2f32fcbdb29e30bf6006ab446f81223/src/domainrobot.json#L35914-L35932 | ||||
| type ZoneStream struct { | ||||
| 	Adds    []*ResourceRecord `json:"adds"` | ||||
| 	Removes []*ResourceRecord `json:"rems"` | ||||
| } | ||||
| @@ -7,6 +7,7 @@ import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/Azure/go-autorest/autorest" | ||||
| @@ -14,6 +15,7 @@ import ( | ||||
| 	"github.com/Azure/go-autorest/autorest/azure/auth" | ||||
| 	"github.com/go-acme/lego/v4/challenge" | ||||
| 	"github.com/go-acme/lego/v4/platform/config/env" | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| const defaultMetadataEndpoint = "http://169.254.169.254" | ||||
| @@ -122,7 +124,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 	} | ||||
|  | ||||
| 	if config.HTTPClient == nil { | ||||
| 		config.HTTPClient = http.DefaultClient | ||||
| 		config.HTTPClient = &http.Client{Timeout: 5 * time.Second} | ||||
| 	} | ||||
|  | ||||
| 	authorizer, err := getAuthorizer(config) | ||||
| @@ -208,8 +210,12 @@ func getMetadata(config *Config, field string) (string, error) { | ||||
| 		metadataEndpoint = defaultMetadataEndpoint | ||||
| 	} | ||||
|  | ||||
| 	resource := fmt.Sprintf("%s/metadata/instance/compute/%s", metadataEndpoint, field) | ||||
| 	req, err := http.NewRequest(http.MethodGet, resource, nil) | ||||
| 	endpoint, err := url.JoinPath(metadataEndpoint, "metadata", "instance", "compute", field) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| @@ -223,14 +229,15 @@ func getMetadata(config *Config, field string) (string, error) { | ||||
|  | ||||
| 	resp, err := config.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 		return "", errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	respBody, err := io.ReadAll(resp.Body) | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 		return "", errutils.NewReadResponseError(req, resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	return string(respBody), nil | ||||
| 	return string(raw), nil | ||||
| } | ||||
|   | ||||
| @@ -118,7 +118,7 @@ func (d *dnsProviderPrivate) getHostedZoneID(ctx context.Context, fqdn string) ( | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(fqdn) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 		return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) | ||||
| 	} | ||||
|  | ||||
| 	dc := privatedns.NewPrivateZonesClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) | ||||
|   | ||||
| @@ -118,7 +118,7 @@ func (d *dnsProviderPublic) getHostedZoneID(ctx context.Context, fqdn string) (s | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(fqdn) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 		return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) | ||||
| 	} | ||||
|  | ||||
| 	dc := dns.NewZonesClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| package bluecat | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -97,7 +98,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 		return nil, errors.New("bluecat: credentials missing") | ||||
| 	} | ||||
|  | ||||
| 	client := internal.NewClient(config.BaseURL) | ||||
| 	client := internal.NewClient(config.BaseURL, config.UserName, config.Password) | ||||
|  | ||||
| 	if config.HTTPClient != nil { | ||||
| 		client.HTTPClient = config.HTTPClient | ||||
| @@ -112,17 +113,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	err := d.client.Login(d.config.UserName, d.config.Password) | ||||
| 	ctx, err := d.client.CreateAuthenticatedContext(context.Background()) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: login: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	viewID, err := d.client.LookupViewID(d.config.ConfigName, d.config.DNSView) | ||||
| 	viewID, err := d.client.LookupViewID(ctx, d.config.ConfigName, d.config.DNSView) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: lookupViewID: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	parentZoneID, name, err := d.client.LookupParentZoneID(viewID, info.EffectiveFQDN) | ||||
| 	parentZoneID, name, err := d.client.LookupParentZoneID(ctx, viewID, info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: lookupParentZoneID: %w", err) | ||||
| 	} | ||||
| @@ -137,17 +138,17 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 		Properties: fmt.Sprintf("ttl=%d|absoluteName=%s|txt=%s|", d.config.TTL, info.EffectiveFQDN, info.Value), | ||||
| 	} | ||||
|  | ||||
| 	_, err = d.client.AddEntity(parentZoneID, txtRecord) | ||||
| 	_, err = d.client.AddEntity(ctx, parentZoneID, txtRecord) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: add TXT record: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.Deploy(parentZoneID) | ||||
| 	err = d.client.Deploy(ctx, parentZoneID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: deploy: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.Logout() | ||||
| 	err = d.client.Logout(ctx) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: logout: %w", err) | ||||
| 	} | ||||
| @@ -159,37 +160,37 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	err := d.client.Login(d.config.UserName, d.config.Password) | ||||
| 	ctx, err := d.client.CreateAuthenticatedContext(context.Background()) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: login: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	viewID, err := d.client.LookupViewID(d.config.ConfigName, d.config.DNSView) | ||||
| 	viewID, err := d.client.LookupViewID(ctx, d.config.ConfigName, d.config.DNSView) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: lookupViewID: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	parentZoneID, name, err := d.client.LookupParentZoneID(viewID, info.EffectiveFQDN) | ||||
| 	parentZoneID, name, err := d.client.LookupParentZoneID(ctx, viewID, info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: lookupParentZoneID: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	txtRecord, err := d.client.GetEntityByName(parentZoneID, name, internal.TXTType) | ||||
| 	txtRecord, err := d.client.GetEntityByName(ctx, parentZoneID, name, internal.TXTType) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: get TXT record: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.Delete(txtRecord.ID) | ||||
| 	err = d.client.Delete(ctx, txtRecord.ID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: delete TXT record: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.Deploy(parentZoneID) | ||||
| 	err = d.client.Deploy(ctx, parentZoneID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: deploy: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.Logout() | ||||
| 	err = d.client.Logout(ctx) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("bluecat: logout: %w", err) | ||||
| 	} | ||||
|   | ||||
| @@ -2,14 +2,18 @@ package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"regexp" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| // Object types. | ||||
| @@ -20,153 +24,88 @@ const ( | ||||
| 	TXTType    = "TXTRecord" | ||||
| ) | ||||
|  | ||||
| const authorizationHeader = "Authorization" | ||||
|  | ||||
| type Client struct { | ||||
| 	HTTPClient *http.Client | ||||
| 	username string | ||||
| 	password string | ||||
|  | ||||
| 	baseURL string | ||||
|  | ||||
| 	token    string | ||||
| 	tokenExp *regexp.Regexp | ||||
|  | ||||
| 	baseURL    *url.URL | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| func NewClient(baseURL string) *Client { | ||||
| func NewClient(baseURL string, username, password string) *Client { | ||||
| 	bu, _ := url.Parse(baseURL) | ||||
|  | ||||
| 	return &Client{ | ||||
| 		HTTPClient: &http.Client{Timeout: 30 * time.Second}, | ||||
| 		baseURL:    baseURL, | ||||
| 		username:   username, | ||||
| 		password:   password, | ||||
| 		tokenExp:   regexp.MustCompile("BAMAuthToken: [^ ]+"), | ||||
| 		baseURL:    bu, | ||||
| 		HTTPClient: &http.Client{Timeout: 30 * time.Second}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Login Logs in as API user. | ||||
| // Authenticates and receives a token to be used in for subsequent requests. | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/login/9.1.0 | ||||
| func (c *Client) Login(username, password string) error { | ||||
| 	queryArgs := map[string]string{ | ||||
| 		"username": username, | ||||
| 		"password": password, | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.sendRequest(http.MethodGet, "login", nil, queryArgs) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		data, _ := io.ReadAll(resp.Body) | ||||
| 		return &APIError{ | ||||
| 			StatusCode: resp.StatusCode, | ||||
| 			Resource:   "login", | ||||
| 			Message:    string(data), | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	authBytes, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	authResp := string(authBytes) | ||||
| 	if strings.Contains(authResp, "Authentication Error") { | ||||
| 		return fmt.Errorf("request failed: %s", strings.Trim(authResp, `"`)) | ||||
| 	} | ||||
|  | ||||
| 	// Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username" | ||||
| 	c.token = c.tokenExp.FindString(authResp) | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Logout Logs out of the current API session. | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/logout/9.1.0 | ||||
| func (c *Client) Logout() error { | ||||
| 	if c.token == "" { | ||||
| 		// nothing to do | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.sendRequest(http.MethodGet, "logout", nil, nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		data, _ := io.ReadAll(resp.Body) | ||||
| 		return &APIError{ | ||||
| 			StatusCode: resp.StatusCode, | ||||
| 			Resource:   "logout", | ||||
| 			Message:    string(data), | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	authBytes, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	authResp := string(authBytes) | ||||
| 	if !strings.Contains(authResp, "successfully") { | ||||
| 		return fmt.Errorf("request failed to delete session: %s", strings.Trim(authResp, `"`)) | ||||
| 	} | ||||
|  | ||||
| 	c.token = "" | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Deploy the DNS config for the specified entity to the authoritative servers. | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/POST/v1/quickDeploy/9.1.0 | ||||
| func (c *Client) Deploy(entityID uint) error { | ||||
| 	queryArgs := map[string]string{ | ||||
| 		"entityId": strconv.FormatUint(uint64(entityID), 10), | ||||
| 	} | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/quickDeploy/9.5.0 | ||||
| func (c *Client) Deploy(ctx context.Context, entityID uint) error { | ||||
| 	endpoint := c.createEndpoint("quickDeploy") | ||||
|  | ||||
| 	resp, err := c.sendRequest(http.MethodPost, "quickDeploy", nil, queryArgs) | ||||
| 	q := endpoint.Query() | ||||
| 	q.Set("entityId", strconv.FormatUint(uint64(entityID), 10)) | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	resp, err := c.doAuthenticated(ctx, req) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	// The API doc says that 201 is expected but in the reality 200 is return. | ||||
| 	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { | ||||
| 		data, _ := io.ReadAll(resp.Body) | ||||
| 		return &APIError{ | ||||
| 			StatusCode: resp.StatusCode, | ||||
| 			Resource:   "quickDeploy", | ||||
| 			Message:    string(data), | ||||
| 		} | ||||
| 		return errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // AddEntity A generic method for adding configurations, DNS zones, and DNS resource records. | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/POST/v1/addEntity/9.1.0 | ||||
| func (c *Client) AddEntity(parentID uint, entity Entity) (uint64, error) { | ||||
| 	queryArgs := map[string]string{ | ||||
| 		"parentId": strconv.FormatUint(uint64(parentID), 10), | ||||
| 	} | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/addEntity/9.5.0 | ||||
| func (c *Client) AddEntity(ctx context.Context, parentID uint, entity Entity) (uint64, error) { | ||||
| 	endpoint := c.createEndpoint("addEntity") | ||||
|  | ||||
| 	resp, err := c.sendRequest(http.MethodPost, "addEntity", entity, queryArgs) | ||||
| 	q := endpoint.Query() | ||||
| 	q.Set("parentId", strconv.FormatUint(uint64(parentID), 10)) | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, entity) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		data, _ := io.ReadAll(resp.Body) | ||||
| 		return 0, &APIError{ | ||||
| 			StatusCode: resp.StatusCode, | ||||
| 			Resource:   "addEntity", | ||||
| 			Message:    string(data), | ||||
| 		} | ||||
| 	resp, err := c.doAuthenticated(ctx, req) | ||||
| 	if err != nil { | ||||
| 		return 0, errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	addTxtBytes, _ := io.ReadAll(resp.Body) | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return 0, errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	raw, _ := io.ReadAll(resp.Body) | ||||
|  | ||||
| 	// addEntity responds only with body text containing the ID of the created record | ||||
| 	addTxtResp := string(addTxtBytes) | ||||
| 	addTxtResp := string(raw) | ||||
| 	id, err := strconv.ParseUint(addTxtResp, 10, 64) | ||||
| 	if err != nil { | ||||
| 		return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp) | ||||
| @@ -176,73 +115,84 @@ func (c *Client) AddEntity(parentID uint, entity Entity) (uint64, error) { | ||||
| } | ||||
|  | ||||
| // GetEntityByName Returns objects from the database referenced by their database ID and with its properties fields populated. | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/getEntityById/9.1.0 | ||||
| func (c *Client) GetEntityByName(parentID uint, name, objType string) (*EntityResponse, error) { | ||||
| 	queryArgs := map[string]string{ | ||||
| 		"parentId": strconv.FormatUint(uint64(parentID), 10), | ||||
| 		"name":     name, | ||||
| 		"type":     objType, | ||||
| 	} | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/getEntityById/9.5.0 | ||||
| func (c *Client) GetEntityByName(ctx context.Context, parentID uint, name, objType string) (*EntityResponse, error) { | ||||
| 	endpoint := c.createEndpoint("getEntityByName") | ||||
|  | ||||
| 	resp, err := c.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs) | ||||
| 	q := endpoint.Query() | ||||
| 	q.Set("parentId", strconv.FormatUint(uint64(parentID), 10)) | ||||
| 	q.Set("name", name) | ||||
| 	q.Set("type", objType) | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	resp, err := c.doAuthenticated(ctx, req) | ||||
| 	if err != nil { | ||||
| 		return nil, errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		data, _ := io.ReadAll(resp.Body) | ||||
| 		return nil, &APIError{ | ||||
| 			StatusCode: resp.StatusCode, | ||||
| 			Resource:   "getEntityByName", | ||||
| 			Message:    string(data), | ||||
| 		} | ||||
| 		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	var txtRec EntityResponse | ||||
| 	if err = json.NewDecoder(resp.Body).Decode(&txtRec); err != nil { | ||||
| 		return nil, fmt.Errorf("JSON decode: %w", err) | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	return &txtRec, nil | ||||
| 	var entity EntityResponse | ||||
| 	err = json.Unmarshal(raw, &entity) | ||||
| 	if err != nil { | ||||
| 		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) | ||||
| 	} | ||||
|  | ||||
| 	return &entity, nil | ||||
| } | ||||
|  | ||||
| // Delete Deletes an object using the generic delete method. | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/DELETE/v1/delete/9.1.0 | ||||
| func (c *Client) Delete(objectID uint) error { | ||||
| 	queryArgs := map[string]string{ | ||||
| 		"objectId": strconv.FormatUint(uint64(objectID), 10), | ||||
| 	} | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/DELETE/v1/delete/9.5.0 | ||||
| func (c *Client) Delete(ctx context.Context, objectID uint) error { | ||||
| 	endpoint := c.createEndpoint("delete") | ||||
|  | ||||
| 	resp, err := c.sendRequest(http.MethodDelete, "delete", nil, queryArgs) | ||||
| 	q := endpoint.Query() | ||||
| 	q.Set("objectId", strconv.FormatUint(uint64(objectID), 10)) | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	defer resp.Body.Close() | ||||
| 	resp, err := c.doAuthenticated(ctx, req) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	// The API doc says that 204 is expected but in the reality 200 is return. | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	// The API doc says that 204 is expected but in the reality 200 is returned. | ||||
| 	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { | ||||
| 		data, _ := io.ReadAll(resp.Body) | ||||
| 		return &APIError{ | ||||
| 			StatusCode: resp.StatusCode, | ||||
| 			Resource:   "delete", | ||||
| 			Message:    string(data), | ||||
| 		} | ||||
| 		return errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // LookupViewID Find the DNS view with the given name within. | ||||
| func (c *Client) LookupViewID(configName, viewName string) (uint, error) { | ||||
| func (c *Client) LookupViewID(ctx context.Context, configName, viewName string) (uint, error) { | ||||
| 	// Lookup the entity ID of the configuration named in our properties. | ||||
| 	conf, err := c.GetEntityByName(0, configName, ConfigType) | ||||
| 	conf, err := c.GetEntityByName(ctx, 0, configName, ConfigType) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
|  | ||||
| 	view, err := c.GetEntityByName(conf.ID, viewName, ViewType) | ||||
| 	view, err := c.GetEntityByName(ctx, conf.ID, viewName, ViewType) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| @@ -252,7 +202,7 @@ func (c *Client) LookupViewID(configName, viewName string) (uint, error) { | ||||
|  | ||||
| // LookupParentZoneID Return the entityId of the parent zone by recursing from the root view. | ||||
| // Also return the simple name of the host. | ||||
| func (c *Client) LookupParentZoneID(viewID uint, fqdn string) (uint, string, error) { | ||||
| func (c *Client) LookupParentZoneID(ctx context.Context, viewID uint, fqdn string) (uint, string, error) { | ||||
| 	if fqdn == "" { | ||||
| 		return viewID, "", nil | ||||
| 	} | ||||
| @@ -263,7 +213,7 @@ func (c *Client) LookupParentZoneID(viewID uint, fqdn string) (uint, string, err | ||||
| 	parentViewID := viewID | ||||
|  | ||||
| 	for i := len(zones) - 1; i > -1; i-- { | ||||
| 		zone, err := c.GetEntityByName(parentViewID, zones[i], ZoneType) | ||||
| 		zone, err := c.GetEntityByName(ctx, parentViewID, zones[i], ZoneType) | ||||
| 		if err != nil { | ||||
| 			return 0, "", fmt.Errorf("could not find zone named %s: %w", name, err) | ||||
| 		} | ||||
| @@ -282,32 +232,39 @@ func (c *Client) LookupParentZoneID(viewID uint, fqdn string) (uint, string, err | ||||
| 	return parentViewID, name, nil | ||||
| } | ||||
|  | ||||
| // Send a REST request, using query parameters specified. | ||||
| // The Authorization header will be set if we have an active auth token. | ||||
| func (c *Client) sendRequest(method, resource string, payload interface{}, queryParams map[string]string) (*http.Response, error) { | ||||
| 	url := fmt.Sprintf("%s/Services/REST/v1/%s", c.baseURL, resource) | ||||
| func (c *Client) createEndpoint(resource string) *url.URL { | ||||
| 	return c.baseURL.JoinPath("Services", "REST", "v1", resource) | ||||
| } | ||||
|  | ||||
| 	body, err := json.Marshal(payload) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| func (c *Client) doAuthenticated(ctx context.Context, req *http.Request) (*http.Response, error) { | ||||
| 	tok := getToken(ctx) | ||||
| 	if tok != "" { | ||||
| 		req.Header.Set(authorizationHeader, tok) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(method, url, bytes.NewReader(body)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	req.Header.Set("Content-Type", "application/json") | ||||
|  | ||||
| 	if c.token != "" { | ||||
| 		req.Header.Set("Authorization", c.token) | ||||
| 	} | ||||
|  | ||||
| 	q := req.URL.Query() | ||||
| 	for k, v := range queryParams { | ||||
| 		q.Set(k, v) | ||||
| 	} | ||||
| 	req.URL.RawQuery = q.Encode() | ||||
|  | ||||
| 	return c.HTTPClient.Do(req) | ||||
| } | ||||
|  | ||||
| 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 | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| @@ -15,7 +16,8 @@ func TestClient_LookupParentZoneID(t *testing.T) { | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	client := NewClient(server.URL) | ||||
| 	client := NewClient(server.URL, "user", "secret") | ||||
| 	client.HTTPClient = server.Client() | ||||
|  | ||||
| 	mux.HandleFunc("/Services/REST/v1/getEntityByName", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		query := req.URL.Query() | ||||
| @@ -33,7 +35,7 @@ func TestClient_LookupParentZoneID(t *testing.T) { | ||||
| 		http.Error(rw, "{}", http.StatusOK) | ||||
| 	}) | ||||
|  | ||||
| 	parentID, name, err := client.LookupParentZoneID(2, "foo.example.com") | ||||
| 	parentID, name, err := client.LookupParentZoneID(context.Background(), 2, "foo.example.com") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	assert.EqualValues(t, 2, parentID) | ||||
|   | ||||
							
								
								
									
										115
									
								
								providers/dns/bluecat/internal/identity.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								providers/dns/bluecat/internal/identity.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| type token string | ||||
|  | ||||
| const tokenKey token = "token" | ||||
|  | ||||
| // login Logs in as API user. | ||||
| // Authenticates and receives a token to be used in for subsequent requests. | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/login/9.5.0 | ||||
| func (c *Client) login(ctx context.Context) (string, error) { | ||||
| 	endpoint := c.createEndpoint("login") | ||||
|  | ||||
| 	q := endpoint.Query() | ||||
| 	q.Set("username", c.username) | ||||
| 	q.Set("password", c.password) | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return "", errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return "", errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return "", errutils.NewReadResponseError(req, resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	authResp := string(raw) | ||||
| 	if strings.Contains(authResp, "Authentication Error") { | ||||
| 		return "", fmt.Errorf("request failed: %s", strings.Trim(authResp, `"`)) | ||||
| 	} | ||||
|  | ||||
| 	// Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username" | ||||
| 	tok := c.tokenExp.FindString(authResp) | ||||
|  | ||||
| 	return tok, nil | ||||
| } | ||||
|  | ||||
| // Logout Logs out of the current API session. | ||||
| // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/logout/9.5.0 | ||||
| func (c *Client) Logout(ctx context.Context) error { | ||||
| 	if getToken(ctx) == "" { | ||||
| 		// nothing to do | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	endpoint := c.createEndpoint("logout") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.doAuthenticated(ctx, req) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewReadResponseError(req, resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	authResp := string(raw) | ||||
| 	if !strings.Contains(authResp, "successfully") { | ||||
| 		return fmt.Errorf("request failed to delete session: %s", strings.Trim(authResp, `"`)) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { | ||||
| 	tok, err := c.login(ctx) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return context.WithValue(ctx, tokenKey, tok), nil | ||||
| } | ||||
|  | ||||
| func getToken(ctx context.Context) string { | ||||
| 	tok, ok := ctx.Value(tokenKey).(string) | ||||
| 	if !ok { | ||||
| 		return "" | ||||
| 	} | ||||
|  | ||||
| 	return tok | ||||
| } | ||||
							
								
								
									
										59
									
								
								providers/dns/bluecat/internal/identity_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								providers/dns/bluecat/internal/identity_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| const fakeToken = "BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM=" | ||||
|  | ||||
| func TestClient_CreateAuthenticatedContext(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	client := NewClient(server.URL, "user", "secret") | ||||
| 	client.HTTPClient = server.Client() | ||||
|  | ||||
| 	mux.HandleFunc("/Services/REST/v1/login", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodGet { | ||||
| 			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		query := req.URL.Query() | ||||
| 		if query.Get("username") != "user" { | ||||
| 			http.Error(rw, fmt.Sprintf("invalid username %s", query.Get("username")), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if query.Get("password") != "secret" { | ||||
| 			http.Error(rw, fmt.Sprintf("invalid password %s", query.Get("password")), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		_, _ = fmt.Fprint(rw, fakeToken) | ||||
| 	}) | ||||
| 	mux.HandleFunc("/Services/REST/v1/delete", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		authorization := req.Header.Get(authorizationHeader) | ||||
| 		if authorization != fakeToken { | ||||
| 			http.Error(rw, fmt.Sprintf("invalid credential: %s", authorization), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	ctx, err := client.CreateAuthenticatedContext(context.Background()) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	at := getToken(ctx) | ||||
| 	assert.Equal(t, fakeToken, at) | ||||
|  | ||||
| 	err = client.Delete(ctx, 123) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
| @@ -1,7 +1,5 @@ | ||||
| package internal | ||||
|  | ||||
| import "fmt" | ||||
|  | ||||
| // Entity JSON body for Bluecat entity requests. | ||||
| type Entity struct { | ||||
| 	ID         string `json:"id,omitempty"` | ||||
| @@ -17,13 +15,3 @@ type EntityResponse struct { | ||||
| 	Type       string `json:"type"` | ||||
| 	Properties string `json:"properties"` | ||||
| } | ||||
|  | ||||
| type APIError struct { | ||||
| 	StatusCode int | ||||
| 	Resource   string | ||||
| 	Message    string | ||||
| } | ||||
|  | ||||
| func (a APIError) Error() string { | ||||
| 	return fmt.Sprintf("resource: %s, status code: %d, message: %s", a.Resource, a.StatusCode, a.Message) | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| package brandit | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| @@ -12,8 +14,6 @@ import ( | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/brandit/internal" | ||||
| ) | ||||
|  | ||||
| const defaultTTL = 600 | ||||
|  | ||||
| // Environment variables names. | ||||
| const ( | ||||
| 	envNamespace = "BRANDIT_" | ||||
| @@ -21,11 +21,10 @@ const ( | ||||
| 	EnvAPIKey      = envNamespace + "API_KEY" | ||||
| 	EnvAPIUsername = envNamespace + "API_USERNAME" | ||||
|  | ||||
| 	EnvTTL                           = envNamespace + "TTL" | ||||
| 	EnvPropagationTimeout            = envNamespace + "PROPAGATION_TIMEOUT" | ||||
| 	EnvPollingInterval               = envNamespace + "POLLING_INTERVAL" | ||||
| 	EnvHTTPTimeout                   = envNamespace + "HTTP_TIMEOUT" | ||||
| 	DefaultBrandItPropagationTimeout = 600 * time.Second | ||||
| 	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. | ||||
| @@ -42,8 +41,8 @@ type Config struct { | ||||
| // NewDefaultConfig returns a default configuration for the DNSProvider. | ||||
| func NewDefaultConfig() *Config { | ||||
| 	return &Config{ | ||||
| 		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL), | ||||
| 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, DefaultBrandItPropagationTimeout), | ||||
| 		TTL:                env.GetOrDefaultInt(EnvTTL, 600), | ||||
| 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), | ||||
| 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), | ||||
| 		HTTPClient: &http.Client{ | ||||
| 			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), | ||||
| @@ -97,13 +96,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 	}, 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 using the specified parameters. | ||||
| func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("brandit: %w", err) | ||||
| 		return fmt.Errorf("brandit: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) | ||||
| @@ -111,6 +116,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 		return fmt.Errorf("brandit: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	record := internal.Record{ | ||||
| 		Type:    "TXT", | ||||
| 		Name:    subDomain, | ||||
| @@ -119,18 +126,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	} | ||||
|  | ||||
| 	// find the account associated with the domain | ||||
| 	account, err := d.client.StatusDomain(dns01.UnFqdn(authZone)) | ||||
| 	account, err := d.client.StatusDomain(ctx, dns01.UnFqdn(authZone)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("brandit: status domain: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// Find the next record id | ||||
| 	recordID, err := d.client.ListRecords(account.Response.Registrar[0], dns01.UnFqdn(authZone)) | ||||
| 	recordID, err := d.client.ListRecords(ctx, account.Registrar[0], dns01.UnFqdn(authZone)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("brandit: list records: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	result, err := d.client.AddRecord(dns01.UnFqdn(authZone), account.Response.Registrar[0], fmt.Sprint(recordID.Response.Total[0]), record) | ||||
| 	result, err := d.client.AddRecord(ctx, dns01.UnFqdn(authZone), account.Registrar[0], strconv.Itoa(recordID.Total[0]), record) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("brandit: add record: %w", err) | ||||
| 	} | ||||
| @@ -148,7 +155,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("brandit: %w", err) | ||||
| 		return fmt.Errorf("brandit: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	// gets the record's unique ID | ||||
| @@ -159,25 +166,27 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 		return fmt.Errorf("brandit: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) | ||||
| 	} | ||||
|  | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	// find the account associated with the domain | ||||
| 	account, err := d.client.StatusDomain(dns01.UnFqdn(authZone)) | ||||
| 	account, err := d.client.StatusDomain(ctx, dns01.UnFqdn(authZone)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("brandit: status domain: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	records, err := d.client.ListRecords(account.Response.Registrar[0], dns01.UnFqdn(authZone)) | ||||
| 	records, err := d.client.ListRecords(ctx, account.Registrar[0], dns01.UnFqdn(authZone)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("brandit: list records: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var recordID int | ||||
| 	for i, r := range records.Response.RR { | ||||
| 	for i, r := range records.RR { | ||||
| 		if r == dnsRecord { | ||||
| 			recordID = i | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	_, err = d.client.DeleteRecord(dns01.UnFqdn(authZone), account.Response.Registrar[0], dnsRecord, fmt.Sprint(recordID)) | ||||
| 	err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), account.Registrar[0], dnsRecord, strconv.Itoa(recordID)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("brandit: delete record: %w", err) | ||||
| 	} | ||||
| @@ -189,9 +198,3 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	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 | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| Name = "BRANDIT" | ||||
| Name = "Brandit" | ||||
| Description = '''''' | ||||
| URL = "https://www.brandit.com/" | ||||
| Code = "brandit" | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto/hmac" | ||||
| 	"crypto/sha256" | ||||
| 	"encoding/hex" | ||||
| @@ -12,6 +13,8 @@ import ( | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| const defaultBaseURL = "https://portal.brandit.com/api/v3/" | ||||
| @@ -20,8 +23,9 @@ const defaultBaseURL = "https://portal.brandit.com/api/v3/" | ||||
| type Client struct { | ||||
| 	apiUsername string | ||||
| 	apiKey      string | ||||
| 	BaseURL     string | ||||
| 	HTTPClient  *http.Client | ||||
|  | ||||
| 	baseURL    string | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient creates a new Client. | ||||
| @@ -33,70 +37,69 @@ func NewClient(apiUsername, apiKey string) (*Client, error) { | ||||
| 	return &Client{ | ||||
| 		apiUsername: apiUsername, | ||||
| 		apiKey:      apiKey, | ||||
| 		BaseURL:     defaultBaseURL, | ||||
| 		baseURL:     defaultBaseURL, | ||||
| 		HTTPClient:  &http.Client{Timeout: 10 * time.Second}, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // ListRecords lists all records. | ||||
| // https://portal.brandit.com/apidocv3#listDNSRR | ||||
| func (c *Client) ListRecords(account, dnsZone string) (*ListRecords, error) { | ||||
| 	// Create a new query | ||||
| func (c *Client) ListRecords(ctx context.Context, account, dnsZone string) (*ListRecordsResponse, error) { | ||||
| 	query := url.Values{} | ||||
| 	query.Add("command", "listDNSRR") | ||||
| 	query.Add("account", account) | ||||
| 	query.Add("dnszone", dnsZone) | ||||
|  | ||||
| 	result := &ListRecords{} | ||||
| 	result := &Response[*ListRecordsResponse]{} | ||||
|  | ||||
| 	err := c.do(query, result) | ||||
| 	err := c.do(ctx, query, result) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("do: %w", err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	for len(result.Response.RR) < result.Response.Total[0] { | ||||
| 		query.Add("first", fmt.Sprint(result.Response.Last[0]+1)) | ||||
|  | ||||
| 		tmp := &ListRecords{} | ||||
| 		err := c.do(query, tmp) | ||||
| 		tmp := &Response[*ListRecordsResponse]{} | ||||
| 		err := c.do(ctx, query, tmp) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("do: %w", err) | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		result.Response.RR = append(result.Response.RR, tmp.Response.RR...) | ||||
| 		result.Response.Last = tmp.Response.Last | ||||
| 	} | ||||
|  | ||||
| 	return result, nil | ||||
| 	return result.Response, nil | ||||
| } | ||||
|  | ||||
| // AddRecord adds a DNS record. | ||||
| // https://portal.brandit.com/apidocv3#addDNSRR | ||||
| func (c *Client) AddRecord(domainName, account, newRecordID string, record Record) (*AddRecord, error) { | ||||
| 	// Create a new query | ||||
| func (c *Client) AddRecord(ctx context.Context, domainName, account, newRecordID string, record Record) (*AddRecord, error) { | ||||
| 	value := strings.Join([]string{record.Name, fmt.Sprint(record.TTL), "IN", record.Type, record.Content}, " ") | ||||
|  | ||||
| 	query := url.Values{} | ||||
| 	query.Add("command", "addDNSRR") | ||||
| 	query.Add("account", account) | ||||
| 	query.Add("dnszone", domainName) | ||||
| 	query.Add("rrdata", strings.Join([]string{record.Name, fmt.Sprint(record.TTL), "IN", record.Type, record.Content}, " ")) | ||||
| 	query.Add("rrdata", value) | ||||
| 	query.Add("key", newRecordID) | ||||
|  | ||||
| 	result := &AddRecord{} | ||||
|  | ||||
| 	err := c.do(query, result) | ||||
| 	err := c.do(ctx, query, result) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("do: %w", err) | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	result.Record = strings.Join([]string{record.Name, fmt.Sprint(record.TTL), "IN", record.Type, record.Content}, " ") | ||||
|  | ||||
| 	result.Record = value | ||||
|  | ||||
| 	return result, nil | ||||
| } | ||||
|  | ||||
| // DeleteRecord deletes a DNS record. | ||||
| // https://portal.brandit.com/apidocv3#deleteDNSRR | ||||
| func (c *Client) DeleteRecord(domainName, account, dnsRecord, recordID string) (*DeleteRecord, error) { | ||||
| 	// Create a new query | ||||
| func (c *Client) DeleteRecord(ctx context.Context, domainName, account, dnsRecord, recordID string) error { | ||||
| 	query := url.Values{} | ||||
| 	query.Add("command", "deleteDNSRR") | ||||
| 	query.Add("account", account) | ||||
| @@ -104,68 +107,70 @@ func (c *Client) DeleteRecord(domainName, account, dnsRecord, recordID string) ( | ||||
| 	query.Add("rrdata", dnsRecord) | ||||
| 	query.Add("key", recordID) | ||||
|  | ||||
| 	result := &DeleteRecord{} | ||||
|  | ||||
| 	err := c.do(query, result) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("do: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return result, nil | ||||
| 	return c.do(ctx, query, nil) | ||||
| } | ||||
|  | ||||
| // StatusDomain returns the status of a domain and account associated with it. | ||||
| // https://portal.brandit.com/apidocv3#statusDomain | ||||
| func (c *Client) StatusDomain(domain string) (*StatusDomain, error) { | ||||
| 	// Create a new query | ||||
| func (c *Client) StatusDomain(ctx context.Context, domain string) (*StatusResponse, error) { | ||||
| 	query := url.Values{} | ||||
|  | ||||
| 	query.Add("command", "statusDomain") | ||||
| 	query.Add("domain", domain) | ||||
|  | ||||
| 	result := &StatusDomain{} | ||||
| 	result := &Response[*StatusResponse]{} | ||||
|  | ||||
| 	err := c.do(query, result) | ||||
| 	err := c.do(ctx, query, result) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("do: %w", err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return result, nil | ||||
| 	return result.Response, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) do(query url.Values, result any) error { | ||||
| 	// Add signature | ||||
| 	v, err := sign(c.apiUsername, c.apiKey, query) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("signature: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.HTTPClient.PostForm(c.BaseURL, v) | ||||
| func (c *Client) do(ctx context.Context, query url.Values, result any) error { | ||||
| 	values, err := sign(c.apiUsername, c.apiKey, query) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(values.Encode())) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||||
|  | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("read response body: %w", err) | ||||
| 		return errutils.NewReadResponseError(req, resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	//  Unmarshal the error response, because the API returns a 200 OK even if there is an error. | ||||
| 	var apiError APIError | ||||
| 	err = json.Unmarshal(raw, &apiError) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("unmarshal error response: %w %s", err, string(raw)) | ||||
| 		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) | ||||
| 	} | ||||
|  | ||||
| 	if apiError.Code > 299 || apiError.Status != "success" { | ||||
| 		return apiError | ||||
| 	} | ||||
|  | ||||
| 	if result == nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	err = json.Unmarshal(raw, result) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("unmarshal response body: %w %s", err, string(raw)) | ||||
| 		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
|   | ||||
| @@ -1,30 +1,32 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func setupTest(t *testing.T, file string) *Client { | ||||
| func setupTest(t *testing.T, filename string) *Client { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { | ||||
| 		open, err := os.Open(file) | ||||
| 		file, err := os.Open(filepath.Join("fixtures", filename)) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		defer func() { _ = open.Close() }() | ||||
| 		defer func() { _ = file.Close() }() | ||||
|  | ||||
| 		rw.WriteHeader(http.StatusOK) | ||||
| 		_, err = io.Copy(rw, open) | ||||
| 		_, err = io.Copy(rw, file) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| @@ -36,78 +38,82 @@ func setupTest(t *testing.T, file string) *Client { | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	client.HTTPClient = server.Client() | ||||
| 	client.BaseURL = server.URL | ||||
| 	client.baseURL = server.URL | ||||
|  | ||||
| 	return client | ||||
| } | ||||
|  | ||||
| func TestClient_StatusDomain(t *testing.T) { | ||||
| 	client := setupTest(t, "./fixtures/status-domain.json") | ||||
| 	client := setupTest(t, "status-domain.json") | ||||
|  | ||||
| 	domain, err := client.StatusDomain("example.com") | ||||
| 	domain, err := client.StatusDomain(context.Background(), "example.com") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &StatusDomain{ | ||||
| 		Response: StatusResponse{ | ||||
| 			RenewalMode:                []string{"DEFAULT"}, | ||||
| 			Status:                     []string{"clientTransferProhibited"}, | ||||
| 			TransferLock:               []int{1}, | ||||
| 			Registrar:                  []string{"brandit"}, | ||||
| 			PaidUntilDate:              []string{"2021-12-15 05:00:00.0"}, | ||||
| 			Nameserver:                 []string{"NS1.RRPPROXY.NET", "NS2.RRPPROXY.NET"}, | ||||
| 			RegistrationExpirationDate: []string{"2021-12-15 05:00:00.0"}, | ||||
| 			Domain:                     []string{"example.com"}, | ||||
| 			RenewalDate:                []string{"2024-01-19 05:00:00.0"}, | ||||
| 			UpdatedDate:                []string{"2022-12-16 08:01:27.0"}, | ||||
| 			BillingContact:             []string{"example"}, | ||||
| 			XDomainRoID:                []string{"example"}, | ||||
| 			AdminContact:               []string{"example"}, | ||||
| 			TechContact:                []string{"example"}, | ||||
| 			DomainIDN:                  []string{"example.com"}, | ||||
| 			CreatedDate:                []string{"2016-12-16 05:00:00.0"}, | ||||
| 			RegistrarTransferDate:      []string{"2021-12-09 05:17:42.0"}, | ||||
| 			Zone:                       []string{"com"}, | ||||
| 			Auth:                       []string{"example"}, | ||||
| 			UpdatedBy:                  []string{"example"}, | ||||
| 			RoID:                       []string{"example"}, | ||||
| 			OwnerContact:               []string{"example"}, | ||||
| 			CreatedBy:                  []string{"example"}, | ||||
| 			TransferMode:               []string{"auto"}, | ||||
| 		}, | ||||
| 		Code:   200, | ||||
| 		Status: "success", | ||||
| 		Error:  "", | ||||
| 	expected := &StatusResponse{ | ||||
| 		RenewalMode:                []string{"DEFAULT"}, | ||||
| 		Status:                     []string{"clientTransferProhibited"}, | ||||
| 		TransferLock:               []int{1}, | ||||
| 		Registrar:                  []string{"brandit"}, | ||||
| 		PaidUntilDate:              []string{"2021-12-15 05:00:00.0"}, | ||||
| 		Nameserver:                 []string{"NS1.RRPPROXY.NET", "NS2.RRPPROXY.NET"}, | ||||
| 		RegistrationExpirationDate: []string{"2021-12-15 05:00:00.0"}, | ||||
| 		Domain:                     []string{"example.com"}, | ||||
| 		RenewalDate:                []string{"2024-01-19 05:00:00.0"}, | ||||
| 		UpdatedDate:                []string{"2022-12-16 08:01:27.0"}, | ||||
| 		BillingContact:             []string{"example"}, | ||||
| 		XDomainRoID:                []string{"example"}, | ||||
| 		AdminContact:               []string{"example"}, | ||||
| 		TechContact:                []string{"example"}, | ||||
| 		DomainIDN:                  []string{"example.com"}, | ||||
| 		CreatedDate:                []string{"2016-12-16 05:00:00.0"}, | ||||
| 		RegistrarTransferDate:      []string{"2021-12-09 05:17:42.0"}, | ||||
| 		Zone:                       []string{"com"}, | ||||
| 		Auth:                       []string{"example"}, | ||||
| 		UpdatedBy:                  []string{"example"}, | ||||
| 		RoID:                       []string{"example"}, | ||||
| 		OwnerContact:               []string{"example"}, | ||||
| 		CreatedBy:                  []string{"example"}, | ||||
| 		TransferMode:               []string{"auto"}, | ||||
| 	} | ||||
|  | ||||
| 	assert.Equal(t, expected, domain) | ||||
| } | ||||
|  | ||||
| func TestClient_ListRecords(t *testing.T) { | ||||
| 	client := setupTest(t, "./fixtures/list-records.json") | ||||
| func TestClient_StatusDomain_error(t *testing.T) { | ||||
| 	client := setupTest(t, "error.json") | ||||
|  | ||||
| 	resp, err := client.ListRecords("example", "example.com") | ||||
| 	_, err := client.StatusDomain(context.Background(), "example.com") | ||||
| 	require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) | ||||
| } | ||||
|  | ||||
| func TestClient_ListRecords(t *testing.T) { | ||||
| 	client := setupTest(t, "list-records.json") | ||||
|  | ||||
| 	resp, err := client.ListRecords(context.Background(), "example", "example.com") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &ListRecords{ | ||||
| 		Response: ListRecordsResponse{ | ||||
| 			Limit:  []int{100}, | ||||
| 			Column: []string{"rr"}, | ||||
| 			Count:  []int{1}, | ||||
| 			First:  []int{0}, | ||||
| 			Total:  []int{1}, | ||||
| 			RR:     []string{"example.com. 600 IN TXT txttxttxt"}, | ||||
| 			Last:   []int{0}, | ||||
| 		}, | ||||
| 		Code:   200, | ||||
| 		Status: "success", | ||||
| 		Error:  "", | ||||
| 	expected := &ListRecordsResponse{ | ||||
| 		Limit:  []int{100}, | ||||
| 		Column: []string{"rr"}, | ||||
| 		Count:  []int{1}, | ||||
| 		First:  []int{0}, | ||||
| 		Total:  []int{1}, | ||||
| 		RR:     []string{"example.com. 600 IN TXT txttxttxt"}, | ||||
| 		Last:   []int{0}, | ||||
| 	} | ||||
|  | ||||
| 	assert.Equal(t, expected, resp) | ||||
| } | ||||
|  | ||||
| func TestClient_ListRecords_error(t *testing.T) { | ||||
| 	client := setupTest(t, "error.json") | ||||
|  | ||||
| 	_, err := client.ListRecords(context.Background(), "example", "example.com") | ||||
| 	require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) | ||||
| } | ||||
|  | ||||
| func TestClient_AddRecord(t *testing.T) { | ||||
| 	client := setupTest(t, "./fixtures/add-record.json") | ||||
| 	client := setupTest(t, "add-record.json") | ||||
|  | ||||
| 	testRecord := Record{ | ||||
| 		ID:      2565, | ||||
| @@ -116,7 +122,7 @@ func TestClient_AddRecord(t *testing.T) { | ||||
| 		Content: "txttxttxt", | ||||
| 		TTL:     600, | ||||
| 	} | ||||
| 	resp, err := client.AddRecord("example.com", "test", "2565", testRecord) | ||||
| 	resp, err := client.AddRecord(context.Background(), "example.com", "test", "2565", testRecord) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &AddRecord{ | ||||
| @@ -133,17 +139,31 @@ func TestClient_AddRecord(t *testing.T) { | ||||
| 	assert.Equal(t, expected, resp) | ||||
| } | ||||
|  | ||||
| func TestClient_DeleteRecord(t *testing.T) { | ||||
| 	client := setupTest(t, "./fixtures/delete-record.json") | ||||
| func TestClient_AddRecord_error(t *testing.T) { | ||||
| 	client := setupTest(t, "error.json") | ||||
|  | ||||
| 	resp, err := client.DeleteRecord("example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &DeleteRecord{ | ||||
| 		Code:   200, | ||||
| 		Status: "success", | ||||
| 		Error:  "", | ||||
| 	testRecord := Record{ | ||||
| 		ID:      2565, | ||||
| 		Type:    "TXT", | ||||
| 		Name:    "example.com", | ||||
| 		Content: "txttxttxt", | ||||
| 		TTL:     600, | ||||
| 	} | ||||
|  | ||||
| 	assert.Equal(t, expected, resp) | ||||
| 	_, err := client.AddRecord(context.Background(), "example.com", "test", "2565", testRecord) | ||||
| 	require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) | ||||
| } | ||||
|  | ||||
| func TestClient_DeleteRecord(t *testing.T) { | ||||
| 	client := setupTest(t, "delete-record.json") | ||||
|  | ||||
| 	err := client.DeleteRecord(context.Background(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
|  | ||||
| func TestClient_DeleteRecord_error(t *testing.T) { | ||||
| 	client := setupTest(t, "error.json") | ||||
|  | ||||
| 	err := client.DeleteRecord(context.Background(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") | ||||
| 	require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) | ||||
| } | ||||
|   | ||||
							
								
								
									
										5
									
								
								providers/dns/brandit/internal/fixtures/error.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								providers/dns/brandit/internal/fixtures/error.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| { | ||||
|   "code": 402, | ||||
|   "status": "error", | ||||
|   "error": "Invalid user." | ||||
| } | ||||
| @@ -2,11 +2,11 @@ package internal | ||||
|  | ||||
| import "fmt" | ||||
|  | ||||
| type StatusDomain struct { | ||||
| 	Response StatusResponse `json:"response,omitempty"` | ||||
| 	Code     int            `json:"code"` | ||||
| 	Status   string         `json:"status"` | ||||
| 	Error    string         `json:"error"` | ||||
| type Response[T any] struct { | ||||
| 	Response T      `json:"response,omitempty"` | ||||
| 	Code     int    `json:"code"` | ||||
| 	Status   string `json:"status"` | ||||
| 	Error    string `json:"error"` | ||||
| } | ||||
|  | ||||
| type StatusResponse struct { | ||||
| @@ -36,13 +36,6 @@ type StatusResponse struct { | ||||
| 	TransferMode               []string `json:"transfermode"` | ||||
| } | ||||
|  | ||||
| type ListRecords struct { | ||||
| 	Response ListRecordsResponse `json:"response,omitempty"` | ||||
| 	Code     int                 `json:"code"` | ||||
| 	Status   string              `json:"status"` | ||||
| 	Error    string              `json:"error"` | ||||
| } | ||||
|  | ||||
| type ListRecordsResponse struct { | ||||
| 	Limit  []int    `json:"limit,omitempty"` | ||||
| 	Column []string `json:"column,omitempty"` | ||||
| @@ -83,9 +76,3 @@ type Record struct { | ||||
| 	Content string `json:"content,omitempty"` | ||||
| 	TTL     int    `json:"ttl,omitempty"` // default 600 | ||||
| } | ||||
|  | ||||
| type DeleteRecord struct { | ||||
| 	Code   int    `json:"code"` | ||||
| 	Status string `json:"status"` | ||||
| 	Error  string `json:"error"` | ||||
| } | ||||
|   | ||||
| @@ -2,15 +2,16 @@ | ||||
| package checkdomain | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"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/checkdomain/internal" | ||||
| ) | ||||
|  | ||||
| // Environment variables names. | ||||
| @@ -26,11 +27,6 @@ const ( | ||||
| 	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	defaultEndpoint = "https://api.checkdomain.de" | ||||
| 	defaultTTL      = 300 | ||||
| ) | ||||
|  | ||||
| // Config is used to configure the creation of the DNSProvider. | ||||
| type Config struct { | ||||
| 	Endpoint           *url.URL | ||||
| @@ -44,7 +40,7 @@ type Config struct { | ||||
| // NewDefaultConfig returns a default configuration for the DNSProvider. | ||||
| func NewDefaultConfig() *Config { | ||||
| 	return &Config{ | ||||
| 		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL), | ||||
| 		TTL:                env.GetOrDefaultInt(EnvTTL, 300), | ||||
| 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), | ||||
| 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 7*time.Second), | ||||
| 		HTTPClient: &http.Client{ | ||||
| @@ -56,9 +52,7 @@ func NewDefaultConfig() *Config { | ||||
| // DNSProvider implements the challenge.Provider interface. | ||||
| type DNSProvider struct { | ||||
| 	config *Config | ||||
|  | ||||
| 	domainIDMu      sync.Mutex | ||||
| 	domainIDMapping map[string]int | ||||
| 	client *internal.Client | ||||
| } | ||||
|  | ||||
| // NewDNSProvider returns a DNSProvider instance configured for CheckDomain. | ||||
| @@ -71,7 +65,7 @@ func NewDNSProvider() (*DNSProvider, error) { | ||||
| 	config := NewDefaultConfig() | ||||
| 	config.Token = values[EnvToken] | ||||
|  | ||||
| 	endpoint, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, defaultEndpoint)) | ||||
| 	endpoint, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, internal.DefaultEndpoint)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("checkdomain: invalid %s: %w", EnvEndpoint, err) | ||||
| 	} | ||||
| @@ -89,32 +83,33 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 		return nil, errors.New("checkdomain: missing token") | ||||
| 	} | ||||
|  | ||||
| 	if config.HTTPClient == nil { | ||||
| 		config.HTTPClient = http.DefaultClient | ||||
| 	client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.Token)) | ||||
|  | ||||
| 	if config.Endpoint != nil { | ||||
| 		client.BaseURL = config.Endpoint | ||||
| 	} | ||||
|  | ||||
| 	return &DNSProvider{ | ||||
| 		config:          config, | ||||
| 		domainIDMapping: make(map[string]int), | ||||
| 	}, nil | ||||
| 	return &DNSProvider{config: config, client: client}, nil | ||||
| } | ||||
|  | ||||
| // Present creates a TXT record to fulfill the dns-01 challenge. | ||||
| func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	// TODO(ldez) replace domain by FQDN to follow CNAME. | ||||
| 	domainID, err := d.getDomainIDByName(domain) | ||||
| 	domainID, err := d.client.GetDomainIDByName(ctx, domain) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("checkdomain: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.checkNameservers(domainID) | ||||
| 	err = d.client.CheckNameservers(ctx, domainID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("checkdomain: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	err = d.createRecord(domainID, &Record{ | ||||
| 	err = d.client.CreateRecord(ctx, domainID, &internal.Record{ | ||||
| 		Name:  info.EffectiveFQDN, | ||||
| 		TTL:   d.config.TTL, | ||||
| 		Type:  "TXT", | ||||
| @@ -130,28 +125,28 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| // CleanUp removes the TXT record previously created. | ||||
| func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	// TODO(ldez) replace domain by FQDN to follow CNAME. | ||||
| 	domainID, err := d.getDomainIDByName(domain) | ||||
| 	domainID, err := d.client.GetDomainIDByName(ctx, domain) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("checkdomain: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.checkNameservers(domainID) | ||||
| 	err = d.client.CheckNameservers(ctx, domainID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("checkdomain: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	err = d.deleteTXTRecord(domainID, info.EffectiveFQDN, info.Value) | ||||
| 	defer d.client.CleanCache(info.EffectiveFQDN) | ||||
|  | ||||
| 	err = d.client.DeleteTXTRecord(ctx, domainID, info.EffectiveFQDN, info.Value) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("checkdomain: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	d.domainIDMu.Lock() | ||||
| 	delete(d.domainIDMapping, info.EffectiveFQDN) | ||||
| 	d.domainIDMu.Unlock() | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/platform/tester" | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/checkdomain/internal" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
| @@ -83,7 +84,7 @@ func TestNewDNSProviderConfig(t *testing.T) { | ||||
| 	for _, test := range testCases { | ||||
| 		t.Run(test.desc, func(t *testing.T) { | ||||
| 			config := NewDefaultConfig() | ||||
| 			config.Endpoint, _ = url.Parse(defaultEndpoint) | ||||
| 			config.Endpoint, _ = url.Parse(internal.DefaultEndpoint) | ||||
|  | ||||
| 			if test.token != "" { | ||||
| 				config.Token = test.token | ||||
|   | ||||
| @@ -1,416 +0,0 @@ | ||||
| package checkdomain | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	ns1 = "ns.checkdomain.de" | ||||
| 	ns2 = "ns2.checkdomain.de" | ||||
| ) | ||||
|  | ||||
| const domainNotFound = -1 | ||||
|  | ||||
| // max page limit that the checkdomain api allows. | ||||
| const maxLimit = 100 | ||||
|  | ||||
| // max integer value. | ||||
| const maxInt = int((^uint(0)) >> 1) | ||||
|  | ||||
| type ( | ||||
| 	// Some fields have been omitted from the structs | ||||
| 	// because they are not required for this application. | ||||
|  | ||||
| 	DomainListingResponse struct { | ||||
| 		Page     int                `json:"page"` | ||||
| 		Limit    int                `json:"limit"` | ||||
| 		Pages    int                `json:"pages"` | ||||
| 		Total    int                `json:"total"` | ||||
| 		Embedded EmbeddedDomainList `json:"_embedded"` | ||||
| 	} | ||||
|  | ||||
| 	EmbeddedDomainList struct { | ||||
| 		Domains []*Domain `json:"domains"` | ||||
| 	} | ||||
|  | ||||
| 	Domain struct { | ||||
| 		ID   int    `json:"id"` | ||||
| 		Name string `json:"name"` | ||||
| 	} | ||||
|  | ||||
| 	DomainResponse struct { | ||||
| 		ID      int    `json:"id"` | ||||
| 		Name    string `json:"name"` | ||||
| 		Created string `json:"created"` | ||||
| 		PaidUp  string `json:"payed_up"` | ||||
| 		Active  bool   `json:"active"` | ||||
| 	} | ||||
|  | ||||
| 	NameserverResponse struct { | ||||
| 		General     NameserverGeneral `json:"general"` | ||||
| 		Nameservers []*Nameserver     `json:"nameservers"` | ||||
| 		SOA         NameserverSOA     `json:"soa"` | ||||
| 	} | ||||
|  | ||||
| 	NameserverGeneral struct { | ||||
| 		IPv4       string `json:"ip_v4"` | ||||
| 		IPv6       string `json:"ip_v6"` | ||||
| 		IncludeWWW bool   `json:"include_www"` | ||||
| 	} | ||||
|  | ||||
| 	NameserverSOA struct { | ||||
| 		Mail    string `json:"mail"` | ||||
| 		Refresh int    `json:"refresh"` | ||||
| 		Retry   int    `json:"retry"` | ||||
| 		Expiry  int    `json:"expiry"` | ||||
| 		TTL     int    `json:"ttl"` | ||||
| 	} | ||||
|  | ||||
| 	Nameserver struct { | ||||
| 		Name string `json:"name"` | ||||
| 	} | ||||
|  | ||||
| 	RecordListingResponse struct { | ||||
| 		Page     int                `json:"page"` | ||||
| 		Limit    int                `json:"limit"` | ||||
| 		Pages    int                `json:"pages"` | ||||
| 		Total    int                `json:"total"` | ||||
| 		Embedded EmbeddedRecordList `json:"_embedded"` | ||||
| 	} | ||||
|  | ||||
| 	EmbeddedRecordList struct { | ||||
| 		Records []*Record `json:"records"` | ||||
| 	} | ||||
|  | ||||
| 	Record struct { | ||||
| 		Name     string `json:"name"` | ||||
| 		Value    string `json:"value"` | ||||
| 		TTL      int    `json:"ttl"` | ||||
| 		Priority int    `json:"priority"` | ||||
| 		Type     string `json:"type"` | ||||
| 	} | ||||
| ) | ||||
|  | ||||
| func (d *DNSProvider) getDomainIDByName(name string) (int, error) { | ||||
| 	// Load from cache if exists | ||||
| 	d.domainIDMu.Lock() | ||||
| 	id, ok := d.domainIDMapping[name] | ||||
| 	d.domainIDMu.Unlock() | ||||
| 	if ok { | ||||
| 		return id, nil | ||||
| 	} | ||||
|  | ||||
| 	// Find out by querying API | ||||
| 	domains, err := d.listDomains() | ||||
| 	if err != nil { | ||||
| 		return domainNotFound, err | ||||
| 	} | ||||
|  | ||||
| 	// Linear search over all registered domains | ||||
| 	for _, domain := range domains { | ||||
| 		if domain.Name == name || strings.HasSuffix(name, "."+domain.Name) { | ||||
| 			d.domainIDMu.Lock() | ||||
| 			d.domainIDMapping[name] = domain.ID | ||||
| 			d.domainIDMu.Unlock() | ||||
|  | ||||
| 			return domain.ID, nil | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return domainNotFound, errors.New("domain not found") | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) listDomains() ([]*Domain, error) { | ||||
| 	req, err := d.makeRequest(http.MethodGet, "/v1/domains", http.NoBody) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to make request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// Checkdomain also provides a query param 'query' which allows filtering domains for a string. | ||||
| 	// But that functionality is kinda broken, | ||||
| 	// so we scan through the whole list of registered domains to later find the one that is of interest to us. | ||||
| 	q := req.URL.Query() | ||||
| 	q.Set("limit", strconv.Itoa(maxLimit)) | ||||
|  | ||||
| 	currentPage := 1 | ||||
| 	totalPages := maxInt | ||||
|  | ||||
| 	var domainList []*Domain | ||||
| 	for currentPage <= totalPages { | ||||
| 		q.Set("page", strconv.Itoa(currentPage)) | ||||
| 		req.URL.RawQuery = q.Encode() | ||||
|  | ||||
| 		var res DomainListingResponse | ||||
| 		if err := d.sendRequest(req, &res); err != nil { | ||||
| 			return nil, fmt.Errorf("failed to send domain listing request: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		// This is the first response, | ||||
| 		// so we update totalPages and allocate the slice memory. | ||||
| 		if totalPages == maxInt { | ||||
| 			totalPages = res.Pages | ||||
| 			domainList = make([]*Domain, 0, res.Total) | ||||
| 		} | ||||
|  | ||||
| 		domainList = append(domainList, res.Embedded.Domains...) | ||||
| 		currentPage++ | ||||
| 	} | ||||
|  | ||||
| 	return domainList, nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) getNameserverInfo(domainID int) (*NameserverResponse, error) { | ||||
| 	req, err := d.makeRequest(http.MethodGet, fmt.Sprintf("/v1/domains/%d/nameservers", domainID), http.NoBody) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	res := &NameserverResponse{} | ||||
| 	if err := d.sendRequest(req, res); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return res, nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) checkNameservers(domainID int) error { | ||||
| 	info, err := d.getNameserverInfo(domainID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	var found1, found2 bool | ||||
| 	for _, item := range info.Nameservers { | ||||
| 		switch item.Name { | ||||
| 		case ns1: | ||||
| 			found1 = true | ||||
| 		case ns2: | ||||
| 			found2 = true | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if !found1 || !found2 { | ||||
| 		return errors.New("not using checkdomain nameservers, can not update records") | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) createRecord(domainID int, record *Record) error { | ||||
| 	bs, err := json.Marshal(record) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("encoding record failed: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := d.makeRequest(http.MethodPost, fmt.Sprintf("/v1/domains/%d/nameservers/records", domainID), bytes.NewReader(bs)) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return d.sendRequest(req, nil) | ||||
| } | ||||
|  | ||||
| // Checkdomain doesn't seem provide a way to delete records but one can replace all records at once. | ||||
| // The current solution is to fetch all records and then use that list minus the record deleted as the new record list. | ||||
| // TODO: Simplify this function once Checkdomain do provide the functionality. | ||||
| func (d *DNSProvider) deleteTXTRecord(domainID int, recordName, recordValue string) error { | ||||
| 	domainInfo, err := d.getDomainInfo(domainID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	nsInfo, err := d.getNameserverInfo(domainID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	allRecords, err := d.listRecords(domainID, "") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	recordName = strings.TrimSuffix(recordName, "."+domainInfo.Name+".") | ||||
|  | ||||
| 	var recordsToKeep []*Record | ||||
|  | ||||
| 	// Find and delete matching records | ||||
| 	for _, record := range allRecords { | ||||
| 		if skipRecord(recordName, recordValue, record, nsInfo) { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Checkdomain API can return records without any TTL set (indicated by the value of 0). | ||||
| 		// The API Call to replace the records would fail if we wouldn't specify a value. | ||||
| 		// Thus, we use the default TTL queried beforehand | ||||
| 		if record.TTL == 0 { | ||||
| 			record.TTL = nsInfo.SOA.TTL | ||||
| 		} | ||||
|  | ||||
| 		recordsToKeep = append(recordsToKeep, record) | ||||
| 	} | ||||
|  | ||||
| 	return d.replaceRecords(domainID, recordsToKeep) | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) getDomainInfo(domainID int) (*DomainResponse, error) { | ||||
| 	req, err := d.makeRequest(http.MethodGet, fmt.Sprintf("/v1/domains/%d", domainID), http.NoBody) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	var res DomainResponse | ||||
| 	err = d.sendRequest(req, &res) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return &res, nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) listRecords(domainID int, recordType string) ([]*Record, error) { | ||||
| 	req, err := d.makeRequest(http.MethodGet, fmt.Sprintf("/v1/domains/%d/nameservers/records", domainID), http.NoBody) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to make request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	q := req.URL.Query() | ||||
| 	q.Set("limit", strconv.Itoa(maxLimit)) | ||||
| 	if recordType != "" { | ||||
| 		q.Set("type", recordType) | ||||
| 	} | ||||
|  | ||||
| 	currentPage := 1 | ||||
| 	totalPages := maxInt | ||||
|  | ||||
| 	var recordList []*Record | ||||
| 	for currentPage <= totalPages { | ||||
| 		q.Set("page", strconv.Itoa(currentPage)) | ||||
| 		req.URL.RawQuery = q.Encode() | ||||
|  | ||||
| 		var res RecordListingResponse | ||||
| 		if err := d.sendRequest(req, &res); err != nil { | ||||
| 			return nil, fmt.Errorf("failed to send record listing request: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		// This is the first response, so we update totalPages and allocate the slice memory. | ||||
| 		if totalPages == maxInt { | ||||
| 			totalPages = res.Pages | ||||
| 			recordList = make([]*Record, 0, res.Total) | ||||
| 		} | ||||
|  | ||||
| 		recordList = append(recordList, res.Embedded.Records...) | ||||
| 		currentPage++ | ||||
| 	} | ||||
|  | ||||
| 	return recordList, nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) replaceRecords(domainID int, records []*Record) error { | ||||
| 	bs, err := json.Marshal(records) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("encoding record failed: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := d.makeRequest(http.MethodPut, fmt.Sprintf("/v1/domains/%d/nameservers/records", domainID), bytes.NewReader(bs)) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return d.sendRequest(req, nil) | ||||
| } | ||||
|  | ||||
| func skipRecord(recordName, recordValue string, record *Record, nsInfo *NameserverResponse) bool { | ||||
| 	// Skip empty records | ||||
| 	if record.Value == "" { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	// Skip some special records, otherwise we would get a "Nameserver update failed" | ||||
| 	if record.Type == "SOA" || record.Type == "NS" || record.Name == "@" || (nsInfo.General.IncludeWWW && record.Name == "www") { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	nameMatch := recordName == "" || record.Name == recordName | ||||
| 	valueMatch := recordValue == "" || record.Value == recordValue | ||||
|  | ||||
| 	// Skip our matching record | ||||
| 	if record.Type == "TXT" && nameMatch && valueMatch { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) makeRequest(method, resource string, body io.Reader) (*http.Request, error) { | ||||
| 	uri, err := d.config.Endpoint.Parse(resource) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(method, uri.String(), body) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	req.Header.Set("Accept", "application/json") | ||||
| 	req.Header.Set("Authorization", "Bearer "+d.config.Token) | ||||
| 	if method != http.MethodGet { | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 	} | ||||
|  | ||||
| 	return req, nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) sendRequest(req *http.Request, result interface{}) error { | ||||
| 	resp, err := d.config.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err = checkResponse(resp); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if result == nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	err = json.Unmarshal(raw, result) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("unmarshaling %T error [status code=%d]: %w: %s", result, resp.StatusCode, err, string(raw)) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func checkResponse(resp *http.Response) error { | ||||
| 	if resp.StatusCode < http.StatusBadRequest { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if resp.Body == nil { | ||||
| 		return fmt.Errorf("response body is nil, status code=%d", resp.StatusCode) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("unable to read body: status code=%d, error=%w", resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	return fmt.Errorf("status code=%d: %s", resp.StatusCode, string(raw)) | ||||
| } | ||||
							
								
								
									
										383
									
								
								providers/dns/checkdomain/internal/client.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										383
									
								
								providers/dns/checkdomain/internal/client.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,383 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| 	"golang.org/x/oauth2" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	ns1 = "ns.checkdomain.de" | ||||
| 	ns2 = "ns2.checkdomain.de" | ||||
| ) | ||||
|  | ||||
| // DefaultEndpoint the default API endpoint. | ||||
| const DefaultEndpoint = "https://api.checkdomain.de" | ||||
|  | ||||
| const domainNotFound = -1 | ||||
|  | ||||
| // max page limit that the checkdomain api allows. | ||||
| const maxLimit = 100 | ||||
|  | ||||
| // max integer value. | ||||
| const maxInt = int((^uint(0)) >> 1) | ||||
|  | ||||
| // Client the Autodns API client. | ||||
| type Client struct { | ||||
| 	domainIDMapping map[string]int | ||||
| 	domainIDMu      sync.Mutex | ||||
|  | ||||
| 	BaseURL    *url.URL | ||||
| 	httpClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient creates a new Client. | ||||
| func NewClient(hc *http.Client) *Client { | ||||
| 	baseURL, _ := url.Parse(DefaultEndpoint) | ||||
|  | ||||
| 	if hc == nil { | ||||
| 		hc = &http.Client{Timeout: 10 * time.Second} | ||||
| 	} | ||||
|  | ||||
| 	return &Client{ | ||||
| 		BaseURL:         baseURL, | ||||
| 		httpClient:      hc, | ||||
| 		domainIDMapping: make(map[string]int), | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (c *Client) GetDomainIDByName(ctx context.Context, name string) (int, error) { | ||||
| 	// Load from cache if exists | ||||
| 	c.domainIDMu.Lock() | ||||
| 	id, ok := c.domainIDMapping[name] | ||||
| 	c.domainIDMu.Unlock() | ||||
| 	if ok { | ||||
| 		return id, nil | ||||
| 	} | ||||
|  | ||||
| 	// Find out by querying API | ||||
| 	domains, err := c.listDomains(ctx) | ||||
| 	if err != nil { | ||||
| 		return domainNotFound, err | ||||
| 	} | ||||
|  | ||||
| 	// Linear search over all registered domains | ||||
| 	for _, domain := range domains { | ||||
| 		if domain.Name == name || strings.HasSuffix(name, "."+domain.Name) { | ||||
| 			c.domainIDMu.Lock() | ||||
| 			c.domainIDMapping[name] = domain.ID | ||||
| 			c.domainIDMu.Unlock() | ||||
|  | ||||
| 			return domain.ID, nil | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return domainNotFound, errors.New("domain not found") | ||||
| } | ||||
|  | ||||
| func (c *Client) listDomains(ctx context.Context) ([]*Domain, error) { | ||||
| 	endpoint := c.BaseURL.JoinPath("v1", "domains") | ||||
|  | ||||
| 	// Checkdomain also provides a query param 'query' which allows filtering domains for a string. | ||||
| 	// But that functionality is kinda broken, | ||||
| 	// so we scan through the whole list of registered domains to later find the one that is of interest to us. | ||||
| 	q := endpoint.Query() | ||||
| 	q.Set("limit", strconv.Itoa(maxLimit)) | ||||
|  | ||||
| 	currentPage := 1 | ||||
| 	totalPages := maxInt | ||||
|  | ||||
| 	var domainList []*Domain | ||||
| 	for currentPage <= totalPages { | ||||
| 		q.Set("page", strconv.Itoa(currentPage)) | ||||
| 		endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 		req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("failed to make request: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		var res DomainListingResponse | ||||
| 		if err := c.do(req, &res); err != nil { | ||||
| 			return nil, fmt.Errorf("failed to send domain listing request: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		// This is the first response, | ||||
| 		// so we update totalPages and allocate the slice memory. | ||||
| 		if totalPages == maxInt { | ||||
| 			totalPages = res.Pages | ||||
| 			domainList = make([]*Domain, 0, res.Total) | ||||
| 		} | ||||
|  | ||||
| 		domainList = append(domainList, res.Embedded.Domains...) | ||||
| 		currentPage++ | ||||
| 	} | ||||
|  | ||||
| 	return domainList, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) getNameserverInfo(ctx context.Context, domainID int) (*NameserverResponse, error) { | ||||
| 	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	res := &NameserverResponse{} | ||||
| 	if err := c.do(req, res); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return res, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) CheckNameservers(ctx context.Context, domainID int) error { | ||||
| 	info, err := c.getNameserverInfo(ctx, domainID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	var found1, found2 bool | ||||
| 	for _, item := range info.Nameservers { | ||||
| 		switch item.Name { | ||||
| 		case ns1: | ||||
| 			found1 = true | ||||
| 		case ns2: | ||||
| 			found2 = true | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if !found1 || !found2 { | ||||
| 		return errors.New("not using checkdomain nameservers, can not update records") | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (c *Client) CreateRecord(ctx context.Context, domainID int, record *Record) error { | ||||
| 	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers", "records") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return c.do(req, nil) | ||||
| } | ||||
|  | ||||
| // DeleteTXTRecord Checkdomain doesn't seem provide a way to delete records but one can replace all records at once. | ||||
| // The current solution is to fetch all records and then use that list minus the record deleted as the new record list. | ||||
| // TODO: Simplify this function once Checkdomain do provide the functionality. | ||||
| func (c *Client) DeleteTXTRecord(ctx context.Context, domainID int, recordName, recordValue string) error { | ||||
| 	domainInfo, err := c.getDomainInfo(ctx, domainID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	nsInfo, err := c.getNameserverInfo(ctx, domainID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	allRecords, err := c.listRecords(ctx, domainID, "") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	recordName = strings.TrimSuffix(recordName, "."+domainInfo.Name+".") | ||||
|  | ||||
| 	var recordsToKeep []*Record | ||||
|  | ||||
| 	// Find and delete matching records | ||||
| 	for _, record := range allRecords { | ||||
| 		if skipRecord(recordName, recordValue, record, nsInfo) { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Checkdomain API can return records without any TTL set (indicated by the value of 0). | ||||
| 		// The API Call to replace the records would fail if we wouldn't specify a value. | ||||
| 		// Thus, we use the default TTL queried beforehand | ||||
| 		if record.TTL == 0 { | ||||
| 			record.TTL = nsInfo.SOA.TTL | ||||
| 		} | ||||
|  | ||||
| 		recordsToKeep = append(recordsToKeep, record) | ||||
| 	} | ||||
|  | ||||
| 	return c.replaceRecords(ctx, domainID, recordsToKeep) | ||||
| } | ||||
|  | ||||
| func (c *Client) getDomainInfo(ctx context.Context, domainID int) (*DomainResponse, error) { | ||||
| 	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID)) | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	var res DomainResponse | ||||
| 	err = c.do(req, &res) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return &res, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) listRecords(ctx context.Context, domainID int, recordType string) ([]*Record, error) { | ||||
| 	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers", "records") | ||||
|  | ||||
| 	q := endpoint.Query() | ||||
| 	q.Set("limit", strconv.Itoa(maxLimit)) | ||||
| 	if recordType != "" { | ||||
| 		q.Set("type", recordType) | ||||
| 	} | ||||
|  | ||||
| 	currentPage := 1 | ||||
| 	totalPages := maxInt | ||||
|  | ||||
| 	var recordList []*Record | ||||
| 	for currentPage <= totalPages { | ||||
| 		q.Set("page", strconv.Itoa(currentPage)) | ||||
| 		endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 		req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("failed to create request: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		var res RecordListingResponse | ||||
| 		if err := c.do(req, &res); err != nil { | ||||
| 			return nil, fmt.Errorf("failed to send record listing request: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		// This is the first response, so we update totalPages and allocate the slice memory. | ||||
| 		if totalPages == maxInt { | ||||
| 			totalPages = res.Pages | ||||
| 			recordList = make([]*Record, 0, res.Total) | ||||
| 		} | ||||
|  | ||||
| 		recordList = append(recordList, res.Embedded.Records...) | ||||
| 		currentPage++ | ||||
| 	} | ||||
|  | ||||
| 	return recordList, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) replaceRecords(ctx context.Context, domainID int, records []*Record) error { | ||||
| 	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers", "records") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, records) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return c.do(req, nil) | ||||
| } | ||||
|  | ||||
| func (c *Client) do(req *http.Request, result any) error { | ||||
| 	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 errutils.NewUnexpectedResponseStatusCodeError(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 (c *Client) CleanCache(fqdn string) { | ||||
| 	c.domainIDMu.Lock() | ||||
| 	delete(c.domainIDMapping, fqdn) | ||||
| 	c.domainIDMu.Unlock() | ||||
| } | ||||
|  | ||||
| func skipRecord(recordName, recordValue string, record *Record, nsInfo *NameserverResponse) bool { | ||||
| 	// Skip empty records | ||||
| 	if record.Value == "" { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	// Skip some special records, otherwise we would get a "Nameserver update failed" | ||||
| 	if record.Type == "SOA" || record.Type == "NS" || record.Name == "@" || (nsInfo.General.IncludeWWW && record.Name == "www") { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	nameMatch := recordName == "" || record.Name == recordName | ||||
| 	valueMatch := recordValue == "" || record.Value == recordValue | ||||
|  | ||||
| 	// Skip our matching record | ||||
| 	if record.Type == "TXT" && nameMatch && valueMatch { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| 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 OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { | ||||
| 	if client == nil { | ||||
| 		client = &http.Client{Timeout: 5 * time.Second} | ||||
| 	} | ||||
|  | ||||
| 	client.Transport = &oauth2.Transport{ | ||||
| 		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), | ||||
| 		Base:   client.Transport, | ||||
| 	} | ||||
|  | ||||
| 	return client | ||||
| } | ||||
| @@ -1,6 +1,8 @@ | ||||
| package checkdomain | ||||
| package internal | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| @@ -15,32 +17,42 @@ import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
| 
 | ||||
| func setupTestProvider(t *testing.T) (*DNSProvider, *http.ServeMux) { | ||||
| func setupTest(t *testing.T) (*Client, *http.ServeMux) { | ||||
| 	t.Helper() | ||||
| 
 | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
| 
 | ||||
| 	config := NewDefaultConfig() | ||||
| 	config.Endpoint, _ = url.Parse(server.URL) | ||||
| 	config.Token = "secret" | ||||
| 	client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) | ||||
| 	client.BaseURL, _ = url.Parse(server.URL) | ||||
| 
 | ||||
| 	p, err := NewDNSProviderConfig(config) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	return p, mux | ||||
| 	return client, mux | ||||
| } | ||||
| 
 | ||||
| func Test_getDomainIDByName(t *testing.T) { | ||||
| 	prd, handler := setupTestProvider(t) | ||||
| func checkAuthorizationHeader(req *http.Request) error { | ||||
| 	val := req.Header.Get("Authorization") | ||||
| 	if val != "Bearer secret" { | ||||
| 		return fmt.Errorf("invalid header value, got: %s want %s", val, "Bearer secret") | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| 	handler.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) { | ||||
| func TestClient_GetDomainIDByName(t *testing.T) { | ||||
| 	client, mux := setupTest(t) | ||||
| 
 | ||||
| 	mux.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodGet { | ||||
| 			http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		err := checkAuthorizationHeader(req) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		domainList := DomainListingResponse{ | ||||
| 			Embedded: EmbeddedDomainList{Domains: []*Domain{ | ||||
| 				{ID: 1, Name: "test.com"}, | ||||
| @@ -48,28 +60,34 @@ func Test_getDomainIDByName(t *testing.T) { | ||||
| 			}}, | ||||
| 		} | ||||
| 
 | ||||
| 		err := json.NewEncoder(rw).Encode(domainList) | ||||
| 		err = json.NewEncoder(rw).Encode(domainList) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
| 
 | ||||
| 	id, err := prd.getDomainIDByName("test.com") | ||||
| 	id, err := client.GetDomainIDByName(context.Background(), "test.com") | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	assert.Equal(t, 1, id) | ||||
| } | ||||
| 
 | ||||
| func Test_checkNameservers(t *testing.T) { | ||||
| 	prd, handler := setupTestProvider(t) | ||||
| func TestClient_CheckNameservers(t *testing.T) { | ||||
| 	client, mux := setupTest(t) | ||||
| 
 | ||||
| 	handler.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 	mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodGet { | ||||
| 			http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		err := checkAuthorizationHeader(req) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		nsResp := NameserverResponse{ | ||||
| 			Nameservers: []*Nameserver{ | ||||
| 				{Name: ns1}, | ||||
| @@ -78,33 +96,39 @@ func Test_checkNameservers(t *testing.T) { | ||||
| 			}, | ||||
| 		} | ||||
| 
 | ||||
| 		err := json.NewEncoder(rw).Encode(nsResp) | ||||
| 		err = json.NewEncoder(rw).Encode(nsResp) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
| 
 | ||||
| 	err := prd.checkNameservers(1) | ||||
| 	err := client.CheckNameservers(context.Background(), 1) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
| 
 | ||||
| func Test_createRecord(t *testing.T) { | ||||
| 	prd, handler := setupTestProvider(t) | ||||
| func TestClient_CreateRecord(t *testing.T) { | ||||
| 	client, mux := setupTest(t) | ||||
| 
 | ||||
| 	handler.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 	mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodPost { | ||||
| 			http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		err := checkAuthorizationHeader(req) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		content, err := io.ReadAll(req.Body) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		if string(content) != `{"name":"test.com","value":"value","ttl":300,"priority":0,"type":"TXT"}` { | ||||
| 		if string(bytes.TrimSpace(content)) != `{"name":"test.com","value":"value","ttl":300,"priority":0,"type":"TXT"}` { | ||||
| 			http.Error(rw, "invalid request body: "+string(content), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
| @@ -117,12 +141,12 @@ func Test_createRecord(t *testing.T) { | ||||
| 		Value: "value", | ||||
| 	} | ||||
| 
 | ||||
| 	err := prd.createRecord(1, record) | ||||
| 	err := client.CreateRecord(context.Background(), 1, record) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
| 
 | ||||
| func Test_deleteTXTRecord(t *testing.T) { | ||||
| 	prd, handler := setupTestProvider(t) | ||||
| func TestClient_DeleteTXTRecord(t *testing.T) { | ||||
| 	client, mux := setupTest(t) | ||||
| 
 | ||||
| 	domainName := "lego.test" | ||||
| 	recordValue := "test" | ||||
| @@ -158,20 +182,26 @@ func Test_deleteTXTRecord(t *testing.T) { | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	handler.HandleFunc("/v1/domains/1", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 	mux.HandleFunc("/v1/domains/1", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		err := checkAuthorizationHeader(req) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		resp := DomainResponse{ | ||||
| 			ID:   1, | ||||
| 			Name: domainName, | ||||
| 		} | ||||
| 
 | ||||
| 		err := json.NewEncoder(rw).Encode(resp) | ||||
| 		err = json.NewEncoder(rw).Encode(resp) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
| 
 | ||||
| 	handler.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 	mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodGet { | ||||
| 			http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) | ||||
| 			return | ||||
| @@ -188,7 +218,7 @@ func Test_deleteTXTRecord(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
| 
 | ||||
| 	handler.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 	mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		switch req.Method { | ||||
| 		case http.MethodGet: | ||||
| 			resp := RecordListingResponse{ | ||||
| @@ -226,6 +256,6 @@ func Test_deleteTXTRecord(t *testing.T) { | ||||
| 	}) | ||||
| 
 | ||||
| 	info := dns01.GetChallengeInfo(domainName, "abc") | ||||
| 	err := prd.deleteTXTRecord(1, info.EffectiveFQDN, recordValue) | ||||
| 	err := client.DeleteTXTRecord(context.Background(), 1, info.EffectiveFQDN, recordValue) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
							
								
								
									
										73
									
								
								providers/dns/checkdomain/internal/types.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								providers/dns/checkdomain/internal/types.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| package internal | ||||
|  | ||||
| // Some fields have been omitted from the structs | ||||
| // because they are not required for this application. | ||||
|  | ||||
| type DomainListingResponse struct { | ||||
| 	Page     int                `json:"page"` | ||||
| 	Limit    int                `json:"limit"` | ||||
| 	Pages    int                `json:"pages"` | ||||
| 	Total    int                `json:"total"` | ||||
| 	Embedded EmbeddedDomainList `json:"_embedded"` | ||||
| } | ||||
|  | ||||
| type EmbeddedDomainList struct { | ||||
| 	Domains []*Domain `json:"domains"` | ||||
| } | ||||
|  | ||||
| type Domain struct { | ||||
| 	ID   int    `json:"id"` | ||||
| 	Name string `json:"name"` | ||||
| } | ||||
|  | ||||
| type DomainResponse struct { | ||||
| 	ID      int    `json:"id"` | ||||
| 	Name    string `json:"name"` | ||||
| 	Created string `json:"created"` | ||||
| 	PaidUp  string `json:"payed_up"` | ||||
| 	Active  bool   `json:"active"` | ||||
| } | ||||
|  | ||||
| type NameserverResponse struct { | ||||
| 	General     NameserverGeneral `json:"general"` | ||||
| 	Nameservers []*Nameserver     `json:"nameservers"` | ||||
| 	SOA         NameserverSOA     `json:"soa"` | ||||
| } | ||||
|  | ||||
| type NameserverGeneral struct { | ||||
| 	IPv4       string `json:"ip_v4"` | ||||
| 	IPv6       string `json:"ip_v6"` | ||||
| 	IncludeWWW bool   `json:"include_www"` | ||||
| } | ||||
|  | ||||
| type NameserverSOA struct { | ||||
| 	Mail    string `json:"mail"` | ||||
| 	Refresh int    `json:"refresh"` | ||||
| 	Retry   int    `json:"retry"` | ||||
| 	Expiry  int    `json:"expiry"` | ||||
| 	TTL     int    `json:"ttl"` | ||||
| } | ||||
|  | ||||
| type Nameserver struct { | ||||
| 	Name string `json:"name"` | ||||
| } | ||||
|  | ||||
| type RecordListingResponse struct { | ||||
| 	Page     int                `json:"page"` | ||||
| 	Limit    int                `json:"limit"` | ||||
| 	Pages    int                `json:"pages"` | ||||
| 	Total    int                `json:"total"` | ||||
| 	Embedded EmbeddedRecordList `json:"_embedded"` | ||||
| } | ||||
|  | ||||
| type EmbeddedRecordList struct { | ||||
| 	Records []*Record `json:"records"` | ||||
| } | ||||
|  | ||||
| type Record struct { | ||||
| 	Name     string `json:"name"` | ||||
| 	Value    string `json:"value"` | ||||
| 	TTL      int    `json:"ttl"` | ||||
| 	Priority int    `json:"priority"` | ||||
| 	Type     string `json:"type"` | ||||
| } | ||||
| @@ -93,11 +93,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	zone, err := getZone(info.EffectiveFQDN) | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("civo: failed to find zone: fqdn=%s: %w", info.EffectiveFQDN, err) | ||||
| 		return fmt.Errorf("civo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	zone := dns01.UnFqdn(authZone) | ||||
|  | ||||
| 	dnsDomain, err := d.client.GetDNSDomain(zone) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("civo: %w", err) | ||||
| @@ -125,11 +127,13 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	zone, err := getZone(info.EffectiveFQDN) | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("civo: failed to find zone: fqdn=%s: %w", info.EffectiveFQDN, err) | ||||
| 		return fmt.Errorf("civo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	zone := dns01.UnFqdn(authZone) | ||||
|  | ||||
| 	dnsDomain, err := d.client.GetDNSDomain(zone) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("civo: %w", err) | ||||
| @@ -166,12 +170,3 @@ 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 getZone(fqdn string) (string, error) { | ||||
| 	authZone, err := dns01.FindZoneByFqdn(fqdn) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	return dns01.UnFqdn(authZone), nil | ||||
| } | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| package clouddns | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -89,10 +90,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 		client.HTTPClient = config.HTTPClient | ||||
| 	} | ||||
|  | ||||
| 	return &DNSProvider{ | ||||
| 		client: client, | ||||
| 		config: config, | ||||
| 	}, nil | ||||
| 	return &DNSProvider{client: client, config: config}, nil | ||||
| } | ||||
|  | ||||
| // Timeout returns the timeout and interval to use when checking for DNS propagation. | ||||
| @@ -107,12 +105,17 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("clouddns: %w", err) | ||||
| 		return fmt.Errorf("clouddns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.AddRecord(authZone, info.EffectiveFQDN, info.Value) | ||||
| 	ctx, err := d.client.CreateAuthenticatedContext(context.Background()) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("clouddns: %w", err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.AddRecord(ctx, authZone, info.EffectiveFQDN, info.Value) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("clouddns: add record: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| @@ -124,12 +127,17 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("clouddns: %w", err) | ||||
| 		return fmt.Errorf("clouddns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.DeleteRecord(authZone, info.EffectiveFQDN) | ||||
| 	ctx, err := d.client.CreateAuthenticatedContext(context.Background()) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("clouddns: %w", err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.DeleteRecord(ctx, authZone, info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("clouddns: delete record: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
|   | ||||
| @@ -2,117 +2,127 @@ package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	apiBaseURL = "https://admin.vshosting.cloud/clouddns" | ||||
| 	loginURL   = "https://admin.vshosting.cloud/api/public/auth/login" | ||||
| ) | ||||
| const apiBaseURL = "https://admin.vshosting.cloud/clouddns" | ||||
|  | ||||
| const authorizationHeader = "Authorization" | ||||
|  | ||||
| // Client handles all communication with CloudDNS API. | ||||
| type Client struct { | ||||
| 	AccessToken string | ||||
| 	ClientID    string | ||||
| 	Email       string | ||||
| 	Password    string | ||||
| 	TTL         int | ||||
| 	HTTPClient  *http.Client | ||||
| 	clientID string | ||||
| 	email    string | ||||
| 	password string | ||||
| 	ttl      int | ||||
|  | ||||
| 	apiBaseURL string | ||||
| 	loginURL   string | ||||
| 	apiBaseURL *url.URL | ||||
|  | ||||
| 	loginURL *url.URL | ||||
|  | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient returns a Client instance configured to handle CloudDNS API communication. | ||||
| func NewClient(clientID, email, password string, ttl int) *Client { | ||||
| 	baseURL, _ := url.Parse(apiBaseURL) | ||||
| 	loginBaseURL, _ := url.Parse(loginURL) | ||||
|  | ||||
| 	return &Client{ | ||||
| 		ClientID:   clientID, | ||||
| 		Email:      email, | ||||
| 		Password:   password, | ||||
| 		TTL:        ttl, | ||||
| 		HTTPClient: &http.Client{}, | ||||
| 		apiBaseURL: apiBaseURL, | ||||
| 		loginURL:   loginURL, | ||||
| 		clientID:   clientID, | ||||
| 		email:      email, | ||||
| 		password:   password, | ||||
| 		ttl:        ttl, | ||||
| 		apiBaseURL: baseURL, | ||||
| 		loginURL:   loginBaseURL, | ||||
| 		HTTPClient: &http.Client{Timeout: 5 * time.Second}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // AddRecord is a high level method to add a new record into CloudDNS zone. | ||||
| func (c *Client) AddRecord(zone, recordName, recordValue string) error { | ||||
| 	domain, err := c.getDomain(zone) | ||||
| func (c *Client) AddRecord(ctx context.Context, zone, recordName, recordValue string) error { | ||||
| 	domain, err := c.getDomain(ctx, zone) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	record := Record{DomainID: domain.ID, Name: recordName, Value: recordValue, Type: "TXT"} | ||||
|  | ||||
| 	err = c.addTxtRecord(record) | ||||
| 	err = c.addTxtRecord(ctx, record) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return c.publishRecords(domain.ID) | ||||
| 	return c.publishRecords(ctx, domain.ID) | ||||
| } | ||||
|  | ||||
| // DeleteRecord is a high level method to remove a record from zone. | ||||
| func (c *Client) DeleteRecord(zone, recordName string) error { | ||||
| 	domain, err := c.getDomain(zone) | ||||
| func (c *Client) DeleteRecord(ctx context.Context, zone, recordName string) error { | ||||
| 	domain, err := c.getDomain(ctx, zone) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	record, err := c.getRecord(domain.ID, recordName) | ||||
| 	record, err := c.getRecord(ctx, domain.ID, recordName) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	err = c.deleteRecord(record) | ||||
| 	err = c.deleteRecord(ctx, record) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return c.publishRecords(domain.ID) | ||||
| 	return c.publishRecords(ctx, domain.ID) | ||||
| } | ||||
|  | ||||
| func (c *Client) addTxtRecord(record Record) error { | ||||
| 	body, err := json.Marshal(record) | ||||
| func (c *Client) addTxtRecord(ctx context.Context, record Record) error { | ||||
| 	endpoint := c.apiBaseURL.JoinPath("record-txt") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	_, err = c.doAPIRequest(http.MethodPost, "record-txt", bytes.NewReader(body)) | ||||
| 	return err | ||||
| 	return c.do(req, nil) | ||||
| } | ||||
|  | ||||
| func (c *Client) deleteRecord(record Record) error { | ||||
| 	endpoint := fmt.Sprintf("record/%s", record.ID) | ||||
| 	_, err := c.doAPIRequest(http.MethodDelete, endpoint, nil) | ||||
| 	return err | ||||
| func (c *Client) deleteRecord(ctx context.Context, record Record) error { | ||||
| 	endpoint := c.apiBaseURL.JoinPath("record", record.ID) | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return c.do(req, nil) | ||||
| } | ||||
|  | ||||
| func (c *Client) getDomain(zone string) (Domain, error) { | ||||
| func (c *Client) getDomain(ctx context.Context, zone string) (Domain, error) { | ||||
| 	searchQuery := SearchQuery{ | ||||
| 		Search: []Search{ | ||||
| 			{Name: "clientId", Operator: "eq", Value: c.ClientID}, | ||||
| 			{Name: "clientId", Operator: "eq", Value: c.clientID}, | ||||
| 			{Name: "domainName", Operator: "eq", Value: zone}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	body, err := json.Marshal(searchQuery) | ||||
| 	if err != nil { | ||||
| 		return Domain{}, err | ||||
| 	} | ||||
| 	endpoint := c.apiBaseURL.JoinPath("domain", "search") | ||||
|  | ||||
| 	resp, err := c.doAPIRequest(http.MethodPost, "domain/search", bytes.NewReader(body)) | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, searchQuery) | ||||
| 	if err != nil { | ||||
| 		return Domain{}, err | ||||
| 	} | ||||
|  | ||||
| 	var result SearchResponse | ||||
| 	err = json.Unmarshal(resp, &result) | ||||
| 	err = c.do(req, &result) | ||||
| 	if err != nil { | ||||
| 		return Domain{}, err | ||||
| 	} | ||||
| @@ -124,15 +134,16 @@ func (c *Client) getDomain(zone string) (Domain, error) { | ||||
| 	return result.Items[0], nil | ||||
| } | ||||
|  | ||||
| func (c *Client) getRecord(domainID, recordName string) (Record, error) { | ||||
| 	endpoint := fmt.Sprintf("domain/%s", domainID) | ||||
| 	resp, err := c.doAPIRequest(http.MethodGet, endpoint, nil) | ||||
| func (c *Client) getRecord(ctx context.Context, domainID, recordName string) (Record, error) { | ||||
| 	endpoint := c.apiBaseURL.JoinPath("domain", domainID) | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return Record{}, err | ||||
| 	} | ||||
|  | ||||
| 	var result DomainInfo | ||||
| 	err = json.Unmarshal(resp, &result) | ||||
| 	err = c.do(req, &result) | ||||
| 	if err != nil { | ||||
| 		return Record{}, err | ||||
| 	} | ||||
| @@ -146,116 +157,85 @@ func (c *Client) getRecord(domainID, recordName string) (Record, error) { | ||||
| 	return Record{}, fmt.Errorf("record not found: domainID %s, name %s", domainID, recordName) | ||||
| } | ||||
|  | ||||
| func (c *Client) publishRecords(domainID string) error { | ||||
| 	body, err := json.Marshal(DomainInfo{SoaTTL: c.TTL}) | ||||
| func (c *Client) publishRecords(ctx context.Context, domainID string) error { | ||||
| 	endpoint := c.apiBaseURL.JoinPath("domain", domainID, "publish") | ||||
|  | ||||
| 	payload := DomainInfo{SoaTTL: c.ttl} | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, payload) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	endpoint := fmt.Sprintf("domain/%s/publish", domainID) | ||||
| 	_, err = c.doAPIRequest(http.MethodPut, endpoint, bytes.NewReader(body)) | ||||
| 	return err | ||||
| 	return c.do(req, nil) | ||||
| } | ||||
|  | ||||
| func (c *Client) login() error { | ||||
| 	authorization := Authorization{Email: c.Email, Password: c.Password} | ||||
|  | ||||
| 	body, err := json.Marshal(authorization) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| func (c *Client) do(req *http.Request, result any) error { | ||||
| 	at := getAccessToken(req.Context()) | ||||
| 	if at != "" { | ||||
| 		req.Header.Set(authorizationHeader, "Bearer "+at) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodPost, c.loginURL, bytes.NewReader(body)) | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	req.Header.Set("Content-Type", "application/json") | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	content, err := c.doRequest(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	if resp.StatusCode/100 != 2 { | ||||
| 		return parseError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	var result AuthResponse | ||||
| 	err = json.Unmarshal(content, &result) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	if result == nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	c.AccessToken = result.Auth.AccessToken | ||||
| 	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 (c *Client) doAPIRequest(method, endpoint string, body io.Reader) ([]byte, error) { | ||||
| 	if c.AccessToken == "" { | ||||
| 		err := c.login() | ||||
| 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, err | ||||
| 			return nil, fmt.Errorf("failed to create request JSON body: %w", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	url := fmt.Sprintf("%s/%s", c.apiBaseURL, endpoint) | ||||
|  | ||||
| 	req, err := c.newRequest(method, url, body) | ||||
| 	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	content, err := c.doRequest(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	req.Header.Set("Accept", "application/json") | ||||
|  | ||||
| 	if payload != nil { | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 	} | ||||
|  | ||||
| 	return content, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) newRequest(method, reqURL string, body io.Reader) (*http.Request, error) { | ||||
| 	req, err := http.NewRequest(method, reqURL, body) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	req.Header.Set("Content-Type", "application/json") | ||||
| 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.AccessToken)) | ||||
|  | ||||
| 	return req, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) doRequest(req *http.Request) ([]byte, error) { | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| func parseError(req *http.Request, resp *http.Response) error { | ||||
| 	raw, _ := io.ReadAll(resp.Body) | ||||
|  | ||||
| 	if resp.StatusCode >= http.StatusBadRequest { | ||||
| 		return nil, readError(req, resp) | ||||
| 	var response APIError | ||||
| 	err := json.Unmarshal(raw, &response) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) | ||||
| 	} | ||||
|  | ||||
| 	content, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return content, nil | ||||
| } | ||||
|  | ||||
| func readError(req *http.Request, resp *http.Response) error { | ||||
| 	content, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return errors.New(toUnreadableBodyMessage(req, content)) | ||||
| 	} | ||||
|  | ||||
| 	var errInfo APIError | ||||
| 	err = json.Unmarshal(content, &errInfo) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("APIError unmarshaling error: %w: %s", err, toUnreadableBodyMessage(req, content)) | ||||
| 	} | ||||
|  | ||||
| 	return fmt.Errorf("HTTP %d: code %v: %s", resp.StatusCode, errInfo.Error.Code, errInfo.Error.Message) | ||||
| } | ||||
|  | ||||
| func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { | ||||
| 	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody)) | ||||
| 	return fmt.Errorf("[status code %d] %w", resp.StatusCode, response.Error) | ||||
| } | ||||
|   | ||||
| @@ -1,16 +1,33 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"net/url" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestClient_AddRecord(t *testing.T) { | ||||
| func setupTest(t *testing.T) (*Client, *http.ServeMux) { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	client := NewClient("clientID", "email@example.com", "secret", 300) | ||||
| 	client.HTTPClient = server.Client() | ||||
| 	client.apiBaseURL, _ = url.Parse(server.URL + "/api") | ||||
| 	client.loginURL, _ = url.Parse(server.URL + "/login") | ||||
|  | ||||
| 	return client, mux | ||||
| } | ||||
|  | ||||
| func TestClient_AddRecord(t *testing.T) { | ||||
| 	client, mux := setupTest(t) | ||||
|  | ||||
| 	mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		response := SearchResponse{ | ||||
| @@ -45,19 +62,12 @@ func TestClient_AddRecord(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	client := NewClient("clientID", "email@example.com", "secret", 300) | ||||
| 	client.apiBaseURL = server.URL + "/api" | ||||
| 	client.loginURL = server.URL + "/login" | ||||
|  | ||||
| 	err := client.AddRecord("example.com", "_acme-challenge.example.com", "txt") | ||||
| 	err := client.AddRecord(context.Background(), "example.com", "_acme-challenge.example.com", "txt") | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
|  | ||||
| func TestClient_DeleteRecord(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	client, mux := setupTest(t) | ||||
|  | ||||
| 	mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		response := SearchResponse{ | ||||
| @@ -114,13 +124,9 @@ func TestClient_DeleteRecord(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
| 	ctx, err := client.CreateAuthenticatedContext(context.Background()) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	client := NewClient("clientID", "email@example.com", "secret", 300) | ||||
| 	client.apiBaseURL = server.URL + "/api" | ||||
| 	client.loginURL = server.URL + "/login" | ||||
|  | ||||
| 	err := client.DeleteRecord("example.com", "_acme-challenge.example.com") | ||||
| 	err = client.DeleteRecord(ctx, "example.com", "_acme-challenge.example.com") | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
|   | ||||
							
								
								
									
										47
									
								
								providers/dns/clouddns/internal/identity.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								providers/dns/clouddns/internal/identity.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"net/http" | ||||
| ) | ||||
|  | ||||
| const loginURL = "https://admin.vshosting.cloud/api/public/auth/login" | ||||
|  | ||||
| type token string | ||||
|  | ||||
| const accessTokenKey token = "accessToken" | ||||
|  | ||||
| func (c *Client) login(ctx context.Context) (*AuthResponse, error) { | ||||
| 	authorization := Authorization{Email: c.email, Password: c.password} | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, c.loginURL, authorization) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	var result AuthResponse | ||||
| 	err = c.do(req, &result) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return &result, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { | ||||
| 	tok, err := c.login(ctx) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return context.WithValue(ctx, accessTokenKey, tok.Auth.AccessToken), nil | ||||
| } | ||||
|  | ||||
| func getAccessToken(ctx context.Context) string { | ||||
| 	tok, ok := ctx.Value(accessTokenKey).(string) | ||||
| 	if !ok { | ||||
| 		return "" | ||||
| 	} | ||||
|  | ||||
| 	return tok | ||||
| } | ||||
							
								
								
									
										46
									
								
								providers/dns/clouddns/internal/identity_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								providers/dns/clouddns/internal/identity_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"net/http" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestClient_CreateAuthenticatedContext(t *testing.T) { | ||||
| 	client, mux := setupTest(t) | ||||
|  | ||||
| 	mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		response := AuthResponse{ | ||||
| 			Auth: Auth{ | ||||
| 				AccessToken:  "at", | ||||
| 				RefreshToken: "", | ||||
| 			}, | ||||
| 		} | ||||
|  | ||||
| 		err := json.NewEncoder(rw).Encode(response) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
| 	mux.HandleFunc("/api/record/xxx", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		authorization := req.Header.Get(authorizationHeader) | ||||
| 		if authorization != "Bearer at" { | ||||
| 			http.Error(rw, "invalid credential: "+authorization, http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	ctx, err := client.CreateAuthenticatedContext(context.Background()) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	at := getAccessToken(ctx) | ||||
| 	assert.Equal(t, "at", at) | ||||
|  | ||||
| 	err = client.deleteRecord(ctx, Record{ID: "xxx"}) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
| @@ -1,5 +1,7 @@ | ||||
| package internal | ||||
| 
 | ||||
| import "fmt" | ||||
| 
 | ||||
| type APIError struct { | ||||
| 	Error ErrorContent `json:"error"` | ||||
| } | ||||
| @@ -9,6 +11,10 @@ type ErrorContent struct { | ||||
| 	Message string `json:"message,omitempty"` | ||||
| } | ||||
| 
 | ||||
| func (e ErrorContent) Error() string { | ||||
| 	return fmt.Sprintf("%d: %s", e.Code, e.Message) | ||||
| } | ||||
| 
 | ||||
| type Authorization struct { | ||||
| 	Email    string `json:"email,omitempty"` | ||||
| 	Password string `json:"password,omitempty"` | ||||
| @@ -126,7 +126,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("cloudflare: %w", err) | ||||
| 		return fmt.Errorf("cloudflare: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	zoneID, err := d.client.ZoneIDByName(authZone) | ||||
| @@ -165,7 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("cloudflare: %w", err) | ||||
| 		return fmt.Errorf("cloudflare: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	zoneID, err := d.client.ZoneIDByName(authZone) | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| package cloudns | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -104,29 +105,33 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	zone, err := d.client.GetZone(info.EffectiveFQDN) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	zone, err := d.client.GetZone(ctx, info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("ClouDNS: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.AddTxtRecord(zone.Name, info.EffectiveFQDN, info.Value, d.config.TTL) | ||||
| 	err = d.client.AddTxtRecord(ctx, zone.Name, info.EffectiveFQDN, info.Value, d.config.TTL) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("ClouDNS: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return d.waitNameservers(domain, zone) | ||||
| 	return d.waitNameservers(ctx, domain, zone) | ||||
| } | ||||
|  | ||||
| // CleanUp removes the TXT records matching the specified parameters. | ||||
| func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	zone, err := d.client.GetZone(info.EffectiveFQDN) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	zone, err := d.client.GetZone(ctx, info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("ClouDNS: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	records, err := d.client.ListTxtRecords(zone.Name, info.EffectiveFQDN) | ||||
| 	records, err := d.client.ListTxtRecords(ctx, zone.Name, info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("ClouDNS: %w", err) | ||||
| 	} | ||||
| @@ -136,7 +141,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	} | ||||
|  | ||||
| 	for _, record := range records { | ||||
| 		err = d.client.RemoveTxtRecord(record.ID, zone.Name) | ||||
| 		err = d.client.RemoveTxtRecord(ctx, record.ID, zone.Name) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("ClouDNS: %w", err) | ||||
| 		} | ||||
| @@ -153,9 +158,9 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { | ||||
|  | ||||
| // waitNameservers At the time of writing 4 servers are found as authoritative, but 8 are reported during the sync. | ||||
| // If this is not done, the secondary verification done by Let's Encrypt server will fail quire a bit. | ||||
| func (d *DNSProvider) waitNameservers(domain string, zone *internal.Zone) error { | ||||
| func (d *DNSProvider) waitNameservers(ctx context.Context, domain string, zone *internal.Zone) error { | ||||
| 	return wait.For("Nameserver sync on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) { | ||||
| 		syncProgress, err := d.client.GetUpdateStatus(zone.Name) | ||||
| 		syncProgress, err := d.client.GetUpdateStatus(ctx, zone.Name) | ||||
| 		if err != nil { | ||||
| 			return false, err | ||||
| 		} | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| @@ -8,8 +9,10 @@ import ( | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/challenge/dns01" | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| const defaultBaseURL = "https://api.cloudns.net/dns/" | ||||
| @@ -19,8 +22,9 @@ type Client struct { | ||||
| 	authID       string | ||||
| 	subAuthID    string | ||||
| 	authPassword string | ||||
| 	HTTPClient   *http.Client | ||||
| 	BaseURL      *url.URL | ||||
|  | ||||
| 	BaseURL    *url.URL | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient creates a ClouDNS client. | ||||
| @@ -42,16 +46,16 @@ func NewClient(authID, subAuthID, authPassword string) (*Client, error) { | ||||
| 		authID:       authID, | ||||
| 		subAuthID:    subAuthID, | ||||
| 		authPassword: authPassword, | ||||
| 		HTTPClient:   &http.Client{}, | ||||
| 		BaseURL:      baseURL, | ||||
| 		HTTPClient:   &http.Client{Timeout: 10 * time.Second}, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // GetZone Get domain name information for a FQDN. | ||||
| func (c *Client) GetZone(authFQDN string) (*Zone, error) { | ||||
| func (c *Client) GetZone(ctx context.Context, authFQDN string) (*Zone, error) { | ||||
| 	authZone, err := dns01.FindZoneByFqdn(authFQDN) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, fmt.Errorf("could not find zone for FQDN %q: %w", authFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	authZoneName := dns01.UnFqdn(authZone) | ||||
| @@ -62,16 +66,21 @@ func (c *Client) GetZone(authFQDN string) (*Zone, error) { | ||||
| 	q.Set("domain-name", authZoneName) | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	result, err := c.doRequest(http.MethodGet, endpoint) | ||||
| 	req, err := c.newRequest(ctx, http.MethodGet, endpoint) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	rawMessage, err := c.do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	var zone Zone | ||||
|  | ||||
| 	if len(result) > 0 { | ||||
| 		if err = json.Unmarshal(result, &zone); err != nil { | ||||
| 			return nil, fmt.Errorf("failed to unmarshal zone: %w", err) | ||||
| 	if len(rawMessage) > 0 { | ||||
| 		if err = json.Unmarshal(rawMessage, &zone); err != nil { | ||||
| 			return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -83,7 +92,7 @@ func (c *Client) GetZone(authFQDN string) (*Zone, error) { | ||||
| } | ||||
|  | ||||
| // FindTxtRecord returns the TXT record a zone ID and a FQDN. | ||||
| func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) { | ||||
| func (c *Client) FindTxtRecord(ctx context.Context, zoneName, fqdn string) (*TXTRecord, error) { | ||||
| 	subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| @@ -97,19 +106,24 @@ func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) { | ||||
| 	q.Set("type", "TXT") | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	result, err := c.doRequest(http.MethodGet, endpoint) | ||||
| 	req, err := c.newRequest(ctx, http.MethodGet, endpoint) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	rawMessage, err := c.do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// the API returns [] when there is no records. | ||||
| 	if string(result) == "[]" { | ||||
| 	if string(rawMessage) == "[]" { | ||||
| 		return nil, nil | ||||
| 	} | ||||
|  | ||||
| 	var records map[string]TXTRecord | ||||
| 	if err = json.Unmarshal(result, &records); err != nil { | ||||
| 		return nil, fmt.Errorf("failed to unmarshall TXT records: %w: %s", err, string(result)) | ||||
| 	if err = json.Unmarshal(rawMessage, &records); err != nil { | ||||
| 		return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) | ||||
| 	} | ||||
|  | ||||
| 	for _, record := range records { | ||||
| @@ -122,7 +136,7 @@ func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) { | ||||
| } | ||||
|  | ||||
| // ListTxtRecords returns the TXT records a zone ID and a FQDN. | ||||
| func (c *Client) ListTxtRecords(zoneName, fqdn string) ([]TXTRecord, error) { | ||||
| func (c *Client) ListTxtRecords(ctx context.Context, zoneName, fqdn string) ([]TXTRecord, error) { | ||||
| 	subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| @@ -136,19 +150,24 @@ func (c *Client) ListTxtRecords(zoneName, fqdn string) ([]TXTRecord, error) { | ||||
| 	q.Set("type", "TXT") | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	result, err := c.doRequest(http.MethodGet, endpoint) | ||||
| 	req, err := c.newRequest(ctx, http.MethodGet, endpoint) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	rawMessage, err := c.do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// the API returns [] when there is no records. | ||||
| 	if string(result) == "[]" { | ||||
| 	if string(rawMessage) == "[]" { | ||||
| 		return nil, nil | ||||
| 	} | ||||
|  | ||||
| 	var raw map[string]TXTRecord | ||||
| 	if err = json.Unmarshal(result, &raw); err != nil { | ||||
| 		return nil, fmt.Errorf("failed to unmarshall TXT records: %w: %s", err, string(result)) | ||||
| 	if err = json.Unmarshal(rawMessage, &raw); err != nil { | ||||
| 		return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) | ||||
| 	} | ||||
|  | ||||
| 	var records []TXTRecord | ||||
| @@ -162,7 +181,7 @@ func (c *Client) ListTxtRecords(zoneName, fqdn string) ([]TXTRecord, error) { | ||||
| } | ||||
|  | ||||
| // AddTxtRecord adds a TXT record. | ||||
| func (c *Client) AddTxtRecord(zoneName, fqdn, value string, ttl int) error { | ||||
| func (c *Client) AddTxtRecord(ctx context.Context, zoneName, fqdn, value string, ttl int) error { | ||||
| 	subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| @@ -178,14 +197,19 @@ func (c *Client) AddTxtRecord(zoneName, fqdn, value string, ttl int) error { | ||||
| 	q.Set("record-type", "TXT") | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	raw, err := c.doRequest(http.MethodPost, endpoint) | ||||
| 	req, err := c.newRequest(ctx, http.MethodPost, endpoint) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	rawMessage, err := c.do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	resp := apiResponse{} | ||||
| 	if err = json.Unmarshal(raw, &resp); err != nil { | ||||
| 		return fmt.Errorf("failed to unmarshal API response: %w: %s", err, string(raw)) | ||||
| 	if err = json.Unmarshal(rawMessage, &resp); err != nil { | ||||
| 		return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) | ||||
| 	} | ||||
|  | ||||
| 	if resp.Status != "Success" { | ||||
| @@ -196,7 +220,7 @@ func (c *Client) AddTxtRecord(zoneName, fqdn, value string, ttl int) error { | ||||
| } | ||||
|  | ||||
| // RemoveTxtRecord removes a TXT record. | ||||
| func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error { | ||||
| func (c *Client) RemoveTxtRecord(ctx context.Context, recordID int, zoneName string) error { | ||||
| 	endpoint := c.BaseURL.JoinPath("delete-record.json") | ||||
|  | ||||
| 	q := endpoint.Query() | ||||
| @@ -204,14 +228,19 @@ func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error { | ||||
| 	q.Set("record-id", strconv.Itoa(recordID)) | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	raw, err := c.doRequest(http.MethodPost, endpoint) | ||||
| 	req, err := c.newRequest(ctx, http.MethodPost, endpoint) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	rawMessage, err := c.do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	resp := apiResponse{} | ||||
| 	if err = json.Unmarshal(raw, &resp); err != nil { | ||||
| 		return fmt.Errorf("failed to unmarshal API response: %w: %s", err, string(raw)) | ||||
| 	if err = json.Unmarshal(rawMessage, &resp); err != nil { | ||||
| 		return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) | ||||
| 	} | ||||
|  | ||||
| 	if resp.Status != "Success" { | ||||
| @@ -222,26 +251,31 @@ func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error { | ||||
| } | ||||
|  | ||||
| // GetUpdateStatus gets sync progress of all CloudDNS NS servers. | ||||
| func (c *Client) GetUpdateStatus(zoneName string) (*SyncProgress, error) { | ||||
| func (c *Client) GetUpdateStatus(ctx context.Context, zoneName string) (*SyncProgress, error) { | ||||
| 	endpoint := c.BaseURL.JoinPath("update-status.json") | ||||
|  | ||||
| 	q := endpoint.Query() | ||||
| 	q.Set("domain-name", zoneName) | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	result, err := c.doRequest(http.MethodGet, endpoint) | ||||
| 	req, err := c.newRequest(ctx, http.MethodGet, endpoint) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	rawMessage, err := c.do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// the API returns [] when there is no records. | ||||
| 	if string(result) == "[]" { | ||||
| 	if string(rawMessage) == "[]" { | ||||
| 		return nil, errors.New("no nameservers records returned") | ||||
| 	} | ||||
|  | ||||
| 	var records []UpdateRecord | ||||
| 	if err = json.Unmarshal(result, &records); err != nil { | ||||
| 		return nil, fmt.Errorf("failed to unmarshal UpdateRecord: %w: %s", err, string(result)) | ||||
| 	if err = json.Unmarshal(rawMessage, &records); err != nil { | ||||
| 		return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) | ||||
| 	} | ||||
|  | ||||
| 	updatedCount := 0 | ||||
| @@ -254,33 +288,8 @@ func (c *Client) GetUpdateStatus(zoneName string) (*SyncProgress, error) { | ||||
| 	return &SyncProgress{Complete: updatedCount == len(records), Updated: updatedCount, Total: len(records)}, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) doRequest(method string, uri *url.URL) (json.RawMessage, error) { | ||||
| 	req, err := c.buildRequest(method, uri) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	content, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.New(toUnreadableBodyMessage(req, content)) | ||||
| 	} | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return nil, fmt.Errorf("invalid code (%d), error: %s", resp.StatusCode, content) | ||||
| 	} | ||||
|  | ||||
| 	return content, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) buildRequest(method string, uri *url.URL) (*http.Request, error) { | ||||
| 	q := uri.Query() | ||||
| func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL) (*http.Request, error) { | ||||
| 	q := endpoint.Query() | ||||
|  | ||||
| 	if c.subAuthID != "" { | ||||
| 		q.Set("sub-auth-id", c.subAuthID) | ||||
| @@ -290,18 +299,34 @@ func (c *Client) buildRequest(method string, uri *url.URL) (*http.Request, error | ||||
|  | ||||
| 	q.Set("auth-password", c.authPassword) | ||||
|  | ||||
| 	uri.RawQuery = q.Encode() | ||||
| 	endpoint.RawQuery = q.Encode() | ||||
|  | ||||
| 	req, err := http.NewRequest(method, uri.String(), nil) | ||||
| 	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), nil) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("invalid request: %w", err) | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return req, nil | ||||
| } | ||||
|  | ||||
| func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { | ||||
| 	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody)) | ||||
| func (c *Client) do(req *http.Request) (json.RawMessage, error) { | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	return raw, nil | ||||
| } | ||||
|  | ||||
| // Rounds the given TTL in seconds to the next accepted value. | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| @@ -11,6 +12,21 @@ import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func setupTest(t *testing.T, subAuthID string, handler http.HandlerFunc) *Client { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	server := httptest.NewServer(handler) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	client, err := NewClient("myAuthID", subAuthID, "myAuthPassword") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	client.BaseURL, _ = url.Parse(server.URL) | ||||
| 	client.HTTPClient = server.Client() | ||||
|  | ||||
| 	return client | ||||
| } | ||||
|  | ||||
| func handlerMock(method string, jsonData []byte) http.HandlerFunc { | ||||
| 	return func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != method { | ||||
| @@ -109,22 +125,16 @@ func TestClient_GetZone(t *testing.T) { | ||||
| 			authFQDN:    "_acme-challenge.foo.com.", | ||||
| 			apiResponse: `[{}]`, | ||||
| 			expected: expected{ | ||||
| 				errorMsg: "failed to unmarshal zone: json: cannot unmarshal array into Go value of type internal.Zone", | ||||
| 				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.Zone", | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, test := range testCases { | ||||
| 		t.Run(test.desc, func(t *testing.T) { | ||||
| 			server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) | ||||
| 			t.Cleanup(server.Close) | ||||
| 			client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) | ||||
|  | ||||
| 			client, err := NewClient("myAuthID", "", "myAuthPassword") | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			client.BaseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 			zone, err := client.GetZone(test.authFQDN) | ||||
| 			zone, err := client.GetZone(context.Background(), test.authFQDN) | ||||
|  | ||||
| 			if test.expected.errorMsg != "" { | ||||
| 				require.EqualError(t, err, test.expected.errorMsg) | ||||
| @@ -222,22 +232,16 @@ func TestClient_FindTxtRecord(t *testing.T) { | ||||
| 			zoneName:    "example.com", | ||||
| 			apiResponse: `[{}]`, | ||||
| 			expected: expected{ | ||||
| 				errorMsg: "failed to unmarshall TXT records: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord: [{}]", | ||||
| 				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord", | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, test := range testCases { | ||||
| 		t.Run(test.desc, func(t *testing.T) { | ||||
| 			server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) | ||||
| 			t.Cleanup(server.Close) | ||||
| 			client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) | ||||
|  | ||||
| 			client, err := NewClient("myAuthID", "", "myAuthPassword") | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			client.BaseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 			txtRecord, err := client.FindTxtRecord(test.zoneName, test.authFQDN) | ||||
| 			txtRecord, err := client.FindTxtRecord(context.Background(), test.zoneName, test.authFQDN) | ||||
|  | ||||
| 			if test.expected.errorMsg != "" { | ||||
| 				require.EqualError(t, err, test.expected.errorMsg) | ||||
| @@ -337,22 +341,16 @@ func TestClient_ListTxtRecord(t *testing.T) { | ||||
| 			zoneName:    "example.com", | ||||
| 			apiResponse: `[{}]`, | ||||
| 			expected: expected{ | ||||
| 				errorMsg: "failed to unmarshall TXT records: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord: [{}]", | ||||
| 				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord", | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, test := range testCases { | ||||
| 		t.Run(test.desc, func(t *testing.T) { | ||||
| 			server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) | ||||
| 			t.Cleanup(server.Close) | ||||
| 			client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) | ||||
|  | ||||
| 			client, err := NewClient("myAuthID", "", "myAuthPassword") | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			client.BaseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 			txtRecords, err := client.ListTxtRecords(test.zoneName, test.authFQDN) | ||||
| 			txtRecords, err := client.ListTxtRecords(context.Background(), test.zoneName, test.authFQDN) | ||||
|  | ||||
| 			if test.expected.errorMsg != "" { | ||||
| 				require.EqualError(t, err, test.expected.errorMsg) | ||||
| @@ -440,14 +438,14 @@ func TestClient_AddTxtRecord(t *testing.T) { | ||||
| 			apiResponse: `[{}]`, | ||||
| 			expected: expected{ | ||||
| 				query:    `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, | ||||
| 				errorMsg: "failed to unmarshal API response: json: cannot unmarshal array into Go value of type internal.apiResponse: [{}]", | ||||
| 				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, test := range testCases { | ||||
| 		t.Run(test.desc, func(t *testing.T) { | ||||
| 			server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { | ||||
| 			client := setupTest(t, test.subAuthID, func(rw http.ResponseWriter, req *http.Request) { | ||||
| 				if test.expected.query != req.URL.RawQuery { | ||||
| 					msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery) | ||||
| 					http.Error(rw, msg, http.StatusBadRequest) | ||||
| @@ -455,15 +453,9 @@ func TestClient_AddTxtRecord(t *testing.T) { | ||||
| 				} | ||||
|  | ||||
| 				handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req) | ||||
| 			})) | ||||
| 			t.Cleanup(server.Close) | ||||
| 			}) | ||||
|  | ||||
| 			client, err := NewClient(test.authID, test.subAuthID, "myAuthPassword") | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			client.BaseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 			err = client.AddTxtRecord(test.zoneName, test.authFQDN, test.value, test.ttl) | ||||
| 			err := client.AddTxtRecord(context.Background(), test.zoneName, test.authFQDN, test.value, test.ttl) | ||||
|  | ||||
| 			if test.expected.errorMsg != "" { | ||||
| 				require.EqualError(t, err, test.expected.errorMsg) | ||||
| @@ -513,7 +505,7 @@ func TestClient_RemoveTxtRecord(t *testing.T) { | ||||
| 			apiResponse: `[{}]`, | ||||
| 			expected: expected{ | ||||
| 				query:    `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo-plus.com&record-id=44`, | ||||
| 				errorMsg: "failed to unmarshal API response: json: cannot unmarshal array into Go value of type internal.apiResponse: [{}]", | ||||
| 				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| @@ -536,7 +528,7 @@ func TestClient_RemoveTxtRecord(t *testing.T) { | ||||
|  | ||||
| 			client.BaseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 			err = client.RemoveTxtRecord(test.id, test.zoneName) | ||||
| 			err = client.RemoveTxtRecord(context.Background(), test.id, test.zoneName) | ||||
|  | ||||
| 			if test.expected.errorMsg != "" { | ||||
| 				require.EqualError(t, err, test.expected.errorMsg) | ||||
| @@ -592,7 +584,7 @@ func TestClient_GetUpdateStatus(t *testing.T) { | ||||
| 			authFQDN:    "_acme-challenge.foo.com.", | ||||
| 			zoneName:    "test-zone", | ||||
| 			apiResponse: `[x]`, | ||||
| 			expected:    expected{errorMsg: "failed to unmarshal UpdateRecord: invalid character 'x' looking for beginning of value: [x]"}, | ||||
| 			expected:    expected{errorMsg: "unable to unmarshal response: [status code: 200] body: [x] error: invalid character 'x' looking for beginning of value"}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| @@ -606,7 +598,7 @@ func TestClient_GetUpdateStatus(t *testing.T) { | ||||
|  | ||||
| 			client.BaseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 			syncProgress, err := client.GetUpdateStatus(test.zoneName) | ||||
| 			syncProgress, err := client.GetUpdateStatus(context.Background(), test.zoneName) | ||||
|  | ||||
| 			if test.expected.errorMsg != "" { | ||||
| 				require.EqualError(t, err, test.expected.errorMsg) | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| package cloudxns | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -59,7 +60,7 @@ type DNSProvider struct { | ||||
| func NewDNSProvider() (*DNSProvider, error) { | ||||
| 	values, err := env.Get(EnvAPIKey, EnvSecretKey) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("CloudXNS: %w", err) | ||||
| 		return nil, fmt.Errorf("cloudxns: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	config := NewDefaultConfig() | ||||
| @@ -72,15 +73,17 @@ func NewDNSProvider() (*DNSProvider, error) { | ||||
| // NewDNSProviderConfig return a DNSProvider instance configured for CloudXNS. | ||||
| func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 	if config == nil { | ||||
| 		return nil, errors.New("CloudXNS: the configuration of the DNS provider is nil") | ||||
| 		return nil, errors.New("cloudxns: the configuration of the DNS provider is nil") | ||||
| 	} | ||||
|  | ||||
| 	client, err := internal.NewClient(config.APIKey, config.SecretKey) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, fmt.Errorf("cloudxns: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	client.HTTPClient = config.HTTPClient | ||||
| 	if config.HTTPClient != nil { | ||||
| 		client.HTTPClient = config.HTTPClient | ||||
| 	} | ||||
|  | ||||
| 	return &DNSProvider{client: client, config: config}, nil | ||||
| } | ||||
| @@ -89,29 +92,43 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	challengeInfo := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	info, err := d.client.GetDomainInformation(challengeInfo.EffectiveFQDN) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	info, err := d.client.GetDomainInformation(ctx, challengeInfo.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("cloudxns: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return d.client.AddTxtRecord(info, challengeInfo.EffectiveFQDN, challengeInfo.Value, d.config.TTL) | ||||
| 	err = d.client.AddTxtRecord(ctx, info, challengeInfo.EffectiveFQDN, challengeInfo.Value, d.config.TTL) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("cloudxns: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // CleanUp removes the TXT record matching the specified parameters. | ||||
| func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	challengeInfo := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	info, err := d.client.GetDomainInformation(challengeInfo.EffectiveFQDN) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	info, err := d.client.GetDomainInformation(ctx, challengeInfo.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("cloudxns: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	record, err := d.client.FindTxtRecord(info.ID, challengeInfo.EffectiveFQDN) | ||||
| 	record, err := d.client.FindTxtRecord(ctx, info.ID, challengeInfo.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("cloudxns: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return d.client.RemoveTxtRecord(record.RecordID, info.ID) | ||||
| 	err = d.client.RemoveTxtRecord(ctx, record.RecordID, info.ID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("cloudxns: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Timeout returns the timeout and interval to use when checking for DNS propagation. | ||||
|   | ||||
| @@ -34,7 +34,7 @@ func TestNewDNSProvider(t *testing.T) { | ||||
| 				EnvAPIKey:    "", | ||||
| 				EnvSecretKey: "", | ||||
| 			}, | ||||
| 			expected: "CloudXNS: some credentials information are missing: CLOUDXNS_API_KEY,CLOUDXNS_SECRET_KEY", | ||||
| 			expected: "cloudxns: some credentials information are missing: CLOUDXNS_API_KEY,CLOUDXNS_SECRET_KEY", | ||||
| 		}, | ||||
| 		{ | ||||
| 			desc: "missing API key", | ||||
| @@ -42,7 +42,7 @@ func TestNewDNSProvider(t *testing.T) { | ||||
| 				EnvAPIKey:    "", | ||||
| 				EnvSecretKey: "456", | ||||
| 			}, | ||||
| 			expected: "CloudXNS: some credentials information are missing: CLOUDXNS_API_KEY", | ||||
| 			expected: "cloudxns: some credentials information are missing: CLOUDXNS_API_KEY", | ||||
| 		}, | ||||
| 		{ | ||||
| 			desc: "missing secret key", | ||||
| @@ -50,7 +50,7 @@ func TestNewDNSProvider(t *testing.T) { | ||||
| 				EnvAPIKey:    "123", | ||||
| 				EnvSecretKey: "", | ||||
| 			}, | ||||
| 			expected: "CloudXNS: some credentials information are missing: CLOUDXNS_SECRET_KEY", | ||||
| 			expected: "cloudxns: some credentials information are missing: CLOUDXNS_SECRET_KEY", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| @@ -89,17 +89,17 @@ func TestNewDNSProviderConfig(t *testing.T) { | ||||
| 		}, | ||||
| 		{ | ||||
| 			desc:     "missing credentials", | ||||
| 			expected: "CloudXNS: credentials missing: apiKey", | ||||
| 			expected: "cloudxns: credentials missing: apiKey", | ||||
| 		}, | ||||
| 		{ | ||||
| 			desc:      "missing api key", | ||||
| 			secretKey: "456", | ||||
| 			expected:  "CloudXNS: credentials missing: apiKey", | ||||
| 			expected:  "cloudxns: credentials missing: apiKey", | ||||
| 		}, | ||||
| 		{ | ||||
| 			desc:     "missing secret key", | ||||
| 			apiKey:   "123", | ||||
| 			expected: "CloudXNS: credentials missing: secretKey", | ||||
| 			expected: "cloudxns: credentials missing: secretKey", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,7 @@ package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"crypto/md5" | ||||
| 	"encoding/hex" | ||||
| 	"encoding/json" | ||||
| @@ -9,83 +10,63 @@ import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/challenge/dns01" | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| const defaultBaseURL = "https://www.cloudxns.net/api2/" | ||||
|  | ||||
| type apiResponse struct { | ||||
| 	Code    int             `json:"code"` | ||||
| 	Message string          `json:"message"` | ||||
| 	Data    json.RawMessage `json:"data,omitempty"` | ||||
| } | ||||
| // Client CloudXNS client. | ||||
| type Client struct { | ||||
| 	apiKey    string | ||||
| 	secretKey string | ||||
|  | ||||
| // Data Domain information. | ||||
| type Data struct { | ||||
| 	ID     string `json:"id"` | ||||
| 	Domain string `json:"domain"` | ||||
| 	TTL    int    `json:"ttl,omitempty"` | ||||
| } | ||||
|  | ||||
| // TXTRecord a TXT record. | ||||
| type TXTRecord struct { | ||||
| 	ID       int    `json:"domain_id,omitempty"` | ||||
| 	RecordID string `json:"record_id,omitempty"` | ||||
|  | ||||
| 	Host   string `json:"host"` | ||||
| 	Value  string `json:"value"` | ||||
| 	Type   string `json:"type"` | ||||
| 	LineID int    `json:"line_id,string"` | ||||
| 	TTL    int    `json:"ttl,string"` | ||||
| 	baseURL    *url.URL | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient creates a CloudXNS client. | ||||
| func NewClient(apiKey, secretKey string) (*Client, error) { | ||||
| 	if apiKey == "" { | ||||
| 		return nil, errors.New("CloudXNS: credentials missing: apiKey") | ||||
| 		return nil, errors.New("credentials missing: apiKey") | ||||
| 	} | ||||
|  | ||||
| 	if secretKey == "" { | ||||
| 		return nil, errors.New("CloudXNS: credentials missing: secretKey") | ||||
| 		return nil, errors.New("credentials missing: secretKey") | ||||
| 	} | ||||
|  | ||||
| 	baseURL, _ := url.Parse(defaultBaseURL) | ||||
|  | ||||
| 	return &Client{ | ||||
| 		apiKey:     apiKey, | ||||
| 		secretKey:  secretKey, | ||||
| 		HTTPClient: &http.Client{}, | ||||
| 		BaseURL:    defaultBaseURL, | ||||
| 		baseURL:    baseURL, | ||||
| 		HTTPClient: &http.Client{Timeout: 10 * time.Second}, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // Client CloudXNS client. | ||||
| type Client struct { | ||||
| 	apiKey     string | ||||
| 	secretKey  string | ||||
| 	HTTPClient *http.Client | ||||
| 	BaseURL    string | ||||
| } | ||||
|  | ||||
| // GetDomainInformation Get domain name information for a FQDN. | ||||
| func (c *Client) GetDomainInformation(fqdn string) (*Data, error) { | ||||
| 	authZone, err := dns01.FindZoneByFqdn(fqdn) | ||||
| func (c *Client) GetDomainInformation(ctx context.Context, fqdn string) (*Data, error) { | ||||
| 	endpoint := c.baseURL.JoinPath("domain") | ||||
|  | ||||
| 	req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	result, err := c.doRequest(http.MethodGet, "domain", nil) | ||||
| 	authZone, err := dns01.FindZoneByFqdn(fqdn) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, fmt.Errorf("cloudflare: could not find zone for FQDN %q: %w", fqdn, err) | ||||
| 	} | ||||
|  | ||||
| 	var domains []Data | ||||
| 	if len(result) > 0 { | ||||
| 		err = json.Unmarshal(result, &domains) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("CloudXNS: domains unmarshaling error: %w", err) | ||||
| 		} | ||||
| 	err = c.do(req, &domains) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	for _, data := range domains { | ||||
| @@ -94,20 +75,28 @@ func (c *Client) GetDomainInformation(fqdn string) (*Data, error) { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil, fmt.Errorf("CloudXNS: zone %s not found for domain %s", authZone, fqdn) | ||||
| 	return nil, fmt.Errorf("zone %s not found for domain %s", authZone, fqdn) | ||||
| } | ||||
|  | ||||
| // FindTxtRecord return the TXT record a zone ID and a FQDN. | ||||
| func (c *Client) FindTxtRecord(zoneID, fqdn string) (*TXTRecord, error) { | ||||
| 	result, err := c.doRequest(http.MethodGet, fmt.Sprintf("record/%s?host_id=0&offset=0&row_num=2000", zoneID), nil) | ||||
| func (c *Client) FindTxtRecord(ctx context.Context, zoneID, fqdn string) (*TXTRecord, error) { | ||||
| 	endpoint := c.baseURL.JoinPath("record", zoneID) | ||||
|  | ||||
| 	query := endpoint.Query() | ||||
| 	query.Set("host_id", "0") | ||||
| 	query.Set("offset", "0") | ||||
| 	query.Set("row_num", "2000") | ||||
| 	endpoint.RawQuery = query.Encode() | ||||
|  | ||||
| 	req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	var records []TXTRecord | ||||
| 	err = json.Unmarshal(result, &records) | ||||
| 	err = c.do(req, &records) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("CloudXNS: TXT record unmarshaling error: %w", err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	for _, record := range records { | ||||
| @@ -116,22 +105,24 @@ func (c *Client) FindTxtRecord(zoneID, fqdn string) (*TXTRecord, error) { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil, fmt.Errorf("CloudXNS: no existing record found for %q", fqdn) | ||||
| 	return nil, fmt.Errorf("no existing record found for %q", fqdn) | ||||
| } | ||||
|  | ||||
| // AddTxtRecord add a TXT record. | ||||
| func (c *Client) AddTxtRecord(info *Data, fqdn, value string, ttl int) error { | ||||
| func (c *Client) AddTxtRecord(ctx context.Context, info *Data, fqdn, value string, ttl int) error { | ||||
| 	id, err := strconv.Atoi(info.ID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("CloudXNS: invalid zone ID: %w", err) | ||||
| 		return fmt.Errorf("invalid zone ID: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	endpoint := c.baseURL.JoinPath("record") | ||||
|  | ||||
| 	subDomain, err := dns01.ExtractSubDomain(fqdn, info.Domain) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("CloudXNS: %w", err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	payload := TXTRecord{ | ||||
| 	record := TXTRecord{ | ||||
| 		ID:     id, | ||||
| 		Host:   subDomain, | ||||
| 		Value:  value, | ||||
| @@ -140,74 +131,91 @@ func (c *Client) AddTxtRecord(info *Data, fqdn, value string, ttl int) error { | ||||
| 		TTL:    ttl, | ||||
| 	} | ||||
|  | ||||
| 	body, err := json.Marshal(payload) | ||||
| 	req, err := c.newRequest(ctx, http.MethodPost, endpoint, record) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("CloudXNS: record unmarshaling error: %w", err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	_, err = c.doRequest(http.MethodPost, "record", body) | ||||
| 	return err | ||||
| 	return c.do(req, nil) | ||||
| } | ||||
|  | ||||
| // RemoveTxtRecord remove a TXT record. | ||||
| func (c *Client) RemoveTxtRecord(recordID, zoneID string) error { | ||||
| 	_, err := c.doRequest(http.MethodDelete, fmt.Sprintf("record/%s/%s", recordID, zoneID), nil) | ||||
| 	return err | ||||
| } | ||||
| func (c *Client) RemoveTxtRecord(ctx context.Context, recordID, zoneID string) error { | ||||
| 	endpoint := c.baseURL.JoinPath("record", recordID, zoneID) | ||||
|  | ||||
| func (c *Client) doRequest(method, uri string, body []byte) (json.RawMessage, error) { | ||||
| 	req, err := c.buildRequest(method, uri, body) | ||||
| 	req, err := c.newRequest(ctx, http.MethodDelete, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return c.do(req, nil) | ||||
| } | ||||
|  | ||||
| func (c *Client) do(req *http.Request, result any) error { | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("CloudXNS: %w", err) | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer resp.Body.Close() | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	content, err := io.ReadAll(resp.Body) | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("CloudXNS: %s", toUnreadableBodyMessage(req, content)) | ||||
| 		return errutils.NewReadResponseError(req, resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	var r apiResponse | ||||
| 	err = json.Unmarshal(content, &r) | ||||
| 	var response apiResponse | ||||
| 	err = json.Unmarshal(raw, &response) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("CloudXNS: response unmashaling error: %w: %s", err, toUnreadableBodyMessage(req, content)) | ||||
| 		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) | ||||
| 	} | ||||
|  | ||||
| 	if r.Code != 1 { | ||||
| 		return nil, fmt.Errorf("CloudXNS: invalid code (%v), error: %s", r.Code, r.Message) | ||||
| 	if response.Code != 1 { | ||||
| 		return fmt.Errorf("[status code %d] invalid code (%v) error: %s", resp.StatusCode, response.Code, response.Message) | ||||
| 	} | ||||
| 	return r.Data, nil | ||||
|  | ||||
| 	if result == nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if len(response.Data) == 0 { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	err = json.Unmarshal(response.Data, result) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (c *Client) buildRequest(method, uri string, body []byte) (*http.Request, error) { | ||||
| 	url := c.BaseURL + uri | ||||
| func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { | ||||
| 	buf := new(bytes.Buffer) | ||||
|  | ||||
| 	req, err := http.NewRequest(method, url, bytes.NewReader(body)) | ||||
| 	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("CloudXNS: invalid request: %w", err) | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	requestDate := time.Now().Format(time.RFC1123Z) | ||||
|  | ||||
| 	req.Header.Set("API-KEY", c.apiKey) | ||||
| 	req.Header.Set("API-REQUEST-DATE", requestDate) | ||||
| 	req.Header.Set("API-HMAC", c.hmac(url, requestDate, string(body))) | ||||
| 	req.Header.Set("API-HMAC", c.hmac(endpoint.String(), requestDate, buf.String())) | ||||
| 	req.Header.Set("API-FORMAT", "json") | ||||
|  | ||||
| 	return req, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) hmac(url, date, body string) string { | ||||
| 	sum := md5.Sum([]byte(c.apiKey + url + body + date + c.secretKey)) | ||||
| func (c *Client) hmac(endpoint, date, body string) string { | ||||
| 	sum := md5.Sum([]byte(c.apiKey + endpoint + body + date + c.secretKey)) | ||||
| 	return hex.EncodeToString(sum[:]) | ||||
| } | ||||
|  | ||||
| func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { | ||||
| 	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody)) | ||||
| } | ||||
|   | ||||
| @@ -1,19 +1,35 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"net/url" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func handlerMock(method string, response *apiResponse, data interface{}) http.Handler { | ||||
| 	return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { | ||||
| func setupTest(t *testing.T, handler http.HandlerFunc) *Client { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	server := httptest.NewServer(handler) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	client, _ := NewClient("myKey", "mySecret") | ||||
| 	client.baseURL, _ = url.Parse(server.URL + "/") | ||||
| 	client.HTTPClient = server.Client() | ||||
|  | ||||
| 	return client | ||||
| } | ||||
|  | ||||
| func handlerMock(method string, response *apiResponse, data interface{}) http.HandlerFunc { | ||||
| 	return func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != method { | ||||
| 			content, err := json.Marshal(apiResponse{ | ||||
| 				Code:    999, // random code only for the test | ||||
| @@ -47,10 +63,10 @@ func handlerMock(method string, response *apiResponse, data interface{}) http.Ha | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestClientGetDomainInformation(t *testing.T) { | ||||
| func TestClient_GetDomainInformation(t *testing.T) { | ||||
| 	type result struct { | ||||
| 		domain *Data | ||||
| 		error  bool | ||||
| @@ -106,13 +122,9 @@ func TestClientGetDomainInformation(t *testing.T) { | ||||
|  | ||||
| 	for _, test := range testCases { | ||||
| 		t.Run(test.desc, func(t *testing.T) { | ||||
| 			server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.data)) | ||||
| 			t.Cleanup(server.Close) | ||||
| 			client := setupTest(t, handlerMock(http.MethodGet, test.response, test.data)) | ||||
|  | ||||
| 			client, _ := NewClient("myKey", "mySecret") | ||||
| 			client.BaseURL = server.URL + "/" | ||||
|  | ||||
| 			domain, err := client.GetDomainInformation(test.fqdn) | ||||
| 			domain, err := client.GetDomainInformation(context.Background(), test.fqdn) | ||||
|  | ||||
| 			if test.expected.error { | ||||
| 				require.Error(t, err) | ||||
| @@ -124,7 +136,7 @@ func TestClientGetDomainInformation(t *testing.T) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestClientFindTxtRecord(t *testing.T) { | ||||
| func TestClient_FindTxtRecord(t *testing.T) { | ||||
| 	type result struct { | ||||
| 		txtRecord *TXTRecord | ||||
| 		error     bool | ||||
| @@ -210,13 +222,9 @@ func TestClientFindTxtRecord(t *testing.T) { | ||||
|  | ||||
| 	for _, test := range testCases { | ||||
| 		t.Run(test.desc, func(t *testing.T) { | ||||
| 			server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.txtRecords)) | ||||
| 			t.Cleanup(server.Close) | ||||
| 			client := setupTest(t, handlerMock(http.MethodGet, test.response, test.txtRecords)) | ||||
|  | ||||
| 			client, _ := NewClient("myKey", "mySecret") | ||||
| 			client.BaseURL = server.URL + "/" | ||||
|  | ||||
| 			txtRecord, err := client.FindTxtRecord(test.zoneID, test.fqdn) | ||||
| 			txtRecord, err := client.FindTxtRecord(context.Background(), test.zoneID, test.fqdn) | ||||
|  | ||||
| 			if test.expected.error { | ||||
| 				require.Error(t, err) | ||||
| @@ -228,7 +236,7 @@ func TestClientFindTxtRecord(t *testing.T) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestClientAddTxtRecord(t *testing.T) { | ||||
| func TestClient_AddTxtRecord(t *testing.T) { | ||||
| 	testCases := []struct { | ||||
| 		desc     string | ||||
| 		domain   *Data | ||||
| @@ -267,21 +275,17 @@ func TestClientAddTxtRecord(t *testing.T) { | ||||
| 				Code: 1, | ||||
| 			} | ||||
|  | ||||
| 			server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { | ||||
| 			client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { | ||||
| 				assert.NotNil(t, req.Body) | ||||
| 				content, err := io.ReadAll(req.Body) | ||||
| 				require.NoError(t, err) | ||||
|  | ||||
| 				assert.Equal(t, test.expected, string(content)) | ||||
| 				assert.Equal(t, test.expected, string(bytes.TrimSpace(content))) | ||||
|  | ||||
| 				handlerMock(http.MethodPost, response, nil).ServeHTTP(rw, req) | ||||
| 			})) | ||||
| 			t.Cleanup(server.Close) | ||||
| 			}) | ||||
|  | ||||
| 			client, _ := NewClient("myKey", "mySecret") | ||||
| 			client.BaseURL = server.URL + "/" | ||||
|  | ||||
| 			err := client.AddTxtRecord(test.domain, test.fqdn, test.value, test.ttl) | ||||
| 			err := client.AddTxtRecord(context.Background(), test.domain, test.fqdn, test.value, test.ttl) | ||||
| 			require.NoError(t, err) | ||||
| 		}) | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										28
									
								
								providers/dns/cloudxns/internal/types.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								providers/dns/cloudxns/internal/types.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| package internal | ||||
|  | ||||
| import "encoding/json" | ||||
|  | ||||
| type apiResponse struct { | ||||
| 	Code    int             `json:"code"` | ||||
| 	Message string          `json:"message"` | ||||
| 	Data    json.RawMessage `json:"data,omitempty"` | ||||
| } | ||||
|  | ||||
| // Data Domain information. | ||||
| type Data struct { | ||||
| 	ID     string `json:"id"` | ||||
| 	Domain string `json:"domain"` | ||||
| 	TTL    int    `json:"ttl,omitempty"` | ||||
| } | ||||
|  | ||||
| // TXTRecord a TXT record. | ||||
| type TXTRecord struct { | ||||
| 	ID       int    `json:"domain_id,omitempty"` | ||||
| 	RecordID string `json:"record_id,omitempty"` | ||||
|  | ||||
| 	Host   string `json:"host"` | ||||
| 	Value  string `json:"value"` | ||||
| 	Type   string `json:"type"` | ||||
| 	LineID int    `json:"line_id,string"` | ||||
| 	TTL    int    `json:"ttl,string"` | ||||
| } | ||||
| @@ -2,6 +2,7 @@ | ||||
| package conoha | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -85,6 +86,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 		return nil, errors.New("conoha: some credentials information are missing") | ||||
| 	} | ||||
|  | ||||
| 	identifier, err := internal.NewIdentifier(config.Region) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("conoha: failed to create identity client: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if config.HTTPClient != nil { | ||||
| 		identifier.HTTPClient = config.HTTPClient | ||||
| 	} | ||||
|  | ||||
| 	auth := internal.Auth{ | ||||
| 		TenantID: config.TenantID, | ||||
| 		PasswordCredentials: internal.PasswordCredentials{ | ||||
| @@ -93,11 +103,20 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	client, err := internal.NewClient(config.Region, auth, config.HTTPClient) | ||||
| 	tokens, err := identifier.GetToken(context.TODO(), auth) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("conoha: failed to login: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	client, err := internal.NewClient(config.Region, tokens.Access.Token.ID) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("conoha: failed to create client: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if config.HTTPClient != nil { | ||||
| 		client.HTTPClient = config.HTTPClient | ||||
| 	} | ||||
|  | ||||
| 	return &DNSProvider{config: config, client: client}, nil | ||||
| } | ||||
|  | ||||
| @@ -107,10 +126,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("conoha: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	id, err := d.client.GetDomainID(authZone) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	id, err := d.client.GetDomainID(ctx, authZone) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("conoha: failed to get domain ID: %w", err) | ||||
| 	} | ||||
| @@ -122,7 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 		TTL:  d.config.TTL, | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.CreateRecord(id, record) | ||||
| 	err = d.client.CreateRecord(ctx, id, record) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("conoha: failed to create record: %w", err) | ||||
| 	} | ||||
| @@ -136,20 +157,22 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("conoha: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	domID, err := d.client.GetDomainID(authZone) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	domID, err := d.client.GetDomainID(ctx, authZone) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("conoha: failed to get domain ID: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	recID, err := d.client.GetRecordID(domID, info.EffectiveFQDN, "TXT", info.Value) | ||||
| 	recID, err := d.client.GetRecordID(ctx, domID, info.EffectiveFQDN, "TXT", info.Value) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("conoha: failed to get record ID: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	err = d.client.DeleteRecord(domID, recID) | ||||
| 	err = d.client.DeleteRecord(ctx, domID, recID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("conoha: failed to delete record: %w", err) | ||||
| 	} | ||||
|   | ||||
| @@ -29,7 +29,7 @@ func TestNewDNSProvider(t *testing.T) { | ||||
| 				EnvAPIUsername: "api_username", | ||||
| 				EnvAPIPassword: "api_password", | ||||
| 			}, | ||||
| 			expected: `conoha: failed to create client: failed to login: HTTP request failed with status code 401: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, | ||||
| 			expected: `conoha: failed to login: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, | ||||
| 		}, | ||||
| 		{ | ||||
| 			desc: "missing credentials", | ||||
| @@ -99,7 +99,7 @@ func TestNewDNSProviderConfig(t *testing.T) { | ||||
| 	}{ | ||||
| 		{ | ||||
| 			desc:     "complete credentials, but login failed", | ||||
| 			expected: `conoha: failed to create client: failed to login: HTTP request failed with status code 401: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, | ||||
| 			expected: `conoha: failed to login: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, | ||||
| 			tenant:   "tenant_id", | ||||
| 			username: "api_username", | ||||
| 			password: "api_password", | ||||
|   | ||||
| @@ -2,121 +2,45 @@ package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	identityBaseURL   = "https://identity.%s.conoha.io" | ||||
| 	dnsServiceBaseURL = "https://dns-service.%s.conoha.io" | ||||
| ) | ||||
|  | ||||
| // IdentityRequest is an authentication request body. | ||||
| type IdentityRequest struct { | ||||
| 	Auth Auth `json:"auth"` | ||||
| } | ||||
|  | ||||
| // Auth is an authentication information. | ||||
| type Auth struct { | ||||
| 	TenantID            string              `json:"tenantId"` | ||||
| 	PasswordCredentials PasswordCredentials `json:"passwordCredentials"` | ||||
| } | ||||
|  | ||||
| // PasswordCredentials is API-user's credentials. | ||||
| type PasswordCredentials struct { | ||||
| 	Username string `json:"username"` | ||||
| 	Password string `json:"password"` | ||||
| } | ||||
|  | ||||
| // IdentityResponse is an authentication response body. | ||||
| type IdentityResponse struct { | ||||
| 	Access Access `json:"access"` | ||||
| } | ||||
|  | ||||
| // Access is an identity information. | ||||
| type Access struct { | ||||
| 	Token Token `json:"token"` | ||||
| } | ||||
|  | ||||
| // Token is an api access token. | ||||
| type Token struct { | ||||
| 	ID string `json:"id"` | ||||
| } | ||||
|  | ||||
| // DomainListResponse is a response of a domain listing request. | ||||
| type DomainListResponse struct { | ||||
| 	Domains []Domain `json:"domains"` | ||||
| } | ||||
|  | ||||
| // Domain is a hosted domain entry. | ||||
| type Domain struct { | ||||
| 	ID   string `json:"id"` | ||||
| 	Name string `json:"name"` | ||||
| } | ||||
|  | ||||
| // RecordListResponse is a response of record listing request. | ||||
| type RecordListResponse struct { | ||||
| 	Records []Record `json:"records"` | ||||
| } | ||||
|  | ||||
| // Record is a record entry. | ||||
| type Record struct { | ||||
| 	ID   string `json:"id,omitempty"` | ||||
| 	Name string `json:"name"` | ||||
| 	Type string `json:"type"` | ||||
| 	Data string `json:"data"` | ||||
| 	TTL  int    `json:"ttl"` | ||||
| } | ||||
| const dnsServiceBaseURL = "https://dns-service.%s.conoha.io" | ||||
|  | ||||
| // Client is a ConoHa API client. | ||||
| type Client struct { | ||||
| 	token      string | ||||
| 	endpoint   string | ||||
| 	httpClient *http.Client | ||||
| 	token string | ||||
|  | ||||
| 	baseURL    *url.URL | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient returns a client instance logged into the ConoHa service. | ||||
| func NewClient(region string, auth Auth, httpClient *http.Client) (*Client, error) { | ||||
| 	if httpClient == nil { | ||||
| 		httpClient = &http.Client{} | ||||
| 	} | ||||
|  | ||||
| 	c := &Client{httpClient: httpClient} | ||||
|  | ||||
| 	c.endpoint = fmt.Sprintf(identityBaseURL, region) | ||||
|  | ||||
| 	identity, err := c.getIdentity(auth) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to login: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	c.token = identity.Access.Token.ID | ||||
| 	c.endpoint = fmt.Sprintf(dnsServiceBaseURL, region) | ||||
|  | ||||
| 	return c, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) getIdentity(auth Auth) (*IdentityResponse, error) { | ||||
| 	req := &IdentityRequest{Auth: auth} | ||||
|  | ||||
| 	identity := &IdentityResponse{} | ||||
|  | ||||
| 	err := c.do(http.MethodPost, "/v2.0/tokens", req, identity) | ||||
| func NewClient(region string, token string) (*Client, error) { | ||||
| 	baseURL, err := url.Parse(fmt.Sprintf(dnsServiceBaseURL, region)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return identity, nil | ||||
| 	return &Client{ | ||||
| 		token:      token, | ||||
| 		baseURL:    baseURL, | ||||
| 		HTTPClient: &http.Client{Timeout: 5 * time.Second}, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // GetDomainID returns an ID of specified domain. | ||||
| func (c *Client) GetDomainID(domainName string) (string, error) { | ||||
| 	domainList := &DomainListResponse{} | ||||
|  | ||||
| 	err := c.do(http.MethodGet, "/v1/domains", nil, domainList) | ||||
| func (c *Client) GetDomainID(ctx context.Context, domainName string) (string, error) { | ||||
| 	domainList, err := c.getDomains(ctx) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| @@ -126,14 +50,32 @@ func (c *Client) GetDomainID(domainName string) (string, error) { | ||||
| 			return domain.ID, nil | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return "", fmt.Errorf("no such domain: %s", domainName) | ||||
| } | ||||
|  | ||||
| // GetRecordID returns an ID of specified record. | ||||
| func (c *Client) GetRecordID(domainID, recordName, recordType, data string) (string, error) { | ||||
| 	recordList := &RecordListResponse{} | ||||
| // https://www.conoha.jp/docs/paas-dns-list-domains.php | ||||
| func (c *Client) getDomains(ctx context.Context) (*DomainListResponse, error) { | ||||
| 	endpoint := c.baseURL.JoinPath("v1", "domains") | ||||
|  | ||||
| 	err := c.do(http.MethodGet, fmt.Sprintf("/v1/domains/%s/records", domainID), nil, recordList) | ||||
| 	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	domainList := &DomainListResponse{} | ||||
|  | ||||
| 	err = c.do(req, domainList) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return domainList, nil | ||||
| } | ||||
|  | ||||
| // GetRecordID returns an ID of specified record. | ||||
| func (c *Client) GetRecordID(ctx context.Context, domainID, recordName, recordType, data string) (string, error) { | ||||
| 	recordList, err := c.getRecords(ctx, domainID) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| @@ -143,63 +85,119 @@ func (c *Client) GetRecordID(domainID, recordName, recordType, data string) (str | ||||
| 			return record.ID, nil | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return "", errors.New("no such record") | ||||
| } | ||||
|  | ||||
| // https://www.conoha.jp/docs/paas-dns-list-records-in-a-domain.php | ||||
| func (c *Client) getRecords(ctx context.Context, domainID string) (*RecordListResponse, error) { | ||||
| 	endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	recordList := &RecordListResponse{} | ||||
|  | ||||
| 	err = c.do(req, recordList) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return recordList, nil | ||||
| } | ||||
|  | ||||
| // CreateRecord adds new record. | ||||
| func (c *Client) CreateRecord(domainID string, record Record) error { | ||||
| 	return c.do(http.MethodPost, fmt.Sprintf("/v1/domains/%s/records", domainID), record, nil) | ||||
| func (c *Client) CreateRecord(ctx context.Context, domainID string, record Record) error { | ||||
| 	_, err := c.createRecord(ctx, domainID, record) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // https://www.conoha.jp/docs/paas-dns-create-record.php | ||||
| func (c *Client) createRecord(ctx context.Context, domainID string, record Record) (*Record, error) { | ||||
| 	endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	newRecord := &Record{} | ||||
| 	err = c.do(req, newRecord) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return newRecord, nil | ||||
| } | ||||
|  | ||||
| // DeleteRecord removes specified record. | ||||
| func (c *Client) DeleteRecord(domainID, recordID string) error { | ||||
| 	return c.do(http.MethodDelete, fmt.Sprintf("/v1/domains/%s/records/%s", domainID, recordID), nil, nil) | ||||
| // https://www.conoha.jp/docs/paas-dns-delete-a-record.php | ||||
| func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID string) error { | ||||
| 	endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records", recordID) | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return c.do(req, nil) | ||||
| } | ||||
|  | ||||
| func (c *Client) do(method, path string, payload, result interface{}) error { | ||||
| 	body := bytes.NewReader(nil) | ||||
|  | ||||
| 	if payload != nil { | ||||
| 		bodyBytes, err := json.Marshal(payload) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		body = bytes.NewReader(bodyBytes) | ||||
| func (c *Client) do(req *http.Request, result any) error { | ||||
| 	if c.token != "" { | ||||
| 		req.Header.Set("X-Auth-Token", c.token) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(method, c.endpoint+path, body) | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	req.Header.Set("Accept", "application/json") | ||||
| 	req.Header.Set("Content-Type", "application/json") | ||||
| 	req.Header.Set("X-Auth-Token", c.token) | ||||
|  | ||||
| 	resp, err := c.httpClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		respBody, err := io.ReadAll(resp.Body) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		defer resp.Body.Close() | ||||
|  | ||||
| 		return fmt.Errorf("HTTP request failed with status code %d: %s", resp.StatusCode, string(respBody)) | ||||
| 		return errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	if result != nil { | ||||
| 		respBody, err := io.ReadAll(resp.Body) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		defer resp.Body.Close() | ||||
| 	if result == nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 		return json.Unmarshal(respBody, result) | ||||
| 	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 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 | ||||
| } | ||||
|   | ||||
| @@ -1,30 +1,71 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func setupTest(t *testing.T) (*http.ServeMux, *Client) { | ||||
| func setupTest(t *testing.T) (*Client, *http.ServeMux) { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	client := &Client{ | ||||
| 		token:      "secret", | ||||
| 		endpoint:   server.URL, | ||||
| 		httpClient: server.Client(), | ||||
| 	} | ||||
| 	client, err := NewClient("tyo1", "secret") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	return mux, client | ||||
| 	client.HTTPClient = server.Client() | ||||
| 	client.baseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 	return client, mux | ||||
| } | ||||
|  | ||||
| func writeFixtureHandler(method, filename string) http.HandlerFunc { | ||||
| 	return func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != method { | ||||
| 			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		writeFixture(rw, filename) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func writeBodyHandler(method, content string) http.HandlerFunc { | ||||
| 	return func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != method { | ||||
| 			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
| 		_, err := fmt.Fprint(rw, content) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func writeFixture(rw http.ResponseWriter, filename string) { | ||||
| 	file, err := os.Open(filepath.Join("fixtures", filename)) | ||||
| 	if err != nil { | ||||
| 		http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 		return | ||||
| 	} | ||||
| 	defer func() { _ = file.Close() }() | ||||
|  | ||||
| 	_, _ = io.Copy(rw, file) | ||||
| } | ||||
|  | ||||
| func TestClient_GetDomainID(t *testing.T) { | ||||
| @@ -42,91 +83,30 @@ func TestClient_GetDomainID(t *testing.T) { | ||||
| 		{ | ||||
| 			desc:       "success", | ||||
| 			domainName: "domain1.com.", | ||||
| 			handler: func(rw http.ResponseWriter, req *http.Request) { | ||||
| 				if req.Method != http.MethodGet { | ||||
| 					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed) | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				content := ` | ||||
| { | ||||
|     "domains":[ | ||||
|       { | ||||
|         "id": "09494b72-b65b-4297-9efb-187f65a0553e", | ||||
|         "name": "domain1.com.", | ||||
|         "ttl": 3600, | ||||
|         "serial": 1351800668, | ||||
|         "email": "nsadmin@example.org", | ||||
|         "gslb": 0, | ||||
|         "created_at": "2012-11-01T20:11:08.000000", | ||||
|         "updated_at": null, | ||||
|         "description": "memo" | ||||
|       }, | ||||
|       { | ||||
|         "id": "cf661142-e577-40b5-b3eb-75795cdc0cd7", | ||||
|         "name": "domain2.com.", | ||||
|         "ttl": 7200, | ||||
|         "serial": 1351800670, | ||||
|         "email": "nsadmin2@example.org", | ||||
|         "gslb": 1, | ||||
|         "created_at": "2012-11-01T20:11:08.000000", | ||||
|         "updated_at": "2012-12-01T20:11:08.000000", | ||||
|         "description": "memomemo" | ||||
|       } | ||||
|     ] | ||||
| } | ||||
| ` | ||||
| 				_, err := fmt.Fprint(rw, content) | ||||
| 				if err != nil { | ||||
| 					http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 					return | ||||
| 				} | ||||
| 			}, | ||||
| 			expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"}, | ||||
| 			handler:    writeFixtureHandler(http.MethodGet, "domains_GET.json"), | ||||
| 			expected:   expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			desc:       "non existing domain", | ||||
| 			domainName: "domain1.com.", | ||||
| 			handler: func(rw http.ResponseWriter, req *http.Request) { | ||||
| 				if req.Method != http.MethodGet { | ||||
| 					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed) | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				_, err := fmt.Fprint(rw, "{}") | ||||
| 				if err != nil { | ||||
| 					http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 					return | ||||
| 				} | ||||
| 			}, | ||||
| 			expected: expected{error: true}, | ||||
| 			handler:    writeBodyHandler(http.MethodGet, "{}"), | ||||
| 			expected:   expected{error: true}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			desc:       "marshaling error", | ||||
| 			domainName: "domain1.com.", | ||||
| 			handler: func(rw http.ResponseWriter, req *http.Request) { | ||||
| 				if req.Method != http.MethodGet { | ||||
| 					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed) | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				_, err := fmt.Fprint(rw, "[]") | ||||
| 				if err != nil { | ||||
| 					http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 					return | ||||
| 				} | ||||
| 			}, | ||||
| 			expected: expected{error: true}, | ||||
| 			handler:    writeBodyHandler(http.MethodGet, "[]"), | ||||
| 			expected:   expected{error: true}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, test := range testCases { | ||||
| 		t.Run(test.desc, func(t *testing.T) { | ||||
| 			mux, client := setupTest(t) | ||||
| 			client, mux := setupTest(t) | ||||
|  | ||||
| 			mux.Handle("/v1/domains", test.handler) | ||||
|  | ||||
| 			domainID, err := client.GetDomainID(test.domainName) | ||||
| 			domainID, err := client.GetDomainID(context.Background(), test.domainName) | ||||
|  | ||||
| 			if test.expected.error { | ||||
| 				require.Error(t, err) | ||||
| @@ -140,15 +120,15 @@ func TestClient_GetDomainID(t *testing.T) { | ||||
|  | ||||
| func TestClient_CreateRecord(t *testing.T) { | ||||
| 	testCases := []struct { | ||||
| 		desc        string | ||||
| 		handler     http.HandlerFunc | ||||
| 		expectError bool | ||||
| 		desc    string | ||||
| 		handler http.HandlerFunc | ||||
| 		assert  require.ErrorAssertionFunc | ||||
| 	}{ | ||||
| 		{ | ||||
| 			desc: "success", | ||||
| 			handler: func(rw http.ResponseWriter, req *http.Request) { | ||||
| 				if req.Method != http.MethodPost { | ||||
| 					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed) | ||||
| 					http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| @@ -157,31 +137,34 @@ func TestClient_CreateRecord(t *testing.T) { | ||||
| 					http.Error(rw, err.Error(), http.StatusBadRequest) | ||||
| 					return | ||||
| 				} | ||||
| 				defer req.Body.Close() | ||||
| 				defer func() { _ = req.Body.Close() }() | ||||
|  | ||||
| 				if string(raw) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` { | ||||
| 				if string(bytes.TrimSpace(raw)) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` { | ||||
| 					http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				writeFixture(rw, "domains-records_POST.json") | ||||
| 			}, | ||||
| 			assert: require.NoError, | ||||
| 		}, | ||||
| 		{ | ||||
| 			desc: "bad request", | ||||
| 			handler: func(rw http.ResponseWriter, req *http.Request) { | ||||
| 				if req.Method != http.MethodPost { | ||||
| 					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed) | ||||
| 					http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				http.Error(rw, "OOPS", http.StatusBadRequest) | ||||
| 			}, | ||||
| 			expectError: true, | ||||
| 			assert: require.Error, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, test := range testCases { | ||||
| 		t.Run(test.desc, func(t *testing.T) { | ||||
| 			mux, client := setupTest(t) | ||||
| 			client, mux := setupTest(t) | ||||
|  | ||||
| 			mux.Handle("/v1/domains/lego/records", test.handler) | ||||
|  | ||||
| @@ -194,13 +177,36 @@ func TestClient_CreateRecord(t *testing.T) { | ||||
| 				TTL:  300, | ||||
| 			} | ||||
|  | ||||
| 			err := client.CreateRecord(domainID, record) | ||||
|  | ||||
| 			if test.expectError { | ||||
| 				require.Error(t, err) | ||||
| 			} else { | ||||
| 				require.NoError(t, err) | ||||
| 			} | ||||
| 			err := client.CreateRecord(context.Background(), domainID, record) | ||||
| 			test.assert(t, err) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestClient_GetRecordID(t *testing.T) { | ||||
| 	client, mux := setupTest(t) | ||||
|  | ||||
| 	mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", | ||||
| 		writeFixtureHandler(http.MethodGet, "domains-records_GET.json")) | ||||
|  | ||||
| 	recordID, err := client.GetRecordID(context.Background(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	assert.Equal(t, "2e32e609-3a4f-45ba-bdef-e50eacd345ad", recordID) | ||||
| } | ||||
|  | ||||
| func TestClient_DeleteRecord(t *testing.T) { | ||||
| 	client, mux := setupTest(t) | ||||
|  | ||||
| 	mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodDelete { | ||||
| 			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		rw.WriteHeader(http.StatusOK) | ||||
| 	}) | ||||
|  | ||||
| 	err := client.DeleteRecord(context.Background(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad") | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,43 @@ | ||||
| { | ||||
|   "records": [ | ||||
|     { | ||||
|       "id": "2e32e609-3a4f-45ba-bdef-e50eacd345ad", | ||||
|       "name": "www.example.com.", | ||||
|       "type": "A", | ||||
|       "ttl": 3600, | ||||
|       "created_at": "2012-11-02T19:56:26.000000", | ||||
|       "updated_at": "2012-11-04T13:22:36.000000", | ||||
|       "data": "15.185.172.153", | ||||
|       "domain_id": "89acac79-38e7-497d-807c-a011e1310438", | ||||
|       "version": 1, | ||||
|       "gslb_region": "JP", | ||||
|       "gslb_weight": 250, | ||||
|       "gslb_check": 12300 | ||||
|     }, | ||||
|     { | ||||
|       "id": "8e9ecf3e-fb92-4a3a-a8ae-7596f167bea3", | ||||
|       "name": "host1.example.com.", | ||||
|       "type": "A", | ||||
|       "ttl": 3600, | ||||
|       "created_at": "2012-11-04T13:57:50.000000", | ||||
|       "updated_at": null, | ||||
|       "data": "15.185.172.154", | ||||
|       "domain_id": "89acac79-38e7-497d-807c-a011e1310438", | ||||
|       "version": 1, | ||||
|       "gslb_region": "US", | ||||
|       "gslb_weight": 220, | ||||
|       "gslb_check": 12200 | ||||
|     }, | ||||
|     { | ||||
|       "id": "4ad19089-3e62-40f8-9482-17cc8ccb92cb", | ||||
|       "name": "web.example.com.", | ||||
|       "type": "CNAME", | ||||
|       "ttl": 3600, | ||||
|       "created_at": "2012-11-04T13:58:16.393735", | ||||
|       "updated_at": null, | ||||
|       "data": "www.example.com.", | ||||
|       "domain_id": "89acac79-38e7-497d-807c-a011e1310438", | ||||
|       "version": 1 | ||||
|     } | ||||
|   ] | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| { | ||||
|   "id": "2e32e609-3a4f-45ba-bdef-e50eacd345ad", | ||||
|   "name": "www.example.com.", | ||||
|   "type": "A", | ||||
|   "created_at": "2012-11-02T19:56:26.366792", | ||||
|   "updated_at": null, | ||||
|   "domain_id": "89acac79-38e7-497d-807c-a011e1310438", | ||||
|   "ttl": null, | ||||
|   "data": "192.0.2.3", | ||||
|   "gslb_check": 1, | ||||
|   "gslb_region": "JP", | ||||
|   "gslb_weight": 250 | ||||
| } | ||||
							
								
								
									
										26
									
								
								providers/dns/conoha/internal/fixtures/domains_GET.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								providers/dns/conoha/internal/fixtures/domains_GET.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| { | ||||
|   "domains":[ | ||||
|     { | ||||
|       "id": "09494b72-b65b-4297-9efb-187f65a0553e", | ||||
|       "name": "domain1.com.", | ||||
|       "ttl": 3600, | ||||
|       "serial": 1351800668, | ||||
|       "email": "nsadmin@example.org", | ||||
|       "gslb": 0, | ||||
|       "created_at": "2012-11-01T20:11:08.000000", | ||||
|       "updated_at": null, | ||||
|       "description": "memo" | ||||
|     }, | ||||
|     { | ||||
|       "id": "cf661142-e577-40b5-b3eb-75795cdc0cd7", | ||||
|       "name": "domain2.com.", | ||||
|       "ttl": 7200, | ||||
|       "serial": 1351800670, | ||||
|       "email": "nsadmin2@example.org", | ||||
|       "gslb": 1, | ||||
|       "created_at": "2012-11-01T20:11:08.000000", | ||||
|       "updated_at": "2012-12-01T20:11:08.000000", | ||||
|       "description": "memomemo" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										17
									
								
								providers/dns/conoha/internal/fixtures/tokens_POST.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								providers/dns/conoha/internal/fixtures/tokens_POST.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| { | ||||
|   "access": { | ||||
|     "token": { | ||||
|       "issued_at": "2015-05-19T07:08:21.927295", | ||||
|       "expires": "2015-05-20T07:08:21Z", | ||||
|       "id": "sample00d88246078f2bexample788f7", | ||||
|       "tenant": { | ||||
|         "name": "example00000000", | ||||
|         "enabled": true, | ||||
|         "tyo1_image_size": "550GB" | ||||
|       }, | ||||
|       "endpoints_links": [], | ||||
|       "type": "mailhosting", | ||||
|       "name": "Mail Hosting Service" | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										82
									
								
								providers/dns/conoha/internal/identity.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								providers/dns/conoha/internal/identity.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| const identityBaseURL = "https://identity.%s.conoha.io" | ||||
|  | ||||
| type Identifier struct { | ||||
| 	baseURL    *url.URL | ||||
| 	HTTPClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewIdentifier creates a new Identifier. | ||||
| func NewIdentifier(region string) (*Identifier, error) { | ||||
| 	baseURL, err := url.Parse(fmt.Sprintf(identityBaseURL, region)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return &Identifier{ | ||||
| 		baseURL:    baseURL, | ||||
| 		HTTPClient: &http.Client{Timeout: 5 * time.Second}, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // GetToken gets valid token information. | ||||
| // https://www.conoha.jp/docs/identity-post_tokens.php | ||||
| func (c *Identifier) GetToken(ctx context.Context, auth Auth) (*IdentityResponse, error) { | ||||
| 	endpoint := c.baseURL.JoinPath("v2.0", "tokens") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, &IdentityRequest{Auth: auth}) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	identity := &IdentityResponse{} | ||||
|  | ||||
| 	err = c.do(req, identity) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return identity, nil | ||||
| } | ||||
|  | ||||
| func (c *Identifier) do(req *http.Request, result any) error { | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return errutils.NewUnexpectedResponseStatusCodeError(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 | ||||
| } | ||||
							
								
								
									
										41
									
								
								providers/dns/conoha/internal/identity_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								providers/dns/conoha/internal/identity_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"net/url" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestNewClient(t *testing.T) { | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	identifier, err := NewIdentifier("tyo1") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	identifier.HTTPClient = server.Client() | ||||
| 	identifier.baseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 	mux.HandleFunc("/v2.0/tokens", writeFixtureHandler(http.MethodPost, "tokens_POST.json")) | ||||
|  | ||||
| 	auth := Auth{ | ||||
| 		TenantID: "487727e3921d44e3bfe7ebb337bf085e", | ||||
| 		PasswordCredentials: PasswordCredentials{ | ||||
| 			Username: "ConoHa", | ||||
| 			Password: "paSSword123456#$%", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	token, err := identifier.GetToken(context.Background(), auth) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &IdentityResponse{Access: Access{Token: Token{ID: "sample00d88246078f2bexample788f7"}}} | ||||
|  | ||||
| 	assert.Equal(t, expected, token) | ||||
| } | ||||
							
								
								
									
										58
									
								
								providers/dns/conoha/internal/types.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								providers/dns/conoha/internal/types.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| package internal | ||||
|  | ||||
| // IdentityRequest is an authentication request body. | ||||
| type IdentityRequest struct { | ||||
| 	Auth Auth `json:"auth"` | ||||
| } | ||||
|  | ||||
| // Auth is an authentication information. | ||||
| type Auth struct { | ||||
| 	TenantID            string              `json:"tenantId"` | ||||
| 	PasswordCredentials PasswordCredentials `json:"passwordCredentials"` | ||||
| } | ||||
|  | ||||
| // PasswordCredentials is API-user's credentials. | ||||
| type PasswordCredentials struct { | ||||
| 	Username string `json:"username"` | ||||
| 	Password string `json:"password"` | ||||
| } | ||||
|  | ||||
| // IdentityResponse is an authentication response body. | ||||
| type IdentityResponse struct { | ||||
| 	Access Access `json:"access"` | ||||
| } | ||||
|  | ||||
| // Access is an identity information. | ||||
| type Access struct { | ||||
| 	Token Token `json:"token"` | ||||
| } | ||||
|  | ||||
| // Token is an api access token. | ||||
| type Token struct { | ||||
| 	ID string `json:"id"` | ||||
| } | ||||
|  | ||||
| // DomainListResponse is a response of a domain listing request. | ||||
| type DomainListResponse struct { | ||||
| 	Domains []Domain `json:"domains"` | ||||
| } | ||||
|  | ||||
| // Domain is a hosted domain entry. | ||||
| type Domain struct { | ||||
| 	ID   string `json:"id"` | ||||
| 	Name string `json:"name"` | ||||
| } | ||||
|  | ||||
| // RecordListResponse is a response of record listing request. | ||||
| type RecordListResponse struct { | ||||
| 	Records []Record `json:"records"` | ||||
| } | ||||
|  | ||||
| // Record is a record entry. | ||||
| type Record struct { | ||||
| 	ID   string `json:"id,omitempty"` | ||||
| 	Name string `json:"name"` | ||||
| 	Type string `json:"type"` | ||||
| 	Data string `json:"data"` | ||||
| 	TTL  int    `json:"ttl"` | ||||
| } | ||||
| @@ -2,6 +2,7 @@ | ||||
| package constellix | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -101,10 +102,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err) | ||||
| 		return fmt.Errorf("constellix: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	dom, err := d.client.Domains.GetByName(dns01.UnFqdn(authZone)) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	dom, err := d.client.Domains.GetByName(ctx, dns01.UnFqdn(authZone)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("constellix: failed to get domain (%s): %w", authZone, err) | ||||
| 	} | ||||
| @@ -114,7 +117,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 		return fmt.Errorf("constellix: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	records, err := d.client.TxtRecords.Search(dom.ID, internal.Exact, recordName) | ||||
| 	records, err := d.client.TxtRecords.Search(ctx, dom.ID, internal.Exact, recordName) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("constellix: failed to search TXT records: %w", err) | ||||
| 	} | ||||
| @@ -125,10 +128,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	// TXT record entry already existing | ||||
| 	if len(records) == 1 { | ||||
| 		return d.appendRecordValue(dom, records[0].ID, info.Value) | ||||
| 		return d.appendRecordValue(ctx, dom, records[0].ID, info.Value) | ||||
| 	} | ||||
|  | ||||
| 	err = d.createRecord(dom, info.EffectiveFQDN, recordName, info.Value) | ||||
| 	err = d.createRecord(ctx, dom, info.EffectiveFQDN, recordName, info.Value) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("constellix: %w", err) | ||||
| 	} | ||||
| @@ -142,10 +145,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err) | ||||
| 		return fmt.Errorf("constellix: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	dom, err := d.client.Domains.GetByName(dns01.UnFqdn(authZone)) | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	dom, err := d.client.Domains.GetByName(ctx, dns01.UnFqdn(authZone)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("constellix: failed to get domain (%s): %w", authZone, err) | ||||
| 	} | ||||
| @@ -155,7 +160,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 		return fmt.Errorf("constellix: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	records, err := d.client.TxtRecords.Search(dom.ID, internal.Exact, recordName) | ||||
| 	records, err := d.client.TxtRecords.Search(ctx, dom.ID, internal.Exact, recordName) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("constellix: failed to search TXT records: %w", err) | ||||
| 	} | ||||
| @@ -168,7 +173,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	record, err := d.client.TxtRecords.Get(dom.ID, records[0].ID) | ||||
| 	record, err := d.client.TxtRecords.Get(ctx, dom.ID, records[0].ID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("constellix: failed to get TXT records: %w", err) | ||||
| 	} | ||||
| @@ -179,14 +184,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	// only 1 record value, the whole record must be deleted. | ||||
| 	if len(record.Value) == 1 { | ||||
| 		_, err = d.client.TxtRecords.Delete(dom.ID, record.ID) | ||||
| 		_, err = d.client.TxtRecords.Delete(ctx, dom.ID, record.ID) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("constellix: failed to delete TXT records: %w", err) | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	err = d.removeRecordValue(dom, record, info.Value) | ||||
| 	err = d.removeRecordValue(ctx, dom, record, info.Value) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("constellix: %w", err) | ||||
| 	} | ||||
| @@ -194,7 +199,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) createRecord(dom internal.Domain, fqdn, recordName, value string) error { | ||||
| func (d *DNSProvider) createRecord(ctx context.Context, dom internal.Domain, fqdn, recordName, value string) error { | ||||
| 	request := internal.RecordRequest{ | ||||
| 		Name: recordName, | ||||
| 		TTL:  d.config.TTL, | ||||
| @@ -203,7 +208,7 @@ func (d *DNSProvider) createRecord(dom internal.Domain, fqdn, recordName, value | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	_, err := d.client.TxtRecords.Create(dom.ID, request) | ||||
| 	_, err := d.client.TxtRecords.Create(ctx, dom.ID, request) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to create TXT record %s: %w", fqdn, err) | ||||
| 	} | ||||
| @@ -211,8 +216,8 @@ func (d *DNSProvider) createRecord(dom internal.Domain, fqdn, recordName, value | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) appendRecordValue(dom internal.Domain, recordID int64, value string) error { | ||||
| 	record, err := d.client.TxtRecords.Get(dom.ID, recordID) | ||||
| func (d *DNSProvider) appendRecordValue(ctx context.Context, dom internal.Domain, recordID int64, value string) error { | ||||
| 	record, err := d.client.TxtRecords.Get(ctx, dom.ID, recordID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to get TXT records: %w", err) | ||||
| 	} | ||||
| @@ -227,7 +232,7 @@ func (d *DNSProvider) appendRecordValue(dom internal.Domain, recordID int64, val | ||||
| 		RoundRobin: append(record.RoundRobin, internal.RecordValue{Value: fmt.Sprintf(`%q`, value)}), | ||||
| 	} | ||||
|  | ||||
| 	_, err = d.client.TxtRecords.Update(dom.ID, record.ID, request) | ||||
| 	_, err = d.client.TxtRecords.Update(ctx, dom.ID, record.ID, request) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to update TXT records: %w", err) | ||||
| 	} | ||||
| @@ -235,7 +240,7 @@ func (d *DNSProvider) appendRecordValue(dom internal.Domain, recordID int64, val | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) removeRecordValue(dom internal.Domain, record *internal.Record, value string) error { | ||||
| func (d *DNSProvider) removeRecordValue(ctx context.Context, dom internal.Domain, record *internal.Record, value string) error { | ||||
| 	request := internal.RecordRequest{ | ||||
| 		Name: record.Name, | ||||
| 		TTL:  record.TTL, | ||||
| @@ -247,7 +252,7 @@ func (d *DNSProvider) removeRecordValue(dom internal.Domain, record *internal.Re | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	_, err := d.client.TxtRecords.Update(dom.ID, record.ID, request) | ||||
| 	_, err := d.client.TxtRecords.Update(ctx, dom.ID, record.ID, request) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to update TXT records: %w", err) | ||||
| 	} | ||||
|   | ||||
| @@ -6,6 +6,9 @@ import ( | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| @@ -28,7 +31,7 @@ type Client struct { | ||||
| // NewClient Creates a Constellix client. | ||||
| func NewClient(httpClient *http.Client) *Client { | ||||
| 	if httpClient == nil { | ||||
| 		httpClient = http.DefaultClient | ||||
| 		httpClient = &http.Client{Timeout: 5 * time.Second} | ||||
| 	} | ||||
|  | ||||
| 	client := &Client{ | ||||
| @@ -48,13 +51,15 @@ type service struct { | ||||
| } | ||||
|  | ||||
| // do sends an API request and returns the API response. | ||||
| func (c *Client) do(req *http.Request, v interface{}) error { | ||||
| func (c *Client) do(req *http.Request, result any) error { | ||||
| 	req.Header.Set("Accept", "application/json") | ||||
| 	req.Header.Set("Content-Type", "application/json") | ||||
|  | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	err = checkResponse(resp) | ||||
| @@ -64,11 +69,11 @@ func (c *Client) do(req *http.Request, v interface{}) error { | ||||
|  | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to read body: %w", err) | ||||
| 		return errutils.NewReadResponseError(req, resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	if err = json.Unmarshal(raw, v); err != nil { | ||||
| 		return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw)) | ||||
| 	if err = json.Unmarshal(raw, result); err != nil { | ||||
| 		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| @@ -83,21 +88,21 @@ func checkResponse(resp *http.Response) error { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	data, err := io.ReadAll(resp.Body) | ||||
| 	if err == nil && data != nil { | ||||
| 		msg := &APIError{StatusCode: resp.StatusCode} | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err == nil && raw != nil { | ||||
| 		errAPI := &APIError{StatusCode: resp.StatusCode} | ||||
|  | ||||
| 		if json.Unmarshal(data, msg) != nil { | ||||
| 			return fmt.Errorf("API error: status code: %d: %v", resp.StatusCode, string(data)) | ||||
| 		if json.Unmarshal(raw, errAPI) != nil { | ||||
| 			return fmt.Errorf("API error: status code: %d: %v", resp.StatusCode, string(raw)) | ||||
| 		} | ||||
|  | ||||
| 		switch resp.StatusCode { | ||||
| 		case http.StatusNotFound: | ||||
| 			return &NotFound{APIError: msg} | ||||
| 			return &NotFound{APIError: errAPI} | ||||
| 		case http.StatusBadRequest: | ||||
| 			return &BadRequest{APIError: msg} | ||||
| 			return &BadRequest{APIError: errAPI} | ||||
| 		default: | ||||
| 			return msg | ||||
| 			return errAPI | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -13,15 +14,15 @@ type DomainService service | ||||
|  | ||||
| // GetAll domains. | ||||
| // https://api-docs.constellix.com/?version=latest#484c3f21-d724-4ee4-a6fa-ab22c8eb9e9b | ||||
| func (s *DomainService) GetAll(params *PaginationParameters) ([]Domain, error) { | ||||
| func (s *DomainService) GetAll(ctx context.Context, params *PaginationParameters) ([]Domain, error) { | ||||
| 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains") | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request endpoint: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodGet, endpoint, nil) | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if params != nil { | ||||
| @@ -42,8 +43,8 @@ func (s *DomainService) GetAll(params *PaginationParameters) ([]Domain, error) { | ||||
| } | ||||
|  | ||||
| // GetByName Gets domain by name. | ||||
| func (s *DomainService) GetByName(domainName string) (Domain, error) { | ||||
| 	domains, err := s.Search(Exact, domainName) | ||||
| func (s *DomainService) GetByName(ctx context.Context, domainName string) (Domain, error) { | ||||
| 	domains, err := s.Search(ctx, Exact, domainName) | ||||
| 	if err != nil { | ||||
| 		return Domain{}, err | ||||
| 	} | ||||
| @@ -61,15 +62,15 @@ func (s *DomainService) GetByName(domainName string) (Domain, error) { | ||||
|  | ||||
| // Search searches for a domain by name. | ||||
| // https://api-docs.constellix.com/?version=latest#3d7b2679-2209-49f3-b011-b7d24e512008 | ||||
| func (s *DomainService) Search(filter searchFilter, value string) ([]Domain, error) { | ||||
| func (s *DomainService) Search(ctx context.Context, filter searchFilter, value string) ([]Domain, error) { | ||||
| 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", "search") | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request endpoint: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodGet, endpoint, nil) | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	query := req.URL.Query() | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| @@ -47,7 +48,7 @@ func TestDomainService_GetAll(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	data, err := client.Domains.GetAll(nil) | ||||
| 	data, err := client.Domains.GetAll(context.Background(), nil) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := []Domain{ | ||||
| @@ -83,7 +84,7 @@ func TestDomainService_Search(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	data, err := client.Domains.Search(Exact, "lego.wtf") | ||||
| 	data, err := client.Domains.Search(context.Background(), Exact, "lego.wtf") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := []Domain{ | ||||
|   | ||||
| @@ -2,6 +2,7 @@ package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| @@ -14,20 +15,20 @@ type TxtRecordService service | ||||
|  | ||||
| // Create a TXT record. | ||||
| // https://api-docs.constellix.com/?version=latest#22e24d5b-9ec0-49a7-b2b0-5ff0a28e71be | ||||
| func (s *TxtRecordService) Create(domainID int64, record RecordRequest) ([]Record, error) { | ||||
| 	body, err := json.Marshal(record) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to marshall request body: %w", err) | ||||
| 	} | ||||
|  | ||||
| func (s *TxtRecordService) Create(ctx context.Context, domainID int64, record RecordRequest) ([]Record, error) { | ||||
| 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt") | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request endpoint: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) | ||||
| 	body, err := json.Marshal(record) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||
| 		return nil, fmt.Errorf("failed to create request JSON body: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var records []Record | ||||
| @@ -41,15 +42,15 @@ func (s *TxtRecordService) Create(domainID int64, record RecordRequest) ([]Recor | ||||
|  | ||||
| // GetAll TXT records. | ||||
| // https://api-docs.constellix.com/?version=latest#e7103c53-2ad8-4bc8-b5b3-4c22c4b571b2 | ||||
| func (s *TxtRecordService) GetAll(domainID int64) ([]Record, error) { | ||||
| func (s *TxtRecordService) GetAll(ctx context.Context, domainID int64) ([]Record, error) { | ||||
| 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt") | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request endpoint: %w", err) | ||||
| 		return nil, fmt.Errorf("failed to create endpoint: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodGet, endpoint, nil) | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var records []Record | ||||
| @@ -63,15 +64,15 @@ func (s *TxtRecordService) GetAll(domainID int64) ([]Record, error) { | ||||
|  | ||||
| // Get a TXT record. | ||||
| // https://api-docs.constellix.com/?version=latest#e7103c53-2ad8-4bc8-b5b3-4c22c4b571b2 | ||||
| func (s *TxtRecordService) Get(domainID, recordID int64) (*Record, error) { | ||||
| func (s *TxtRecordService) Get(ctx context.Context, domainID, recordID int64) (*Record, error) { | ||||
| 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request endpoint: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodGet, endpoint, nil) | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var records Record | ||||
| @@ -85,20 +86,20 @@ func (s *TxtRecordService) Get(domainID, recordID int64) (*Record, error) { | ||||
|  | ||||
| // Update a TXT record. | ||||
| // https://api-docs.constellix.com/?version=latest#d4e9ab2e-fac0-45a6-b0e4-cf62a2d2e3da | ||||
| func (s *TxtRecordService) Update(domainID, recordID int64, record RecordRequest) (*SuccessMessage, error) { | ||||
| 	body, err := json.Marshal(record) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to marshall request body: %w", err) | ||||
| 	} | ||||
|  | ||||
| func (s *TxtRecordService) Update(ctx context.Context, domainID, recordID int64, record RecordRequest) (*SuccessMessage, error) { | ||||
| 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request endpoint: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body)) | ||||
| 	body, err := json.Marshal(record) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||
| 		return nil, fmt.Errorf("failed to create request JSON body: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var msg SuccessMessage | ||||
| @@ -112,15 +113,15 @@ func (s *TxtRecordService) Update(domainID, recordID int64, record RecordRequest | ||||
|  | ||||
| // Delete a TXT record. | ||||
| // https://api-docs.constellix.com/?version=latest#135947f7-d6c8-481a-83c7-4d387b0bdf9e | ||||
| func (s *TxtRecordService) Delete(domainID, recordID int64) (*SuccessMessage, error) { | ||||
| func (s *TxtRecordService) Delete(ctx context.Context, domainID, recordID int64) (*SuccessMessage, error) { | ||||
| 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request endpoint: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodDelete, endpoint, nil) | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var msg *SuccessMessage | ||||
| @@ -134,15 +135,15 @@ func (s *TxtRecordService) Delete(domainID, recordID int64) (*SuccessMessage, er | ||||
|  | ||||
| // Search searches for a TXT record by name. | ||||
| // https://api-docs.constellix.com/?version=latest#81003e4f-bd3f-413f-a18d-6d9d18f10201 | ||||
| func (s *TxtRecordService) Search(domainID int64, filter searchFilter, value string) ([]Record, error) { | ||||
| func (s *TxtRecordService) Search(ctx context.Context, domainID int64, filter searchFilter, value string) ([]Record, error) { | ||||
| 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", "search") | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request endpoint: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodGet, endpoint, nil) | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||
| 		return nil, fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	query := req.URL.Query() | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| @@ -34,7 +35,7 @@ func TestTxtRecordService_Create(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	records, err := client.TxtRecords.Create(12345, RecordRequest{}) | ||||
| 	records, err := client.TxtRecords.Create(context.Background(), 12345, RecordRequest{}) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	recordsJSON, err := json.Marshal(records) | ||||
| @@ -69,7 +70,7 @@ func TestTxtRecordService_GetAll(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	records, err := client.TxtRecords.GetAll(12345) | ||||
| 	records, err := client.TxtRecords.GetAll(context.Background(), 12345) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	recordsJSON, err := json.Marshal(records) | ||||
| @@ -104,7 +105,7 @@ func TestTxtRecordService_Get(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	record, err := client.TxtRecords.Get(12345, 6789) | ||||
| 	record, err := client.TxtRecords.Get(context.Background(), 12345, 6789) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &Record{ | ||||
| @@ -145,7 +146,7 @@ func TestTxtRecordService_Update(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	msg, err := client.TxtRecords.Update(12345, 6789, RecordRequest{}) | ||||
| 	msg, err := client.TxtRecords.Update(context.Background(), 12345, 6789, RecordRequest{}) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &SuccessMessage{Success: "Record  updated successfully"} | ||||
| @@ -168,7 +169,7 @@ func TestTxtRecordService_Delete(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	msg, err := client.TxtRecords.Delete(12345, 6789) | ||||
| 	msg, err := client.TxtRecords.Delete(context.Background(), 12345, 6789) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &SuccessMessage{Success: "Record  deleted successfully"} | ||||
| @@ -198,7 +199,7 @@ func TestTxtRecordService_Search(t *testing.T) { | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	records, err := client.TxtRecords.Search(12345, Exact, "test") | ||||
| 	records, err := client.TxtRecords.Search(context.Background(), 12345, Exact, "test") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	recordsJSON, err := json.Marshal(records) | ||||
|   | ||||
| @@ -106,7 +106,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("desec: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err) | ||||
| 		return fmt.Errorf("desec: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) | ||||
| @@ -156,7 +156,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("desec: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err) | ||||
| 		return fmt.Errorf("desec: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) | ||||
|   | ||||
| @@ -128,12 +128,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("designate: couldn't get zone ID in Present: %w", err) | ||||
| 		return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	zoneID, err := d.getZoneID(authZone) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("designate: %w", err) | ||||
| 		return fmt.Errorf("designate: couldn't get zone ID in Present: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// use mutex to prevent race condition between creating the record and verifying it | ||||
| @@ -168,7 +168,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	zoneID, err := d.getZoneID(authZone) | ||||
|   | ||||
| @@ -286,6 +286,9 @@ func setupTestProvider(t *testing.T) string { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { | ||||
| 		_, _ = w.Write([]byte(`{ | ||||
| 	"access": { | ||||
| @@ -319,9 +322,6 @@ func setupTestProvider(t *testing.T) string { | ||||
| 		w.WriteHeader(http.StatusOK) | ||||
| 	}) | ||||
|  | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	return server.URL | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,131 +0,0 @@ | ||||
| package digitalocean | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/challenge/dns01" | ||||
| ) | ||||
|  | ||||
| const defaultBaseURL = "https://api.digitalocean.com" | ||||
|  | ||||
| // txtRecordResponse represents a response from DO's API after making a TXT record. | ||||
| type txtRecordResponse struct { | ||||
| 	DomainRecord record `json:"domain_record"` | ||||
| } | ||||
|  | ||||
| type record struct { | ||||
| 	ID   int    `json:"id,omitempty"` | ||||
| 	Type string `json:"type,omitempty"` | ||||
| 	Name string `json:"name,omitempty"` | ||||
| 	Data string `json:"data,omitempty"` | ||||
| 	TTL  int    `json:"ttl,omitempty"` | ||||
| } | ||||
|  | ||||
| type apiError struct { | ||||
| 	ID      string `json:"id"` | ||||
| 	Message string `json:"message"` | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) removeTxtRecord(domain string, recordID int) error { | ||||
| 	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("could not determine zone for domain %q: %w", domain, err) | ||||
| 	} | ||||
|  | ||||
| 	reqURL := fmt.Sprintf("%s/v2/domains/%s/records/%d", d.config.BaseURL, dns01.UnFqdn(authZone), recordID) | ||||
| 	req, err := d.newRequest(http.MethodDelete, reqURL, nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	resp, err := d.config.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	if resp.StatusCode >= http.StatusBadRequest { | ||||
| 		return readError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) addTxtRecord(fqdn, value string) (*txtRecordResponse, error) { | ||||
| 	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(fqdn)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("could not determine zone for domain %q: %w", fqdn, err) | ||||
| 	} | ||||
|  | ||||
| 	reqData := record{Type: "TXT", Name: fqdn, Data: value, TTL: d.config.TTL} | ||||
| 	body, err := json.Marshal(reqData) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	reqURL := fmt.Sprintf("%s/v2/domains/%s/records", d.config.BaseURL, dns01.UnFqdn(authZone)) | ||||
| 	req, err := d.newRequest(http.MethodPost, reqURL, bytes.NewReader(body)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	resp, err := d.config.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	if resp.StatusCode >= http.StatusBadRequest { | ||||
| 		return nil, readError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	content, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.New(toUnreadableBodyMessage(req, content)) | ||||
| 	} | ||||
|  | ||||
| 	// Everything looks good; but we'll need the ID later to delete the record | ||||
| 	respData := &txtRecordResponse{} | ||||
| 	err = json.Unmarshal(content, respData) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("%w: %s", err, toUnreadableBodyMessage(req, content)) | ||||
| 	} | ||||
|  | ||||
| 	return respData, nil | ||||
| } | ||||
|  | ||||
| func (d *DNSProvider) newRequest(method, reqURL string, body io.Reader) (*http.Request, error) { | ||||
| 	req, err := http.NewRequest(method, reqURL, body) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	req.Header.Set("Content-Type", "application/json") | ||||
| 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.config.AuthToken)) | ||||
|  | ||||
| 	return req, nil | ||||
| } | ||||
|  | ||||
| func readError(req *http.Request, resp *http.Response) error { | ||||
| 	content, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return errors.New(toUnreadableBodyMessage(req, content)) | ||||
| 	} | ||||
|  | ||||
| 	var errInfo apiError | ||||
| 	err = json.Unmarshal(content, &errInfo) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("apiError unmarshaling error: %w: %s", err, toUnreadableBodyMessage(req, content)) | ||||
| 	} | ||||
|  | ||||
| 	return fmt.Errorf("HTTP %d: %s: %s", resp.StatusCode, errInfo.ID, errInfo.Message) | ||||
| } | ||||
|  | ||||
| func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { | ||||
| 	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody)) | ||||
| } | ||||
| @@ -2,14 +2,17 @@ | ||||
| package digitalocean | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"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/digitalocean/internal" | ||||
| ) | ||||
|  | ||||
| // Environment variables names. | ||||
| @@ -38,7 +41,7 @@ type Config struct { | ||||
| // NewDefaultConfig returns a default configuration for the DNSProvider. | ||||
| func NewDefaultConfig() *Config { | ||||
| 	return &Config{ | ||||
| 		BaseURL:            env.GetOrDefaultString(EnvAPIUrl, defaultBaseURL), | ||||
| 		BaseURL:            env.GetOrDefaultString(EnvAPIUrl, internal.DefaultBaseURL), | ||||
| 		TTL:                env.GetOrDefaultInt(EnvTTL, 30), | ||||
| 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second), | ||||
| 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), | ||||
| @@ -50,7 +53,9 @@ func NewDefaultConfig() *Config { | ||||
|  | ||||
| // DNSProvider implements the challenge.Provider interface. | ||||
| type DNSProvider struct { | ||||
| 	config      *Config | ||||
| 	config *Config | ||||
| 	client *internal.Client | ||||
|  | ||||
| 	recordIDs   map[string]int | ||||
| 	recordIDsMu sync.Mutex | ||||
| } | ||||
| @@ -80,12 +85,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 		return nil, errors.New("digitalocean: credentials missing") | ||||
| 	} | ||||
|  | ||||
| 	if config.BaseURL == "" { | ||||
| 		config.BaseURL = defaultBaseURL | ||||
| 	client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken)) | ||||
|  | ||||
| 	if config.BaseURL != "" { | ||||
| 		var err error | ||||
| 		client.BaseURL, err = url.Parse(config.BaseURL) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("digitalocean: %w", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return &DNSProvider{ | ||||
| 		config:    config, | ||||
| 		client:    client, | ||||
| 		recordIDs: make(map[string]int), | ||||
| 	}, nil | ||||
| } | ||||
| @@ -100,7 +112,14 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { | ||||
| func (d *DNSProvider) Present(domain, token, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	respData, err := d.addTxtRecord(info.EffectiveFQDN, info.Value) | ||||
| 	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	record := internal.Record{Type: "TXT", Name: info.EffectiveFQDN, Data: info.Value, TTL: d.config.TTL} | ||||
|  | ||||
| 	respData, err := d.client.AddTxtRecord(context.Background(), authZone, record) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("digitalocean: %w", err) | ||||
| 	} | ||||
| @@ -118,7 +137,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("digitalocean: %w", err) | ||||
| 		return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	// get the record's unique ID from when we created it | ||||
| @@ -129,7 +148,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { | ||||
| 		return fmt.Errorf("digitalocean: unknown record ID for '%s'", info.EffectiveFQDN) | ||||
| 	} | ||||
|  | ||||
| 	err = d.removeTxtRecord(authZone, recordID) | ||||
| 	err = d.client.RemoveTxtRecord(context.Background(), authZone, recordID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("digitalocean: %w", err) | ||||
| 	} | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package digitalocean | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| @@ -115,6 +116,7 @@ func TestDNSProvider_Present(t *testing.T) { | ||||
| 	mux.HandleFunc("/v2/domains/example.com/records", func(w http.ResponseWriter, r *http.Request) { | ||||
| 		assert.Equal(t, http.MethodPost, r.Method, "method") | ||||
|  | ||||
| 		assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept") | ||||
| 		assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") | ||||
| 		assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization") | ||||
|  | ||||
| @@ -125,7 +127,7 @@ func TestDNSProvider_Present(t *testing.T) { | ||||
| 		} | ||||
|  | ||||
| 		expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}` | ||||
| 		assert.Equal(t, expectedReqBody, string(reqBody)) | ||||
| 		assert.Equal(t, expectedReqBody, string(bytes.TrimSpace(reqBody))) | ||||
|  | ||||
| 		w.WriteHeader(http.StatusCreated) | ||||
| 		_, err = fmt.Fprintf(w, `{ | ||||
| @@ -157,7 +159,7 @@ func TestDNSProvider_CleanUp(t *testing.T) { | ||||
|  | ||||
| 		assert.Equal(t, "/v2/domains/example.com/records/1234567", r.URL.Path, "Path") | ||||
|  | ||||
| 		// NOTE: Even though the body is empty, DigitalOcean API docs still show setting this Content-Type... | ||||
| 		assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept") | ||||
| 		assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") | ||||
| 		assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization") | ||||
|  | ||||
|   | ||||
							
								
								
									
										142
									
								
								providers/dns/digitalocean/internal/client.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								providers/dns/digitalocean/internal/client.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/challenge/dns01" | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| 	"golang.org/x/oauth2" | ||||
| ) | ||||
|  | ||||
| // DefaultBaseURL default API endpoint. | ||||
| const DefaultBaseURL = "https://api.digitalocean.com" | ||||
|  | ||||
| // Client the Digital Ocean API client. | ||||
| type Client struct { | ||||
| 	BaseURL    *url.URL | ||||
| 	httpClient *http.Client | ||||
| } | ||||
|  | ||||
| // NewClient creates a new Client. | ||||
| func NewClient(hc *http.Client) *Client { | ||||
| 	baseURL, _ := url.Parse(DefaultBaseURL) | ||||
|  | ||||
| 	if hc == nil { | ||||
| 		hc = &http.Client{Timeout: 5 * time.Second} | ||||
| 	} | ||||
|  | ||||
| 	return &Client{BaseURL: baseURL, httpClient: hc} | ||||
| } | ||||
|  | ||||
| func (c *Client) AddTxtRecord(ctx context.Context, zone string, record Record) (*TxtRecordResponse, error) { | ||||
| 	endpoint := c.BaseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records") | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	respData := &TxtRecordResponse{} | ||||
| 	err = c.do(req, respData) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return respData, nil | ||||
| } | ||||
|  | ||||
| func (c *Client) RemoveTxtRecord(ctx context.Context, zone string, recordID int) error { | ||||
| 	endpoint := c.BaseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records", strconv.Itoa(recordID)) | ||||
|  | ||||
| 	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return c.do(req, nil) | ||||
| } | ||||
|  | ||||
| func (c *Client) do(req *http.Request, result any) error { | ||||
| 	resp, err := c.httpClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode >= http.StatusBadRequest { | ||||
| 		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 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") | ||||
|  | ||||
| 	// NOTE: Even though the body is empty, DigitalOcean API docs still show setting this Content-Type... | ||||
| 	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 errInfo APIError | ||||
| 	err := json.Unmarshal(raw, &errInfo) | ||||
| 	if err != nil { | ||||
| 		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) | ||||
| 	} | ||||
|  | ||||
| 	return fmt.Errorf("[status code %d] %w", resp.StatusCode, errInfo) | ||||
| } | ||||
|  | ||||
| func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { | ||||
| 	if client == nil { | ||||
| 		client = &http.Client{Timeout: 5 * time.Second} | ||||
| 	} | ||||
|  | ||||
| 	client.Transport = &oauth2.Transport{ | ||||
| 		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), | ||||
| 		Base:   client.Transport, | ||||
| 	} | ||||
|  | ||||
| 	return client | ||||
| } | ||||
							
								
								
									
										139
									
								
								providers/dns/digitalocean/internal/client_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								providers/dns/digitalocean/internal/client_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) | ||||
| 	client.BaseURL, _ = url.Parse(server.URL) | ||||
|  | ||||
| 	mux.HandleFunc(pattern, handler) | ||||
|  | ||||
| 	return client | ||||
| } | ||||
|  | ||||
| func checkHeader(req *http.Request, name, value string) error { | ||||
| 	val := req.Header.Get(name) | ||||
| 	if val != value { | ||||
| 		return fmt.Errorf("invalid header value, got: %s want %s", val, value) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func writeFixture(rw http.ResponseWriter, filename string) { | ||||
| 	file, err := os.Open(filepath.Join("fixtures", filename)) | ||||
| 	if err != nil { | ||||
| 		http.Error(rw, err.Error(), http.StatusInternalServerError) | ||||
| 		return | ||||
| 	} | ||||
| 	defer func() { _ = file.Close() }() | ||||
|  | ||||
| 	_, _ = io.Copy(rw, file) | ||||
| } | ||||
|  | ||||
| func TestClient_AddTxtRecord(t *testing.T) { | ||||
| 	client := setupTest(t, "/v2/domains/example.com/records", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodPost { | ||||
| 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		err := checkHeader(req, "Accept", "application/json") | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		err = checkHeader(req, "Content-Type", "application/json") | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		err = checkHeader(req, "Authorization", "Bearer secret") | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		reqBody, err := io.ReadAll(req.Body) | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}` | ||||
| 		if expectedReqBody != string(bytes.TrimSpace(reqBody)) { | ||||
| 			http.Error(rw, fmt.Sprintf("unexpected request body: %s", string(bytes.TrimSpace(reqBody))), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		rw.WriteHeader(http.StatusCreated) | ||||
| 		writeFixture(rw, "domains-records_POST.json") | ||||
| 	}) | ||||
|  | ||||
| 	record := Record{ | ||||
| 		Type: "TXT", | ||||
| 		Name: "_acme-challenge.example.com.", | ||||
| 		Data: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", | ||||
| 		TTL:  30, | ||||
| 	} | ||||
|  | ||||
| 	newRecord, err := client.AddTxtRecord(context.Background(), "example.com", record) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expected := &TxtRecordResponse{DomainRecord: Record{ | ||||
| 		ID:   1234567, | ||||
| 		Type: "TXT", | ||||
| 		Name: "_acme-challenge", | ||||
| 		Data: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", | ||||
| 		TTL:  0, | ||||
| 	}} | ||||
|  | ||||
| 	assert.Equal(t, expected, newRecord) | ||||
| } | ||||
|  | ||||
| func TestClient_RemoveTxtRecord(t *testing.T) { | ||||
| 	client := setupTest(t, "/v2/domains/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) { | ||||
| 		if req.Method != http.MethodDelete { | ||||
| 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		err := checkHeader(req, "Accept", "application/json") | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusBadRequest) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		err = checkHeader(req, "Authorization", "Bearer secret") | ||||
| 		if err != nil { | ||||
| 			http.Error(rw, err.Error(), http.StatusUnauthorized) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		rw.WriteHeader(http.StatusNoContent) | ||||
| 	}) | ||||
|  | ||||
| 	err := client.RemoveTxtRecord(context.Background(), "example.com", 1234567) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| { | ||||
|   "domain_record": { | ||||
|     "id": 1234567, | ||||
|     "type": "TXT", | ||||
|     "name": "_acme-challenge", | ||||
|     "data": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", | ||||
|     "priority": null, | ||||
|     "port": null, | ||||
|     "weight": null | ||||
|   } | ||||
| } | ||||
							
								
								
									
										25
									
								
								providers/dns/digitalocean/internal/types.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								providers/dns/digitalocean/internal/types.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| package internal | ||||
|  | ||||
| import "fmt" | ||||
|  | ||||
| // TxtRecordResponse represents a response from DO's API after making a TXT record. | ||||
| type TxtRecordResponse struct { | ||||
| 	DomainRecord Record `json:"domain_record"` | ||||
| } | ||||
|  | ||||
| type Record struct { | ||||
| 	ID   int    `json:"id,omitempty"` | ||||
| 	Type string `json:"type,omitempty"` | ||||
| 	Name string `json:"name,omitempty"` | ||||
| 	Data string `json:"data,omitempty"` | ||||
| 	TTL  int    `json:"ttl,omitempty"` | ||||
| } | ||||
|  | ||||
| type APIError struct { | ||||
| 	ID      string `json:"id"` | ||||
| 	Message string `json:"message"` | ||||
| } | ||||
|  | ||||
| func (a APIError) Error() string { | ||||
| 	return fmt.Sprintf("%s: %s", a.ID, a.Message) | ||||
| } | ||||
| @@ -126,7 +126,7 @@ import ( | ||||
| // NewDNSChallengeProviderByName Factory for DNS providers. | ||||
| func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { | ||||
| 	switch name { | ||||
| 	case "acme-dns": | ||||
| 	case "acme-dns": // TODO(ldez): remove "-" in v5 | ||||
| 		return acmedns.NewDNSProvider() | ||||
| 	case "alidns": | ||||
| 		return alidns.NewDNSProvider() | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| package dnshomede | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| @@ -99,7 +100,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| func (d *DNSProvider) Present(domain, _, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	err := d.client.Add(dns01.UnFqdn(info.EffectiveFQDN), info.Value) | ||||
| 	err := d.client.Add(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("dnshomede: %w", err) | ||||
| 	} | ||||
| @@ -111,7 +112,7 @@ func (d *DNSProvider) Present(domain, _, keyAuth string) error { | ||||
| func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { | ||||
| 	info := dns01.GetChallengeInfo(domain, keyAuth) | ||||
|  | ||||
| 	err := d.client.Remove(dns01.UnFqdn(info.EffectiveFQDN), info.Value) | ||||
| 	err := d.client.Remove(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("dnshomede: %w", err) | ||||
| 	} | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| @@ -9,6 +10,8 @@ import ( | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-acme/lego/v4/providers/dns/internal/errutils" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| @@ -22,8 +25,8 @@ const defaultBaseURL = "https://www.dnshome.de/dyndns.php" | ||||
|  | ||||
| // Client the dnsHome.de client. | ||||
| type Client struct { | ||||
| 	HTTPClient *http.Client | ||||
| 	baseURL    string | ||||
| 	HTTPClient *http.Client | ||||
|  | ||||
| 	credentials map[string]string | ||||
| 	credMu      sync.Mutex | ||||
| @@ -40,75 +43,48 @@ func NewClient(credentials map[string]string) *Client { | ||||
|  | ||||
| // Add adds a TXT record. | ||||
| // only one TXT record for ACME is allowed, so it will update the "current" TXT record. | ||||
| func (c *Client) Add(hostname, value string) error { | ||||
| func (c *Client) Add(ctx context.Context, hostname, value string) error { | ||||
| 	domain := strings.TrimPrefix(hostname, "_acme-challenge.") | ||||
|  | ||||
| 	c.credMu.Lock() | ||||
| 	password, ok := c.credentials[domain] | ||||
| 	c.credMu.Unlock() | ||||
|  | ||||
| 	if !ok { | ||||
| 		return fmt.Errorf("domain %s not found in credentials, check your credentials map", domain) | ||||
| 	} | ||||
|  | ||||
| 	return c.do(url.UserPassword(domain, password), addAction, value) | ||||
| 	return c.doAction(ctx, domain, addAction, value) | ||||
| } | ||||
|  | ||||
| // Remove removes a TXT record. | ||||
| // only one TXT record for ACME is allowed, so it will remove "all" the TXT records. | ||||
| func (c *Client) Remove(hostname, value string) error { | ||||
| func (c *Client) Remove(ctx context.Context, hostname, value string) error { | ||||
| 	domain := strings.TrimPrefix(hostname, "_acme-challenge.") | ||||
|  | ||||
| 	c.credMu.Lock() | ||||
| 	password, ok := c.credentials[domain] | ||||
| 	c.credMu.Unlock() | ||||
|  | ||||
| 	if !ok { | ||||
| 		return fmt.Errorf("domain %s not found in credentials, check your credentials map", domain) | ||||
| 	} | ||||
|  | ||||
| 	return c.do(url.UserPassword(domain, password), removeAction, value) | ||||
| 	return c.doAction(ctx, domain, removeAction, value) | ||||
| } | ||||
|  | ||||
| func (c *Client) do(userInfo *url.Userinfo, action, value string) error { | ||||
| 	if len(value) < 12 { | ||||
| 		return fmt.Errorf("the TXT value must have more than 12 characters: %s", value) | ||||
| 	} | ||||
|  | ||||
| 	apiEndpoint, err := url.Parse(c.baseURL) | ||||
| func (c *Client) doAction(ctx context.Context, domain, action, value string) error { | ||||
| 	endpoint, err := c.createEndpoint(domain, action, value) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	apiEndpoint.User = userInfo | ||||
|  | ||||
| 	query := apiEndpoint.Query() | ||||
| 	query.Set("acme", action) | ||||
| 	query.Set("txt", value) | ||||
| 	apiEndpoint.RawQuery = query.Encode() | ||||
|  | ||||
| 	req, err := http.NewRequest(http.MethodPost, apiEndpoint.String(), http.NoBody) | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return fmt.Errorf("unable to create request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.HTTPClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return errutils.NewHTTPDoError(req, err) | ||||
| 	} | ||||
|  | ||||
| 	defer func() { _ = resp.Body.Close() }() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		all, _ := io.ReadAll(resp.Body) | ||||
| 		return fmt.Errorf("%d: %s", resp.StatusCode, string(all)) | ||||
| 		return errutils.NewUnexpectedResponseStatusCodeError(req, resp) | ||||
| 	} | ||||
|  | ||||
| 	all, err := io.ReadAll(resp.Body) | ||||
| 	raw, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return errutils.NewReadResponseError(req, resp.StatusCode, err) | ||||
| 	} | ||||
|  | ||||
| 	output := string(all) | ||||
| 	output := string(raw) | ||||
|  | ||||
| 	if !strings.HasPrefix(output, successCode) { | ||||
| 		return errors.New(output) | ||||
| @@ -116,3 +92,31 @@ func (c *Client) do(userInfo *url.Userinfo, action, value string) error { | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (c *Client) createEndpoint(domain, action, value string) (*url.URL, error) { | ||||
| 	if len(value) < 12 { | ||||
| 		return nil, fmt.Errorf("the TXT value must have more than 12 characters: %s", value) | ||||
| 	} | ||||
|  | ||||
| 	endpoint, err := url.Parse(c.baseURL) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	c.credMu.Lock() | ||||
| 	password, ok := c.credentials[domain] | ||||
| 	c.credMu.Unlock() | ||||
|  | ||||
| 	if !ok { | ||||
| 		return nil, fmt.Errorf("domain %s not found in credentials, check your credentials map", domain) | ||||
| 	} | ||||
|  | ||||
| 	endpoint.User = url.UserPassword(domain, password) | ||||
|  | ||||
| 	query := endpoint.Query() | ||||
| 	query.Set("acme", action) | ||||
| 	query.Set("txt", value) | ||||
| 	endpoint.RawQuery = query.Encode() | ||||
|  | ||||
| 	return endpoint, nil | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| @@ -9,79 +10,55 @@ import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestClient_Add(t *testing.T) { | ||||
| 	txtValue := "123456789012" | ||||
| func setupTest(t *testing.T, credentials map[string]string, handler http.HandlerFunc) *Client { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	mux.HandleFunc("/", handlerMock(addAction, txtValue)) | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	t.Cleanup(server.Close) | ||||
|  | ||||
| 	credentials := map[string]string{ | ||||
| 		"example.org": "secret", | ||||
| 	} | ||||
| 	mux.HandleFunc("/", handler) | ||||
|  | ||||
| 	client := NewClient(credentials) | ||||
| 	client.HTTPClient = server.Client() | ||||
| 	client.baseURL = server.URL | ||||
|  | ||||
| 	err := client.Add("example.org", txtValue) | ||||
| 	return client | ||||
| } | ||||
|  | ||||
| func TestClient_Add(t *testing.T) { | ||||
| 	txtValue := "123456789012" | ||||
|  | ||||
| 	client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(addAction, txtValue)) | ||||
|  | ||||
| 	err := client.Add(context.Background(), "example.org", txtValue) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
|  | ||||
| func TestClient_Add_error(t *testing.T) { | ||||
| 	txtValue := "123456789012" | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	mux.HandleFunc("/", handlerMock(addAction, txtValue)) | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(addAction, txtValue)) | ||||
|  | ||||
| 	credentials := map[string]string{ | ||||
| 		"example.com": "secret", | ||||
| 	} | ||||
|  | ||||
| 	client := NewClient(credentials) | ||||
| 	client.HTTPClient = server.Client() | ||||
| 	client.baseURL = server.URL | ||||
|  | ||||
| 	err := client.Add("example.org", txtValue) | ||||
| 	err := client.Add(context.Background(), "example.org", txtValue) | ||||
| 	require.Error(t, err) | ||||
| } | ||||
|  | ||||
| func TestClient_Remove(t *testing.T) { | ||||
| 	txtValue := "ABCDEFGHIJKL" | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	mux.HandleFunc("/", handlerMock(removeAction, txtValue)) | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(removeAction, txtValue)) | ||||
|  | ||||
| 	credentials := map[string]string{ | ||||
| 		"example.org": "secret", | ||||
| 	} | ||||
|  | ||||
| 	client := NewClient(credentials) | ||||
| 	client.HTTPClient = server.Client() | ||||
| 	client.baseURL = server.URL | ||||
|  | ||||
| 	err := client.Remove("example.org", txtValue) | ||||
| 	err := client.Remove(context.Background(), "example.org", txtValue) | ||||
| 	require.NoError(t, err) | ||||
| } | ||||
|  | ||||
| func TestClient_Remove_error(t *testing.T) { | ||||
| 	txtValue := "ABCDEFGHIJKL" | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
| 	mux.HandleFunc("/", handlerMock(removeAction, txtValue)) | ||||
| 	server := httptest.NewServer(mux) | ||||
| 	client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(removeAction, txtValue)) | ||||
|  | ||||
| 	credentials := map[string]string{ | ||||
| 		"example.com": "secret", | ||||
| 	} | ||||
|  | ||||
| 	client := NewClient(credentials) | ||||
| 	client.HTTPClient = server.Client() | ||||
| 	client.baseURL = server.URL | ||||
|  | ||||
| 	err := client.Remove("example.org", txtValue) | ||||
| 	err := client.Remove(context.Background(), "example.org", txtValue) | ||||
| 	require.Error(t, err) | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -149,7 +149,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { | ||||
| func (d *DNSProvider) getHostedZone(domain string) (string, error) { | ||||
| 	authZone, err := dns01.FindZoneByFqdn(domain) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 		return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) | ||||
| 	} | ||||
|  | ||||
| 	accountID, err := d.getAccountID() | ||||
|   | ||||
| @@ -2,10 +2,12 @@ | ||||
| package dnsmadeeasy | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto/tls" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| @@ -86,12 +88,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
|  | ||||
| 	var baseURL string | ||||
| 	if config.Sandbox { | ||||
| 		baseURL = "https://api.sandbox.dnsmadeeasy.com/V2.0" | ||||
| 		baseURL = internal.DefaultSandboxBaseURL | ||||
| 	} else { | ||||
| 		if len(config.BaseURL) > 0 { | ||||
| 			baseURL = config.BaseURL | ||||
| 		if config.BaseURL == "" { | ||||
| 			baseURL = internal.DefaultProdBaseURL | ||||
| 		} else { | ||||
| 			baseURL = "https://api.dnsmadeeasy.com/V2.0" | ||||
| 			baseURL = config.BaseURL | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -101,7 +103,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { | ||||
| 	} | ||||
|  | ||||
| 	client.HTTPClient = config.HTTPClient | ||||
| 	client.BaseURL = baseURL | ||||
| 	client.BaseURL, err = url.Parse(baseURL) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return &DNSProvider{ | ||||
| 		client: client, | ||||
| @@ -115,11 +120,13 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("dnsmadeeasy: unable to find zone for %s: %w", info.EffectiveFQDN, err) | ||||
| 		return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q (%s): %w", domainName, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	// fetch the domain details | ||||
| 	domain, err := d.client.GetDomain(authZone) | ||||
| 	domain, err := d.client.GetDomain(ctx, authZone) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("dnsmadeeasy: unable to get domain for zone %s: %w", authZone, err) | ||||
| 	} | ||||
| @@ -128,7 +135,7 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error { | ||||
| 	name := strings.Replace(info.EffectiveFQDN, "."+authZone, "", 1) | ||||
| 	record := &internal.Record{Type: "TXT", Name: name, Value: info.Value, TTL: d.config.TTL} | ||||
|  | ||||
| 	err = d.client.CreateRecord(domain, record) | ||||
| 	err = d.client.CreateRecord(ctx, domain, record) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("dnsmadeeasy: unable to create record for %s: %w", name, err) | ||||
| 	} | ||||
| @@ -141,18 +148,20 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { | ||||
|  | ||||
| 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("dnsmadeeasy: unable to find zone for %s: %w", info.EffectiveFQDN, err) | ||||
| 		return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q (%s): %w", domainName, info.EffectiveFQDN, err) | ||||
| 	} | ||||
|  | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	// fetch the domain details | ||||
| 	domain, err := d.client.GetDomain(authZone) | ||||
| 	domain, err := d.client.GetDomain(ctx, authZone) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("dnsmadeeasy: unable to get domain for zone %s: %w", authZone, err) | ||||
| 	} | ||||
|  | ||||
| 	// find matching records | ||||
| 	name := strings.Replace(info.EffectiveFQDN, "."+authZone, "", 1) | ||||
| 	records, err := d.client.GetRecords(domain, name, "TXT") | ||||
| 	records, err := d.client.GetRecords(ctx, domain, name, "TXT") | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("dnsmadeeasy: unable to get records for domain %s: %w", domain.Name, err) | ||||
| 	} | ||||
| @@ -160,7 +169,7 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { | ||||
| 	// delete records | ||||
| 	var lastError error | ||||
| 	for _, record := range *records { | ||||
| 		err = d.client.DeleteRecord(record) | ||||
| 		err = d.client.DeleteRecord(ctx, record) | ||||
| 		if err != nil { | ||||
| 			lastError = fmt.Errorf("dnsmadeeasy: unable to delete record [id=%d, name=%s]: %w", record.ID, record.Name, err) | ||||
| 		} | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user