forked from TrueCloudLab/lego
chore: refactor clients (#1868)
This commit is contained in:
parent
0c1303c1bd
commit
aeec5be129
431 changed files with 16635 additions and 10176 deletions
|
@ -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
Loading…
Reference in a new issue