diff --git a/README.md b/README.md index 53e9e529..cebd033c 100644 --- a/README.md +++ b/README.md @@ -152,85 +152,90 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). LuaDNS Mail-in-a-Box + ManageEngine CloudDNS Manual Metaname mijn.host - Mittwald + Mittwald MyDNS.jp MythicBeasts Name.com - Namecheap + Namecheap Namesilo NearlyFreeSpeech.NET Netcup - Netlify + Netlify Nicmanager NIFCloud Njalla - Nodion + Nodion NS1 Open Telekom Cloud Oracle Cloud - OVH + OVH plesk.com Porkbun PowerDNS - Rackspace + Rackspace Rain Yun/雨云 RcodeZero reg.ru - Regfish + Regfish RFC2136 RimuHosting Sakura Cloud - Scaleway + Scaleway Selectel Selectel v2 SelfHost.(de|eu) - Servercow + Servercow Shellrent Simply.com Sonic - Stackpath + Stackpath Technitium Tencent Cloud DNS Timeweb Cloud - TransIP + TransIP UKFast SafeDNS Ultradns Variomedia - VegaDNS + VegaDNS Vercel Versio.[nl|eu|uk] VinylDNS - VK Cloud + VK Cloud Volcano Engine/火山引擎 Vscale Vultr - Webnames + Webnames Websupport WEDOS West.cn/西部数码 - Yandex 360 + Yandex 360 Yandex Cloud Yandex PDD Zone.ee + Zonomi + + + diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index b7f6e6c8..e5ae3b46 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -92,6 +92,7 @@ func allDNSCodes() string { "loopia", "luadns", "mailinabox", + "manageengine", "metaname", "mijnhost", "mittwald", @@ -1858,6 +1859,27 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mailinabox`) + case "manageengine": + // generated from: providers/dns/manageengine/manageengine.toml + ew.writeln(`Configuration for ManageEngine CloudDNS.`) + ew.writeln(`Code: 'manageengine'`) + ew.writeln(`Since: 'v4.21.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "MANAGEENGINE_CLIENT_ID": Client ID`) + ew.writeln(` - "MANAGEENGINE_CLIENT_SECRET": Client Secret`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "MANAGEENGINE_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "MANAGEENGINE_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "MANAGEENGINE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "MANAGEENGINE_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/manageengine`) + case "metaname": // generated from: providers/dns/metaname/metaname.toml ew.writeln(`Configuration for Metaname.`) diff --git a/docs/content/dns/zz_gen_manageengine.md b/docs/content/dns/zz_gen_manageengine.md new file mode 100644 index 00000000..32266f2d --- /dev/null +++ b/docs/content/dns/zz_gen_manageengine.md @@ -0,0 +1,69 @@ +--- +title: "ManageEngine CloudDNS" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: manageengine +dnsprovider: + since: "v4.21.0" + code: "manageengine" + url: "https://clouddns.manageengine.com" +--- + + + + + + +Configuration for [ManageEngine CloudDNS](https://clouddns.manageengine.com). + + + + +- Code: `manageengine` +- Since: v4.21.0 + + +Here is an example bash command using the ManageEngine CloudDNS provider: + +```bash +MANAGEENGINE_CLIENT_ID="xxx" \ +MANAGEENGINE_CLIENT_SECRET="yyy" \ +lego --email you@example.com --dns manageengine -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `MANAGEENGINE_CLIENT_ID` | Client ID | +| `MANAGEENGINE_CLIENT_SECRET` | Client Secret | + +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 | +|--------------------------------|-------------| +| `MANAGEENGINE_HTTP_TIMEOUT` | API request timeout | +| `MANAGEENGINE_POLLING_INTERVAL` | Time between DNS propagation check | +| `MANAGEENGINE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `MANAGEENGINE_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://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation) + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index 638a596a..84615c54 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -143,7 +143,7 @@ To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: - acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manual, metaname, mijnhost, mittwald, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneee, zonomi + acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, mijnhost, mittwald, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ diff --git a/providers/dns/manageengine/internal/client.go b/providers/dns/manageengine/internal/client.go new file mode 100644 index 00000000..89c426b0 --- /dev/null +++ b/providers/dns/manageengine/internal/client.go @@ -0,0 +1,197 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://clouddns.manageengine.com/v1" + +// Client the ManageEngine CloudDNS API client. +type Client struct { + baseURL *url.URL + httpClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(ctx context.Context, clientID, clientSecret string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + baseURL: baseURL, + httpClient: createOAuthClient(ctx, clientID, clientSecret), + } +} + +// GetAllZones gets all zones. +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#GET_All +func (c *Client) GetAllZones(ctx context.Context) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("dns", "domain") + + req, err := newRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var results []Zone + + err = c.do(req, &results) + if err != nil { + return nil, err + } + + return results, nil +} + +// GetAllZoneRecords gets all "zone records" for a zone. +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#GET_All_9 +func (c *Client) GetAllZoneRecords(ctx context.Context, zoneID int) ([]ZoneRecord, error) { + endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT") + + req, err := newRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var results []ZoneRecord + + err = c.do(req, &results) + if err != nil { + return nil, err + } + + return results, nil +} + +// DeleteZoneRecord deletes a "zone record". +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#DEL_Delete_10 +func (c *Client) DeleteZoneRecord(ctx context.Context, zoneID int, domainID int) error { + endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT", strconv.Itoa(domainID)) + + req, err := newRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + var results APIResponse + + return c.do(req, &results) +} + +// CreateZoneRecord creates a "zone record". +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#POST_Create_10 +func (c *Client) CreateZoneRecord(ctx context.Context, zoneID int, record ZoneRecord) error { + endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT", "/") + + req, err := newRequest(ctx, http.MethodPost, endpoint, []ZoneRecord{record}) + if err != nil { + return err + } + + var results APIResponse + + return c.do(req, &results) +} + +// UpdateZoneRecord update an existing "zone record". +// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#PUT_Update_10 +func (c *Client) UpdateZoneRecord(ctx context.Context, record ZoneRecord) error { + if record.SpfTxtDomainID == 0 { + return errors.New("SpfTxtDomainID is empty") + } + if record.ZoneID == 0 { + return errors.New("ZoneID is empty") + } + + endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(record.ZoneID), "records", "SPF_TXT", strconv.Itoa(record.SpfTxtDomainID), "/") + + req, err := newRequest(ctx, http.MethodPut, endpoint, []ZoneRecord{record}) + if err != nil { + return err + } + + var results APIResponse + + return c.do(req, &results) +} + +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 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 newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + var body io.Reader = http.NoBody + + if payload != nil { + buf := new(bytes.Buffer) + + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + + values := url.Values{} + values.Set("config", buf.String()) + body = strings.NewReader(values.Encode()) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + 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/x-www-form-urlencoded") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + err := json.Unmarshal(raw, &errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code: %d] %w", resp.StatusCode, &errAPI) +} diff --git a/providers/dns/manageengine/internal/client_test.go b/providers/dns/manageengine/internal/client_test.go new file mode 100644 index 00000000..edf04622 --- /dev/null +++ b/providers/dns/manageengine/internal/client_test.go @@ -0,0 +1,262 @@ +package internal + +import ( + "context" + "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, status int, filename 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 filename == "" { + rw.WriteHeader(status) + 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(status) + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client := NewClient(context.Background(), "abc", "secret") + + client.httpClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client +} + +func TestClient_GetAllZones(t *testing.T) { + client := setupTest(t, "GET /dns/domain", http.StatusOK, "zone_domains_all.json") + + groups, err := client.GetAllZones(context.Background()) + require.NoError(t, err) + + expected := []Zone{ + { + ZoneID: 1, + ZoneName: "test.com.", + ZoneTTL: 500, + ZoneTargeting: true, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + NsID: 1, + Serial: 2022042206, + Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, + }, + { + ZoneID: 2, + ZoneName: "yourdomain.com.", + ZoneTTL: 1000, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + Vanity: true, + NsID: 1, + Serial: 2022040608, + Nss: []string{"ns11.yourdomain.com.", "ns21.yourdomain.net.", "ns31.yourdomain.com.", "ns41.yourdomain.net."}, + }, + { + ZoneID: 20, + ZoneName: "hello45.com.", + ZoneTTL: 3000, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + NsID: 1, + Serial: 2022040711, + Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, + }, + { + ZoneID: 22, + ZoneName: "zohoaccl.com.", + ZoneTTL: 300, + ZoneTargeting: true, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + NsID: 1, + Serial: 2022042206, + Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, + }, + { + ZoneID: 23, + ZoneName: "zohocal.com.", + ZoneTTL: 300, + ZoneTargeting: true, + Refresh: 43200, + Retry: 3600, + Expiry: 1209600, + Minimum: 180, + Org: 2, + NsID: 1, + Serial: 2022041310, + Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, + }, + } + + assert.Equal(t, expected, groups) +} + +func TestClient_GetAllZones_error(t *testing.T) { + client := setupTest(t, "GET /dns/domain", http.StatusUnauthorized, "error.json") + + _, err := client.GetAllZones(context.Background()) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} + +func TestClient_GetAllZoneRecords(t *testing.T) { + client := setupTest(t, "GET /dns/domain/4/records/SPF_TXT", http.StatusOK, "zone_records_all.json") + + groups, err := client.GetAllZoneRecords(context.Background(), 4) + require.NoError(t, err) + + expected := []ZoneRecord{ + { + ZoneID: 4, + SpfTxtDomainID: 6, + DomainName: "spftest.example.com.", + DomainTTL: 300, + DomainLocationID: 1, + RecordType: "SPF", + Records: []Record{{ + ID: 1, + Values: []string{"necwcltpwxbz-noelget3jush-vop2xxvapot3eyq_0"}, + DomainID: 6, + }}, + }, + { + ZoneID: 4, + SpfTxtDomainID: 13, + DomainName: "txt.example.com.", + DomainTTL: 300, + DomainLocationID: 1, + RecordType: "TXT", + Records: []Record{{ + ID: 1, + Values: []string{"v=spf1include:transmail.netinclude:example.com~all", "c-68e3oc4trm8w7piplscg7vgojmtkjrnrabr4king8"}, + DomainID: 13, + }}, + }, + } + + assert.Equal(t, expected, groups) +} + +func TestClient_GetAllZoneRecords_error(t *testing.T) { + client := setupTest(t, "GET /dns/domain/4/records/SPF_TXT", http.StatusUnauthorized, "error.json") + + _, err := client.GetAllZoneRecords(context.Background(), 4) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} + +func TestClient_DeleteZoneRecord(t *testing.T) { + client := setupTest(t, "DELETE /dns/domain/4/records/SPF_TXT/6", http.StatusOK, "zone_record_delete.json") + + err := client.DeleteZoneRecord(context.Background(), 4, 6) + require.NoError(t, err) +} + +func TestClient_DeleteZoneRecord_error(t *testing.T) { + client := setupTest(t, "DELETE /dns/domain/4/records/SPF_TXT/6", http.StatusUnauthorized, "error.json") + + err := client.DeleteZoneRecord(context.Background(), 4, 6) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} + +func TestClient_CreateZoneRecord(t *testing.T) { + client := setupTest(t, "POST /dns/domain/4/records/SPF_TXT/", http.StatusOK, "zone_record_create.json") + + record := ZoneRecord{} + + err := client.CreateZoneRecord(context.Background(), 4, record) + require.NoError(t, err) +} + +func TestClient_CreateZoneRecord_error(t *testing.T) { + client := setupTest(t, "POST /dns/domain/4/records/SPF_TXT/", http.StatusUnauthorized, "error.json") + + record := ZoneRecord{} + + err := client.CreateZoneRecord(context.Background(), 4, record) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} + +func TestClient_CreateZoneRecord_error_bad_request(t *testing.T) { + client := setupTest(t, "POST /dns/domain/4/records/SPF_TXT/", http.StatusBadRequest, "error_bad_request.json") + + record := ZoneRecord{} + + err := client.CreateZoneRecord(context.Background(), 4, record) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 400] Invalid record format, Record should be in list.") +} + +func TestClient_UpdateZoneRecord(t *testing.T) { + client := setupTest(t, "PUT /dns/domain/4/records/SPF_TXT/6/", http.StatusOK, "zone_record_update.json") + + record := ZoneRecord{ + SpfTxtDomainID: 6, + ZoneID: 4, + } + + err := client.UpdateZoneRecord(context.Background(), record) + require.NoError(t, err) +} + +func TestClient_UpdateZoneRecord_error(t *testing.T) { + client := setupTest(t, "PUT /dns/domain/4/records/SPF_TXT/6/", http.StatusUnauthorized, "error.json") + + record := ZoneRecord{ + SpfTxtDomainID: 6, + ZoneID: 4, + } + + err := client.UpdateZoneRecord(context.Background(), record) + require.Error(t, err) + + require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") +} diff --git a/providers/dns/manageengine/internal/fixtures/error.json b/providers/dns/manageengine/internal/fixtures/error.json new file mode 100644 index 00000000..5cd19867 --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/error.json @@ -0,0 +1,3 @@ +{ + "detail": "Authentication credentials were not provided." +} diff --git a/providers/dns/manageengine/internal/fixtures/error_bad_request.json b/providers/dns/manageengine/internal/fixtures/error_bad_request.json new file mode 100644 index 00000000..944cef6c --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/error_bad_request.json @@ -0,0 +1,3 @@ +{ + "error": "Invalid record format, Record should be in list." +} diff --git a/providers/dns/manageengine/internal/fixtures/zone_domains_all.json b/providers/dns/manageengine/internal/fixtures/zone_domains_all.json new file mode 100644 index 00000000..3e37f52a --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_domains_all.json @@ -0,0 +1,146 @@ +[ + { + "zone_id": 1, + "zone_name": "test.com.", + "zone_ttl": 500, + "zone_type": 0, + "zone_targeting": true, + "zone_logging": "{}", + "zone_contact": "mathes.zoho.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": true, + "vanity": false, + "ns_id": 1, + "serial": 2022042206, + "ns": [ + "ns11.zns-53.com.", + "ns21.zns-53.net.", + "ns31.zns-53.com.", + "ns41.zns-53.net." + ], + "contact_group": [ + "test_contact1", + "test_contact2" + ], + "ds": [ + { + "record_id": 59, + "keyTag": 36938, + "algorithm": 13, + "digestType": 1, + "digest": "e9f03d176455d5d16f826b69f9ecb11f59be35e7", + "domain_id": 30 + }, + { + "record_id": 60, + "keyTag": 36938, + "algorithm": 13, + "digestType": 2, + "digest": "7ea640a8668eafd9d89a9b2e9994f5fcfb1dee0668d1e93ba556aa57ac047f96", + "domain_id": 30 + } + ] + }, + { + "zone_id": 2, + "zone_name": "yourdomain.com.", + "zone_ttl": 1000, + "zone_type": 0, + "zone_targeting": false, + "zone_logging": "{}", + "zone_contact": "contact.yourdomain.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": false, + "vanity": true, + "vanity_grp": "yourdomain", + "ns_id": 1, + "serial": 2022040608, + "ns": [ + "ns11.yourdomain.com.", + "ns21.yourdomain.net.", + "ns31.yourdomain.com.", + "ns41.yourdomain.net." + ] + }, + { + "zone_id": 20, + "zone_name": "hello45.com.", + "zone_ttl": 3000, + "zone_targeting": false, + "zone_logging": "{}", + "zone_contact": "mathes.zoho.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": false, + "ns_id": 1, + "serial": 2022040711, + "ns": [ + "ns11.zns-53.com.", + "ns21.zns-53.net.", + "ns31.zns-53.com.", + "ns41.zns-53.net." + ] + }, + { + "zone_id": 22, + "zone_name": "zohoaccl.com.", + "zone_ttl": 300, + "zone_type": 0, + "zone_targeting": true, + "zone_logging": "{}", + "zone_contact": "networkone.zohocorp.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": false, + "ns_id": 1, + "serial": 2022042206, + "ns": [ + "ns11.zns-53.com.", + "ns21.zns-53.net.", + "ns31.zns-53.com.", + "ns41.zns-53.net." + ] + }, + { + "zone_id": 23, + "zone_name": "zohocal.com.", + "zone_ttl": 300, + "zone_type": 0, + "zone_targeting": true, + "zone_logging": "{}", + "zone_contact": "mathes.zoho.com", + "refresh": 43200, + "retry": 3600, + "expiry": 1209600, + "minimum": 180, + "org": 2, + "any_query": false, + "dnssec": false, + "ns_id": 1, + "serial": 2022041310, + "ns": [ + "ns11.zns-53.com.", + "ns21.zns-53.net.", + "ns31.zns-53.com.", + "ns41.zns-53.net." + ] + } +] diff --git a/providers/dns/manageengine/internal/fixtures/zone_record_create.json b/providers/dns/manageengine/internal/fixtures/zone_record_create.json new file mode 100644 index 00000000..3fd216f2 --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_record_create.json @@ -0,0 +1,3 @@ +{ + "message": "Record created successfully" +} diff --git a/providers/dns/manageengine/internal/fixtures/zone_record_delete.json b/providers/dns/manageengine/internal/fixtures/zone_record_delete.json new file mode 100644 index 00000000..c657d84e --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_record_delete.json @@ -0,0 +1,3 @@ +{ + "message": "Record deleted successfully" +} diff --git a/providers/dns/manageengine/internal/fixtures/zone_record_update.json b/providers/dns/manageengine/internal/fixtures/zone_record_update.json new file mode 100644 index 00000000..178c1fb0 --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_record_update.json @@ -0,0 +1,3 @@ +{ + "message": "Record updated successfully" +} diff --git a/providers/dns/manageengine/internal/fixtures/zone_records_all.json b/providers/dns/manageengine/internal/fixtures/zone_records_all.json new file mode 100644 index 00000000..ae08a4c7 --- /dev/null +++ b/providers/dns/manageengine/internal/fixtures/zone_records_all.json @@ -0,0 +1,40 @@ +[ + { + "spf_txt_domain_id": 6, + "zone_id": 4, + "domain_name": "spftest.example.com.", + "domain_ttl": 300, + "domain_location_id": 1, + "record_type": "SPF", + "records": [ + { + "record_id": 1, + "value": [ + "necwcltpwxbz-noelget3jush-vop2xxvapot3eyq_0" + ], + "disabled": false, + "domain_id": 6 + } + ] + }, + { + "spf_txt_domain_id": 13, + "zone_id": 4, + "domain_name": "txt.example.com.", + "domain_ttl": 300, + "domain_maxhost": 1, + "domain_location_id": 1, + "record_type": "TXT", + "records": [ + { + "record_id": 1, + "value": [ + "v=spf1include:transmail.netinclude:example.com~all", + "c-68e3oc4trm8w7piplscg7vgojmtkjrnrabr4king8" + ], + "disabled": false, + "domain_id": 13 + } + ] + } +] diff --git a/providers/dns/manageengine/internal/identity.go b/providers/dns/manageengine/internal/identity.go new file mode 100644 index 00000000..66a65918 --- /dev/null +++ b/providers/dns/manageengine/internal/identity.go @@ -0,0 +1,20 @@ +package internal + +import ( + "context" + "net/http" + + "golang.org/x/oauth2/clientcredentials" +) + +const defaultAuthURL = "https://clouddns.manageengine.com/oauth2/token/" + +func createOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client { + config := &clientcredentials.Config{ + TokenURL: defaultAuthURL, + ClientID: clientID, + ClientSecret: clientSecret, + } + + return config.Client(ctx) +} diff --git a/providers/dns/manageengine/internal/types.go b/providers/dns/manageengine/internal/types.go new file mode 100644 index 00000000..7a039f67 --- /dev/null +++ b/providers/dns/manageengine/internal/types.go @@ -0,0 +1,63 @@ +package internal + +import ( + "strings" +) + +type APIError struct { + Message string `json:"error"` + Detail string `json:"detail"` +} + +func (a *APIError) Error() string { + var msg []string + + if a.Message != "" { + msg = append(msg, a.Message) + } + + if a.Detail != "" { + msg = append(msg, a.Detail) + } + + return strings.Join(msg, " ") +} + +type APIResponse struct { + Message string `json:"message,omitempty"` +} + +type ZoneRecord struct { + ZoneID int `json:"zone_id,omitempty"` + SpfTxtDomainID int `json:"spf_txt_domain_id,omitempty"` + DomainName string `json:"domain_name,omitempty"` + DomainTTL int `json:"domain_ttl,omitempty"` + DomainLocationID int `json:"domain_location_id,omitempty"` + RecordType string `json:"record_type,omitempty"` + Records []Record `json:"records"` +} + +type Record struct { + ID int `json:"record_id,omitempty"` + Values []string `json:"value,omitempty"` + Disabled bool `json:"disabled,omitempty"` + DomainID int `json:"domain_id,omitempty"` +} + +type Zone struct { + ZoneID int `json:"zone_id"` + ZoneName string `json:"zone_name"` + ZoneTTL int `json:"zone_ttl"` + ZoneType int `json:"zone_type,omitempty"` + ZoneTargeting bool `json:"zone_targeting"` + Refresh int `json:"refresh"` + Retry int `json:"retry"` + Expiry int `json:"expiry"` + Minimum int `json:"minimum"` + Org int `json:"org"` + AnyQuery bool `json:"any_query"` + Vanity bool `json:"vanity,omitempty"` + NsID int `json:"ns_id"` + Serial int `json:"serial"` + Nss []string `json:"ns"` +} diff --git a/providers/dns/manageengine/manageengine.go b/providers/dns/manageengine/manageengine.go new file mode 100644 index 00000000..f26ae33b --- /dev/null +++ b/providers/dns/manageengine/manageengine.go @@ -0,0 +1,262 @@ +// Package manageengine implements a DNS provider for solving the DNS-01 challenge using ManageEngine CloudDNS. +package manageengine + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "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/manageengine/internal" +) + +// Environment variables names. +const ( + envNamespace = "MANAGEENGINE_" + + EnvClientID = envNamespace + "CLIENT_ID" + EnvClientSecret = envNamespace + "CLIENT_SECRET" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + ClientID string + ClientSecret string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for ManageEngine CloudDNS. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvClientID, EnvClientSecret) + if err != nil { + return nil, fmt.Errorf("manageengine: %w", err) + } + + config := NewDefaultConfig() + config.ClientID = values[EnvClientID] + config.ClientSecret = values[EnvClientSecret] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for ManageEngine CloudDNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("manageengine: the configuration of the DNS provider is nil") + } + + if config.ClientID == "" || config.ClientSecret == "" { + return nil, errors.New("manageengine: credentials missing") + } + + client := internal.NewClient(context.Background(), config.ClientID, config.ClientSecret) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("manageengine: could not find zone for domain %q: %w", domain, err) + } + + zoneID, err := d.findZoneID(ctx, authZone) + if err != nil { + return fmt.Errorf("manageengine: find zone ID: %w", err) + } + + zoneRecord, err := d.findZoneRecord(ctx, zoneID, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("manageengine: find zone record: %w", err) + } + + // Update the existing zone record. + if zoneRecord != nil { + for _, record := range zoneRecord.Records { + if slices.Contains(record.Values, info.Value) { + continue + } + + zr := internal.ZoneRecord{ + ZoneID: zoneID, + SpfTxtDomainID: zoneRecord.SpfTxtDomainID, + DomainName: info.EffectiveFQDN, + DomainTTL: d.config.TTL, + RecordType: "TXT", + Records: []internal.Record{{ + Values: append(record.Values, info.Value), + DomainID: zoneRecord.SpfTxtDomainID, + }}, + } + + // Update the zone record. + err = d.client.UpdateZoneRecord(ctx, zr) + if err != nil { + return fmt.Errorf("manageengine: update zone record: %w", err) + } + + return nil + } + + return errors.New("manageengine: zone already contains the TXT record value") + } + + // Create a new zone record. + record := internal.ZoneRecord{ + ZoneID: zoneID, + DomainName: info.EffectiveFQDN, + DomainTTL: d.config.TTL, + RecordType: "TXT", + Records: []internal.Record{{ + Values: []string{info.Value}, + }}, + } + + err = d.client.CreateZoneRecord(ctx, zoneID, record) + if err != nil { + return fmt.Errorf("manageengine: create zone record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("manageengine: could not find zone for domain %q: %w", domain, err) + } + + zoneID, err := d.findZoneID(ctx, authZone) + if err != nil { + return fmt.Errorf("manageengine: find zone ID: %w", err) + } + + zoneRecord, err := d.findZoneRecord(ctx, zoneID, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("manageengine: find zone record: %w", err) + } + + for _, record := range zoneRecord.Records { + if !slices.Contains(record.Values, info.Value) { + continue + } + + // Delete the zone record. + if len(record.Values) <= 1 { + err = d.client.DeleteZoneRecord(ctx, zoneID, zoneRecord.SpfTxtDomainID) + if err != nil { + return fmt.Errorf("manageengine: delete zone record: %w", err) + } + + return nil + } + + // Update the zone record. + var values []string + for _, value := range record.Values { + if value != info.Value { + values = append(values, value) + } + } + + zr := internal.ZoneRecord{ + ZoneID: zoneID, + SpfTxtDomainID: zoneRecord.SpfTxtDomainID, + DomainName: info.EffectiveFQDN, + DomainTTL: d.config.TTL, + RecordType: "TXT", + Records: []internal.Record{{ + Values: values, + DomainID: zoneRecord.SpfTxtDomainID, + }}, + } + + err = d.client.UpdateZoneRecord(ctx, zr) + if err != nil { + return fmt.Errorf("manageengine: create zone record: %w", err) + } + + return nil + } + + 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 +} + +func (d *DNSProvider) findZoneID(ctx context.Context, authZone string) (int, error) { + zones, err := d.client.GetAllZones(ctx) + if err != nil { + return 0, fmt.Errorf("get all zone groups: %w", err) + } + + for _, zone := range zones { + if strings.EqualFold(zone.ZoneName, authZone) { + return zone.ZoneID, nil + } + } + + return 0, fmt.Errorf("zone not found %s", authZone) +} + +func (d *DNSProvider) findZoneRecord(ctx context.Context, zoneID int, fqdn string) (*internal.ZoneRecord, error) { + zoneRecords, err := d.client.GetAllZoneRecords(ctx, zoneID) + if err != nil { + return nil, fmt.Errorf("get all zone records: %w", err) + } + + for _, zoneRecord := range zoneRecords { + if !strings.EqualFold(zoneRecord.DomainName, fqdn) { + continue + } + + if strings.EqualFold(zoneRecord.RecordType, "TXT") { + return &zoneRecord, nil + } + } + + return nil, nil +} diff --git a/providers/dns/manageengine/manageengine.toml b/providers/dns/manageengine/manageengine.toml new file mode 100644 index 00000000..dea92b3e --- /dev/null +++ b/providers/dns/manageengine/manageengine.toml @@ -0,0 +1,24 @@ +Name = "ManageEngine CloudDNS" +Description = '''''' +URL = "https://clouddns.manageengine.com" +Code = "manageengine" +Since = "v4.21.0" + +Example = ''' +MANAGEENGINE_CLIENT_ID="xxx" \ +MANAGEENGINE_CLIENT_SECRET="yyy" \ +lego --email you@example.com --dns manageengine -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + MANAGEENGINE_CLIENT_ID = "Client ID" + MANAGEENGINE_CLIENT_SECRET = "Client Secret" + [Configuration.Additional] + MANAGEENGINE_POLLING_INTERVAL = "Time between DNS propagation check" + MANAGEENGINE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + MANAGEENGINE_TTL = "The TTL of the TXT record used for the DNS challenge" + MANAGEENGINE_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation" diff --git a/providers/dns/manageengine/manageengine_test.go b/providers/dns/manageengine/manageengine_test.go new file mode 100644 index 00000000..624459be --- /dev/null +++ b/providers/dns/manageengine/manageengine_test.go @@ -0,0 +1,143 @@ +package manageengine + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvClientID, EnvClientSecret).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvClientID: "abc", + EnvClientSecret: "secret", + }, + }, + { + desc: "missing client ID", + envVars: map[string]string{ + EnvClientID: "", + EnvClientSecret: "secret", + }, + expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_ID", + }, + { + desc: "missing client secret", + envVars: map[string]string{ + EnvClientID: "abc", + EnvClientSecret: "", + }, + expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_SECRET", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_ID,MANAGEENGINE_CLIENT_SECRET", + }, + } + + 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 + clientID string + clientSecret string + expected string + }{ + { + desc: "success", + clientID: "abc", + clientSecret: "secret", + }, + { + desc: "missing client ID", + clientSecret: "secret", + expected: "manageengine: credentials missing", + }, + { + desc: "missing client secret", + clientID: "abc", + expected: "manageengine: credentials missing", + }, + { + desc: "missing credentials", + expected: "manageengine: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.ClientID = test.clientID + config.ClientSecret = test.clientSecret + + 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) +} diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go index a60b48b7..053c3c4e 100644 --- a/providers/dns/zz_gen_dns_providers.go +++ b/providers/dns/zz_gen_dns_providers.go @@ -86,6 +86,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/loopia" "github.com/go-acme/lego/v4/providers/dns/luadns" "github.com/go-acme/lego/v4/providers/dns/mailinabox" + "github.com/go-acme/lego/v4/providers/dns/manageengine" "github.com/go-acme/lego/v4/providers/dns/metaname" "github.com/go-acme/lego/v4/providers/dns/mijnhost" "github.com/go-acme/lego/v4/providers/dns/mittwald" @@ -315,6 +316,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return luadns.NewDNSProvider() case "mailinabox": return mailinabox.NewDNSProvider() + case "manageengine": + return manageengine.NewDNSProvider() case "metaname": return metaname.NewDNSProvider() case "mijnhost":