diff --git a/README.md b/README.md index 6cd9d978..0f4696ba 100644 --- a/README.md +++ b/README.md @@ -63,19 +63,20 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Hosttech](https://go-acme.github.io/lego/dns/hosttech/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [Hurricane Electric DNS](https://go-acme.github.io/lego/dns/hurricane/) | [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | | [IBM Cloud (SoftLayer)](https://go-acme.github.io/lego/dns/ibmcloud/) | [IIJ DNS Platform Service](https://go-acme.github.io/lego/dns/iijdpf/) | [Infoblox](https://go-acme.github.io/lego/dns/infoblox/) | [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [Internet.bs](https://go-acme.github.io/lego/dns/internetbs/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Ionos](https://go-acme.github.io/lego/dns/ionos/) | -| [iwantmyname](https://go-acme.github.io/lego/dns/iwantmyname/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | -| [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | -| [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | -| [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [NearlyFreeSpeech.NET](https://go-acme.github.io/lego/dns/nearlyfreespeech/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | -| [Nicmanager](https://go-acme.github.io/lego/dns/nicmanager/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [Njalla](https://go-acme.github.io/lego/dns/njalla/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | -| [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | -| [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | -| [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | -| [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | -| [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) | -| [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | -| [VK Cloud](https://go-acme.github.io/lego/dns/vkcloud/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | -| [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/) | [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | +| [iwantmyname](https://go-acme.github.io/lego/dns/iwantmyname/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Liara](https://go-acme.github.io/lego/dns/liara/) | +| [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | +| [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | +| [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [NearlyFreeSpeech.NET](https://go-acme.github.io/lego/dns/nearlyfreespeech/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | +| [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [Nicmanager](https://go-acme.github.io/lego/dns/nicmanager/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [Njalla](https://go-acme.github.io/lego/dns/njalla/) | +| [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | +| [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | +| [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | +| [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | +| [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | +| [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | +| [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [VK Cloud](https://go-acme.github.io/lego/dns/vkcloud/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | +| [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/) | [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | +| [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index f9c67f1d..ff0d6e3c 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -71,6 +71,7 @@ func allDNSCodes() string { "ionos", "iwantmyname", "joker", + "liara", "lightsail", "linode", "liquidweb", @@ -1336,6 +1337,26 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/joker`) + case "liara": + // generated from: providers/dns/liara/liara.toml + ew.writeln(`Configuration for Liara.`) + ew.writeln(`Code: 'liara'`) + ew.writeln(`Since: 'v4.10.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "LIARA_API_KEY": The API key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/liara`) + case "lightsail": // generated from: providers/dns/lightsail/lightsail.toml ew.writeln(`Configuration for Amazon Lightsail.`) diff --git a/docs/content/dns/zz_gen_liara.md b/docs/content/dns/zz_gen_liara.md new file mode 100644 index 00000000..ebe5c299 --- /dev/null +++ b/docs/content/dns/zz_gen_liara.md @@ -0,0 +1,67 @@ +--- +title: "Liara" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: liara +dnsprovider: + since: "v4.10.0" + code: "liara" + url: "https://liara.ir" +--- + + + + + + +Configuration for [Liara](https://liara.ir). + + + + +- Code: `liara` +- Since: v4.10.0 + + +Here is an example bash command using the Liara provider: + +```bash +LIARA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --email myemail@example.com --dns liara --domains my.example.org run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `LIARA_API_KEY` | The API key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{< ref "dns#configuration-and-credentials" >}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `LIARA_HTTP_TIMEOUT` | API request timeout | +| `LIARA_POLLING_INTERVAL` | Time between DNS propagation check | +| `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{< ref "dns#configuration-and-credentials" >}}). + + + + +## More information + +- [API documentation](https://dns-service.iran.liara.ir/swagger) + + + + diff --git a/go.mod b/go.mod index 72b748f7..55e2f964 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/google/go-querystring v1.1.0 github.com/gophercloud/gophercloud v1.0.0 github.com/gophercloud/utils v0.0.0-20210216074907-f6de111f2eae + github.com/hashicorp/go-retryablehttp v0.7.1 github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df github.com/infobloxopen/infoblox-go-client v1.1.1 github.com/labbsr0x/bindman-dns-webhook v1.0.2 @@ -95,7 +96,6 @@ require ( github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 178e3220..342770e0 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -62,6 +62,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/ionos" "github.com/go-acme/lego/v4/providers/dns/iwantmyname" "github.com/go-acme/lego/v4/providers/dns/joker" + "github.com/go-acme/lego/v4/providers/dns/liara" "github.com/go-acme/lego/v4/providers/dns/lightsail" "github.com/go-acme/lego/v4/providers/dns/linode" "github.com/go-acme/lego/v4/providers/dns/liquidweb" @@ -231,6 +232,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return iwantmyname.NewDNSProvider() case "joker": return joker.NewDNSProvider() + case "liara": + return liara.NewDNSProvider() case "lightsail": return lightsail.NewDNSProvider() case "linode", "linodev4": // "linodev4" is for compatibility with v3, must be dropped in v5 diff --git a/providers/dns/liara/internal/client.go b/providers/dns/liara/internal/client.go new file mode 100644 index 00000000..763ca3d3 --- /dev/null +++ b/providers/dns/liara/internal/client.go @@ -0,0 +1,184 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "time" +) + +const defaultBaseURL = "https://dns-service.iran.liara.ir" + +// Client a Liara DNS API client. +type Client struct { + apiKey string + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + baseURL: baseURL, + } +} + +// GetRecords gets the records of a domain. +// https://dns-service.iran.liara.ir/swagger +func (c Client) GetRecords(domainName string) ([]Record, error) { + endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "api", "v1", "zones", domainName, "dns-records")) + if err != nil { + return nil, fmt.Errorf("parse URL: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, readError(resp) + } + + var response RecordsResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + + return response.Data, nil +} + +// CreateRecord creates a record. +func (c Client) CreateRecord(domainName string, record Record) (*Record, error) { + endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "api", "v1", "zones", domainName, "dns-records")) + if err != nil { + return nil, fmt.Errorf("parse URL: %w", err) + } + + body, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("marshal request data: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + return nil, readError(resp) + } + + var response RecordResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + + return &response.Data, nil +} + +// GetRecord gets a specific record. +func (c Client) GetRecord(domainName, recordID string) (*Record, error) { + endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "api", "v1", "zones", domainName, "dns-records", recordID)) + if err != nil { + return nil, fmt.Errorf("parse URL: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, readError(resp) + } + + var response RecordResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + + return &response.Data, nil +} + +// DeleteRecord deletes a record. +func (c Client) DeleteRecord(domainName, recordID string) error { + endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "api", "v1", "zones", domainName, "dns-records", recordID)) + if err != nil { + return fmt.Errorf("parse URL: %w", err) + } + + req, err := http.NewRequest(http.MethodDelete, endpoint.String(), nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { + return readError(resp) + } + + return nil +} + +func readError(resp *http.Response) error { + all, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("API error (status code: %d)", resp.StatusCode) + } + + var apiError APIError + err = json.Unmarshal(all, &apiError) + if err != nil { + return fmt.Errorf("API error (status code: %d): %s", resp.StatusCode, string(all)) + } + + return fmt.Errorf("API error (status code: %d): %w", resp.StatusCode, &apiError) +} diff --git a/providers/dns/liara/internal/client_test.go b/providers/dns/liara/internal/client_test.go new file mode 100644 index 00000000..f083af4e --- /dev/null +++ b/providers/dns/liara/internal/client_test.go @@ -0,0 +1,174 @@ +package internal + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const apiKey = "key" + +func TestClient_GetRecords(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordsResponse.json", http.MethodGet, http.StatusOK)) + + records, err := client.GetRecords("example.com") + require.NoError(t, err) + + expected := []Record{ + { + ID: "string", + Type: "string", + Name: "string", + Contents: []Content{ + { + Text: "string", + }, + }, + TTL: 3600, + }, + } + assert.Equal(t, expected, records) +} + +func TestClient_GetRecord(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./RecordResponse.json", http.MethodGet, http.StatusOK)) + + record, err := client.GetRecord("example.com", "123") + require.NoError(t, err) + + expected := &Record{ + ID: "string", + Type: "string", + Name: "string", + Contents: []Content{ + { + Text: "string", + }, + }, + TTL: 3600, + } + assert.Equal(t, expected, record) +} + +func TestClient_CreateRecord(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordResponse.json", http.MethodPost, http.StatusCreated)) + + data := Record{ + Type: "string", + Name: "string", + Contents: []Content{ + { + Text: "string", + }, + }, + TTL: 3600, + } + + record, err := client.CreateRecord("example.com", data) + require.NoError(t, err) + + expected := &Record{ + ID: "string", + Type: "string", + Name: "string", + Contents: []Content{ + { + Text: "string", + }, + }, + TTL: 3600, + } + + assert.Equal(t, expected, record) +} + +func TestClient_DeleteRecord(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusNoContent) + }) + + err := client.DeleteRecord("example.com", "123") + require.NoError(t, err) +} + +func TestClient_DeleteRecord_NotFound_Response(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusNotFound) + }) + + err := client.DeleteRecord("example.com", "123") + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./error.json", http.MethodDelete, http.StatusUnauthorized)) + + err := client.DeleteRecord("example.com", "123") + require.Error(t, err) +} + +func testHandler(filename string, method string, statusCode int) 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.StatusMethodNotAllowed) + return + } + + auth := req.Header.Get("Authorization") + if auth != "Bearer "+apiKey { + http.Error(rw, "invalid Authorization header", http.StatusUnauthorized) + return + } + + file, err := os.Open(filepath.Join("fixtures", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = file.Close() }() + + rw.WriteHeader(statusCode) + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func setup(t *testing.T) (*Client, *http.ServeMux) { + t.Helper() + + mux := http.NewServeMux() + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client := NewClient(apiKey) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, mux +} diff --git a/providers/dns/liara/internal/fixtures/RecordResponse.json b/providers/dns/liara/internal/fixtures/RecordResponse.json new file mode 100644 index 00000000..3bbd1dfa --- /dev/null +++ b/providers/dns/liara/internal/fixtures/RecordResponse.json @@ -0,0 +1,10 @@ +{ + "status": "string", + "data": { + "id": "string", + "name": "string", + "type": "string", + "ttl": 3600, + "contents": [{ "text": "string" }] + } +} diff --git a/providers/dns/liara/internal/fixtures/RecordsResponse.json b/providers/dns/liara/internal/fixtures/RecordsResponse.json new file mode 100644 index 00000000..a7383f92 --- /dev/null +++ b/providers/dns/liara/internal/fixtures/RecordsResponse.json @@ -0,0 +1,12 @@ +{ + "status": "string", + "data": [ + { + "id": "string", + "name": "string", + "type": "string", + "ttl": 3600, + "contents": [{ "text": "string" }] + } + ] +} diff --git a/providers/dns/liara/internal/fixtures/error.json b/providers/dns/liara/internal/fixtures/error.json new file mode 100644 index 00000000..6db3b837 --- /dev/null +++ b/providers/dns/liara/internal/fixtures/error.json @@ -0,0 +1,5 @@ +{ + "statusCode": 401, + "error": "Unauthorized", + "message": "Invalid token missing header" +} diff --git a/providers/dns/liara/internal/types.go b/providers/dns/liara/internal/types.go new file mode 100644 index 00000000..0b817c24 --- /dev/null +++ b/providers/dns/liara/internal/types.go @@ -0,0 +1,35 @@ +package internal + +import "fmt" + +type Content struct { + Text string `json:"text,omitempty"` +} + +type Record struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl"` + Contents []Content `json:"contents"` +} + +type RecordResponse struct { + Status string `json:"status"` + Data Record `json:"data"` +} + +type RecordsResponse struct { + Status string `json:"status"` + Data []Record `json:"data"` +} + +type APIError struct { + StatusCode int `json:"statusCode"` + ErrorCode string `json:"error"` + ErrorMessage string `json:"message"` +} + +func (a APIError) Error() string { + return fmt.Sprintf("%s: %s", a.ErrorCode, a.ErrorMessage) +} diff --git a/providers/dns/liara/liara.go b/providers/dns/liara/liara.go new file mode 100644 index 00000000..2c9db3c3 --- /dev/null +++ b/providers/dns/liara/liara.go @@ -0,0 +1,177 @@ +// Package liara implements a DNS provider for solving the DNS-01 challenge using Liara DNS. +package liara + +import ( + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/liara/internal" + "github.com/hashicorp/go-retryablehttp" +) + +const ( + minTTL = 120 + maxTTL = 432000 +) + +// Environment variables names. +const ( + envNamespace = "LIARA_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 3600), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]string + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Liara DNS. +// Liara_API_KEY must be passed in the environment variables. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("liara: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Liara DNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("liara: the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("liara: APIKey is missing") + } + + if config.TTL < minTTL { + return nil, fmt.Errorf("liara: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) + } + + if config.TTL > maxTTL { + return nil, fmt.Errorf("liara: invalid TTL, TTL (%d) must be lower than %d", config.TTL, maxTTL) + } + + client := internal.NewClient(config.APIKey) + + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 5 + if config.HTTPClient != nil { + retryClient.HTTPClient = config.HTTPClient + } + retryClient.Logger = log.Logger + + client.HTTPClient = retryClient.StandardClient() + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]string), + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("liara: %w", err) + } + + record := internal.Record{ + Type: "TXT", + Name: dns01.UnFqdn(strings.TrimSuffix(fqdn, authZone)), + Contents: []internal.Content{{Text: value}}, + TTL: d.config.TTL, + } + newRecord, err := d.client.CreateRecord(dns01.UnFqdn(authZone), record) + if err != nil { + return fmt.Errorf("liara: failed to create TXT record, fqdn=%s: %w", fqdn, err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = newRecord.ID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _ := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("liara: %w", err) + } + + // gets the record's unique ID + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + if !ok { + return fmt.Errorf("liara: unknown record ID for '%s' '%s'", fqdn, token) + } + + err = d.client.DeleteRecord(dns01.UnFqdn(authZone), recordID) + if err != nil { + return fmt.Errorf("liara: failed to delete TXT record, id=%s: %w", recordID, err) + } + + // deletes record ID from map + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} diff --git a/providers/dns/liara/liara.toml b/providers/dns/liara/liara.toml new file mode 100644 index 00000000..323229c5 --- /dev/null +++ b/providers/dns/liara/liara.toml @@ -0,0 +1,22 @@ +Name = "Liara" +Description = '''''' +URL = "https://liara.ir" +Code = "liara" +Since = "v4.10.0" + +Example = ''' +LIARA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --email myemail@example.com --dns liara --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + LIARA_API_KEY = "The API key" + [Configuration.Additional] + LIARA_POLLING_INTERVAL = "Time between DNS propagation check" + LIARA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + LIARA_TTL = "The TTL of the TXT record used for the DNS challenge" + LIARA_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://dns-service.iran.liara.ir/swagger" diff --git a/providers/dns/liara/liara_test.go b/providers/dns/liara/liara_test.go new file mode 100644 index 00000000..4256be55 --- /dev/null +++ b/providers/dns/liara/liara_test.go @@ -0,0 +1,134 @@ +package liara + +import ( + "fmt" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const ( + envDomain = envNamespace + "DOMAIN" + lowerThanMinTTL = 100 + greaterThanMaxTTL = 440000 +) + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "key", + }, + }, + { + desc: "missing API key", + envVars: map[string]string{}, + expected: "liara: some credentials information are missing: LIARA_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + ttl int + expected string + }{ + { + desc: "success", + apiKey: "key", + ttl: minTTL, + }, + { + desc: "missing API key", + ttl: maxTTL, + expected: "liara: APIKey is missing", + }, + { + desc: "invalid TTL", + ttl: lowerThanMinTTL, + apiKey: "key", + expected: fmt.Sprintf("liara: invalid TTL, TTL (%d) must be greater than %d", lowerThanMinTTL, minTTL), + }, + { + desc: "invalid TTL", + ttl: greaterThanMaxTTL, + apiKey: "key", + expected: fmt.Sprintf("liara: invalid TTL, TTL (%d) must be lower than %d", greaterThanMaxTTL, maxTTL), + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.TTL = test.ttl + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +}