From 2e095b95a57621177a10ee1be2650406d8707524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grigas=20=C5=A0ukys?= <135010329+grigassukys@users.noreply.github.com> Date: Sun, 15 Feb 2026 04:22:35 +0200 Subject: [PATCH] Add DNS provider for FusionLayer NameSurfer (#2852) Co-authored-by: Fernandez Ludovic --- README.md | 62 ++--- cmd/zz_gen_cmd_dnshelp.go | 25 ++ docs/content/dns/zz_gen_namesurfer.md | 73 ++++++ docs/data/zz_cli_help.toml | 2 +- providers/dns/namesurfer/internal/client.go | 226 ++++++++++++++++++ .../dns/namesurfer/internal/client_test.go | 158 ++++++++++++ .../fixtures/addDNSRecord-request.json | 16 ++ .../internal/fixtures/addDNSRecord.json | 4 + .../namesurfer/internal/fixtures/error.json | 24 ++ .../internal/fixtures/listZones-request.json | 9 + .../internal/fixtures/listZones.json | 17 ++ .../fixtures/searchDNSHosts-request.json | 9 + .../internal/fixtures/searchDNSHosts.json | 23 ++ .../fixtures/updateDNSHost-request.json | 17 ++ .../internal/fixtures/updateDNSHost.json | 4 + providers/dns/namesurfer/internal/types.go | 72 ++++++ providers/dns/namesurfer/namesurfer.go | 214 +++++++++++++++++ providers/dns/namesurfer/namesurfer.toml | 28 +++ providers/dns/namesurfer/namesurfer_test.go | 174 ++++++++++++++ providers/dns/zz_gen_dns_providers.go | 3 + 20 files changed, 1128 insertions(+), 32 deletions(-) create mode 100644 docs/content/dns/zz_gen_namesurfer.md create mode 100644 providers/dns/namesurfer/internal/client.go create mode 100644 providers/dns/namesurfer/internal/client_test.go create mode 100644 providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json create mode 100644 providers/dns/namesurfer/internal/fixtures/addDNSRecord.json create mode 100644 providers/dns/namesurfer/internal/fixtures/error.json create mode 100644 providers/dns/namesurfer/internal/fixtures/listZones-request.json create mode 100644 providers/dns/namesurfer/internal/fixtures/listZones.json create mode 100644 providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json create mode 100644 providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json create mode 100644 providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json create mode 100644 providers/dns/namesurfer/internal/fixtures/updateDNSHost.json create mode 100644 providers/dns/namesurfer/internal/types.go create mode 100644 providers/dns/namesurfer/namesurfer.go create mode 100644 providers/dns/namesurfer/namesurfer.toml create mode 100644 providers/dns/namesurfer/namesurfer_test.go diff --git a/README.md b/README.md index 07e4f7dd6..105ea53aa 100644 --- a/README.md +++ b/README.md @@ -141,160 +141,160 @@ If your DNS provider is not supported, please open an [issue](https://github.com F5 XC freemyip.com + FusionLayer NameSurfer G-Core Gandi Gandi Live DNS (v5) - Gigahost.no + Gigahost.no Glesys Go Daddy Google Cloud - Google Domains + Google Domains Gravity Hetzner Hosting.de - Hosting.nl + Hosting.nl Hostinger Hosttech HTTP request - http.net + http.net Huawei Cloud Hurricane Electric DNS HyperOne - IBM Cloud (SoftLayer) + IBM Cloud (SoftLayer) IIJ DNS Platform Service Infoblox Infomaniak - Internet Initiative Japan + Internet Initiative Japan Internet.bs INWX Ionos - Ionos Cloud + Ionos Cloud IPv64 ISPConfig 3 ISPConfig 3 - Dynamic DNS (DDNS) Module - iwantmyname (Deprecated) + iwantmyname (Deprecated) JD Cloud Joker Joohoi's ACME-DNS - KeyHelp + KeyHelp Liara Lima-City Linode (v4) - Liquid Web + Liquid Web Loopia LuaDNS Mail-in-a-Box - ManageEngine CloudDNS + ManageEngine CloudDNS Manual Metaname Metaregistrar - mijn.host + mijn.host Mittwald myaddr.{tools,dev,io} MyDNS.jp - MythicBeasts + MythicBeasts Name.com Namecheap Namesilo - NearlyFreeSpeech.NET + NearlyFreeSpeech.NET Neodigit Netcup Netlify - Nicmanager + Nicmanager NIFCloud Njalla Nodion - NS1 + NS1 Octenium Open Telekom Cloud Oracle Cloud - OVH + OVH plesk.com Porkbun PowerDNS - Rackspace + Rackspace Rain Yun/雨云 RcodeZero reg.ru - Regfish + Regfish RFC2136 RimuHosting RU CENTER - Sakura Cloud + Sakura Cloud Scaleway Selectel Selectel v2 - SelfHost.(de|eu) + SelfHost.(de|eu) Servercow Shellrent Simply.com - Sonic + Sonic Spaceship Stackpath Syse - Technitium + Technitium Tencent Cloud DNS Tencent EdgeOne Timeweb Cloud - TodayNIC/时代互联 + TodayNIC/时代互联 TransIP UKFast SafeDNS Ultradns - United-Domains + United-Domains Variomedia VegaDNS Vercel - Versio.[nl|eu|uk] + Versio.[nl|eu|uk] VinylDNS Virtualname VK Cloud - Volcano Engine/火山引擎 + Volcano Engine/火山引擎 Vscale Vultr webnames.ca - webnames.ru + webnames.ru Websupport WEDOS West.cn/西部数码 - Yandex 360 + Yandex 360 Yandex Cloud Yandex PDD Zone.ee - ZoneEdit + ZoneEdit Zonomi - diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 357834a3c..cdee65371 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -131,6 +131,7 @@ func allDNSCodes() string { "namecheap", "namedotcom", "namesilo", + "namesurfer", "nearlyfreespeech", "neodigit", "netcup", @@ -2742,6 +2743,30 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesilo`) + case "namesurfer": + // generated from: providers/dns/namesurfer/namesurfer.toml + ew.writeln(`Configuration for FusionLayer NameSurfer.`) + ew.writeln(`Code: 'namesurfer'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "NAMESURFER_API_KEY": API key name`) + ew.writeln(` - "NAMESURFER_API_SECRET": API secret`) + ew.writeln(` - "NAMESURFER_BASE_URL": The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "NAMESURFER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "NAMESURFER_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`) + ew.writeln(` - "NAMESURFER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NAMESURFER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "NAMESURFER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) + ew.writeln(` - "NAMESURFER_VIEW": DNS view name (optional, default: empty string)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesurfer`) + case "nearlyfreespeech": // generated from: providers/dns/nearlyfreespeech/nearlyfreespeech.toml ew.writeln(`Configuration for NearlyFreeSpeech.NET.`) diff --git a/docs/content/dns/zz_gen_namesurfer.md b/docs/content/dns/zz_gen_namesurfer.md new file mode 100644 index 000000000..9a2802d0e --- /dev/null +++ b/docs/content/dns/zz_gen_namesurfer.md @@ -0,0 +1,73 @@ +--- +title: "FusionLayer NameSurfer" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: namesurfer +dnsprovider: + since: "v4.32.0" + code: "namesurfer" + url: "https://www.fusionlayer.com/" +--- + + + + + + +Configuration for [FusionLayer NameSurfer](https://www.fusionlayer.com/). + + + + +- Code: `namesurfer` +- Since: v4.32.0 + + +Here is an example bash command using the FusionLayer NameSurfer provider: + +```bash +NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \ +NAMESURFER_API_KEY=xxx \ +NAMESURFER_API_SECRET=yyy \ +lego --dns namesurfer -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `NAMESURFER_API_KEY` | API key name | +| `NAMESURFER_API_SECRET` | API secret | +| `NAMESURFER_BASE_URL` | The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10) | + +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 | +|--------------------------------|-------------| +| `NAMESURFER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `NAMESURFER_INSECURE_SKIP_VERIFY` | Whether to verify the API certificate | +| `NAMESURFER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NAMESURFER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `NAMESURFER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | +| `NAMESURFER_VIEW` | DNS view name (optional, default: empty string) | + +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://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10) + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index e31633567..759b8e84f 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: - acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi + acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ diff --git a/providers/dns/namesurfer/internal/client.go b/providers/dns/namesurfer/internal/client.go new file mode 100644 index 000000000..e40a7988c --- /dev/null +++ b/providers/dns/namesurfer/internal/client.go @@ -0,0 +1,226 @@ +package internal + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +type Client struct { + apiKey string + apiSecret string + + BaseURL *url.URL + HTTPClient *http.Client +} + +func NewClient(baseURL, apiKey, apiSecret string) (*Client, error) { + if apiKey == "" || apiSecret == "" { + return nil, errors.New("credentials missing") + } + + if baseURL == "" { + return nil, errors.New("base URL missing") + } + + apiEndpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + return &Client{ + apiKey: apiKey, + apiSecret: apiSecret, + BaseURL: apiEndpoint.JoinPath("jsonrpc10"), + HTTPClient: &http.Client{ + Timeout: 5 * time.Second, + }, + }, nil +} + +// AddDNSRecord adds a DNS record. +// http://95.128.3.201:8053/API/NSService_10#addDNSRecord +func (d *Client) AddDNSRecord(ctx context.Context, zoneName, viewName string, record DNSNode) error { + digest := d.computeDigest( + zoneName, + viewName, + record.Name, + record.Type, + strconv.Itoa(record.TTL), + record.Data, + ) + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + zoneName, + viewName, + record, + } + + var ok bool + + err := d.doRequest(ctx, "addDNSRecord", params, &ok) + if err != nil { + return err + } + + if !ok { + return errors.New("addDNSRecord failed") + } + + return nil +} + +// UpdateDNSHost updates a DNS host record. +// Passing an empty newNode removes the oldNode. +// http://95.128.3.201:8053/API/NSService_10#updateDNSHost +func (d *Client) UpdateDNSHost(ctx context.Context, zoneName, viewName string, oldNode, newNode DNSNode) error { + digest := d.computeDigest(zoneName, viewName) + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + zoneName, + viewName, + oldNode, + newNode, + } + + var ok bool + + err := d.doRequest(ctx, "updateDNSHost", params, &ok) + if err != nil { + return err + } + + if !ok { + return errors.New("updateDNSHost failed") + } + + return nil +} + +// SearchDNSHosts searches for DNS host records. +// http://95.128.3.201:8053/API/NSService_10#searchDNSHosts +func (d *Client) SearchDNSHosts(ctx context.Context, pattern string) ([]DNSNode, error) { + digest := d.computeDigest(pattern) + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + pattern, + } + + var nodes []DNSNode + + err := d.doRequest(ctx, "searchDNSHosts", params, &nodes) + if err != nil { + return nil, err + } + + return nodes, nil +} + +// ListZones lists DNS zones. +// http://95.128.3.201:8053/API/NSService_10#listZones +func (d *Client) ListZones(ctx context.Context, mode string) ([]DNSZone, error) { + digest := d.computeDigest() + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + mode, + } + + var zones []DNSZone + + err := d.doRequest(ctx, "listZones", params, &zones) + if err != nil { + return nil, err + } + + return zones, nil +} + +func (d *Client) doRequest(ctx context.Context, method string, params []any, result any) error { + payload := APIRequest{ + ID: 1, + Method: method, + Params: slices.Concat([]any{d.apiKey}, params), + } + + buf := new(bytes.Buffer) + + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return fmt.Errorf("failed to create request JSON body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.BaseURL.String(), buf) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := d.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 errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + if resp.StatusCode/100 != 2 { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + var rpcResp APIResponse + + err = json.Unmarshal(raw, &rpcResp) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + if rpcResp.Error != nil { + return rpcResp.Error + } + + err = json.Unmarshal(rpcResp.Result, result) + if err != nil { + return fmt.Errorf("unable to unmarshal response: %w: %s", err, rpcResp.Result) + } + + return nil +} + +func (d *Client) computeDigest(parts ...string) string { + params := []string{d.apiKey} + params = append(params, parts...) + params = append(params, d.apiSecret) + + mac := hmac.New(sha256.New, []byte(d.apiSecret)) + mac.Write([]byte(strings.Join(params, "&"))) + + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/providers/dns/namesurfer/internal/client_test.go b/providers/dns/namesurfer/internal/client_test.go new file mode 100644 index 000000000..9e8f917bc --- /dev/null +++ b/providers/dns/namesurfer/internal/client_test.go @@ -0,0 +1,158 @@ +package internal + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "user", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestClient_AddDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("addDNSRecord.json"), + servermock.CheckRequestJSONBodyFromFixture("addDNSRecord-request.json"), + ). + Build(t) + + record := DNSNode{ + Name: "_acme-challenge", + Type: "TXT", + Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + } + + err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record) + require.NoError(t, err) +} + +func TestClient_AddDNSRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("error.json"), + ). + Build(t) + + record := DNSNode{ + Name: "_acme-challenge", + Type: "TXT", + Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + } + + err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record) + require.EqualError(t, err, "code: Server.Keyfailure, "+ + "filename: service, line: 13, "+ + "message: Unknown keyname user, "+ + `detail: Traceback (most recent call last): File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 159, in dispatch_request result = self.call_method(method,req_dict,tc,export_dict,log_line) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 96, in call_method result = getattr(service_class_instance,req_dict['methodname'])(*args) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py", line 77, in injector res = f(*args,**kw) File "/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py", line 502, in addDNSRecord key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data]) File "/usr/local/namesurfer/webui2/webui/service/base/implementation.py", line 63, in validate_key raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname) ApiFault: service(13): Unknown keyname user `) +} + +func TestClient_UpdateDNSHost(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("updateDNSHost.json"), + servermock.CheckRequestJSONBodyFromFixture("updateDNSHost-request.json"), + ). + Build(t) + + record := DNSNode{ + Name: "_acme-challenge", + Type: "TXT", + Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + } + + err := client.UpdateDNSHost(t.Context(), "example.com", "viewA", record, DNSNode{}) + require.NoError(t, err) +} + +func TestClient_SearchDNSHosts(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("searchDNSHosts.json"), + servermock.CheckRequestJSONBodyFromFixture("searchDNSHosts-request.json"), + ). + Build(t) + + records, err := client.SearchDNSHosts(t.Context(), "value") + require.NoError(t, err) + + expected := []DNSNode{ + {Name: "foo", Type: "TXT", Data: "xxx", TTL: 300}, + {Name: "_acme-challenge", Type: "TXT", Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300}, + {Name: "bar", Type: "A", Data: "yyy", TTL: 300}, + } + + assert.Equal(t, expected, records) +} + +func TestClient_ListZones(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("listZones.json"), + servermock.CheckRequestJSONBodyFromFixture("listZones-request.json"), + ). + Build(t) + + zones, err := client.ListZones(t.Context(), "value") + require.NoError(t, err) + + expected := []DNSZone{ + {Name: "example.com", View: "viewA"}, + {Name: "example.org", View: "viewB"}, + {Name: "example.net", View: "viewC"}, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_computeDigest(t *testing.T) { + client, err := NewClient("https://test.example.com", "testkey", "testsecret") + require.NoError(t, err) + + testCases := []struct { + desc string + parts []string + expected string + }{ + { + desc: "no parts", + parts: []string{}, + expected: "99b5dcdc19bfc0ce2af3fe848f4bcb6f7beb352e9599e8ba50544d86de567282", + }, + { + desc: "parts", + parts: []string{"zone.example.com", "default"}, + expected: "94efef76383889b1ae620582a25d1c3aa9bd9ba9ac4bdccdf4aefbc3ae6e8329", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + digest := client.computeDigest(test.parts...) + + assert.Equal(t, test.expected, digest) + }) + } +} diff --git a/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json b/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json new file mode 100644 index 000000000..660109aae --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json @@ -0,0 +1,16 @@ +{ + "id": 1, + "method": "addDNSRecord", + "params": [ + "user", + "4fcc5fa29531709b0381c8debea127a6a26e71cb9491727876819cf5805c4990", + "example.com", + "viewA", + { + "name": "_acme-challenge", + "type": "TXT", + "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 300 + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json b/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json new file mode 100644 index 000000000..f41779e30 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json @@ -0,0 +1,4 @@ +{ + "id": 1, + "result": true +} diff --git a/providers/dns/namesurfer/internal/fixtures/error.json b/providers/dns/namesurfer/internal/fixtures/error.json new file mode 100644 index 000000000..8ddf8df25 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/error.json @@ -0,0 +1,24 @@ +{ + "result": null, + "error": { + "filename": "service", + "lineno": 13, + "code": "Server.Keyfailure", + "string": "Unknown keyname user", + "detail": [ + "Traceback (most recent call last):", + " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 159, in dispatch_request", + " result = self.call_method(method,req_dict,tc,export_dict,log_line)", + " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 96, in call_method", + " result = getattr(service_class_instance,req_dict['methodname'])(*args)", + " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py\", line 77, in injector", + " res = f(*args,**kw)", + " File \"/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py\", line 502, in addDNSRecord", + " key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data])", + " File \"/usr/local/namesurfer/webui2/webui/service/base/implementation.py\", line 63, in validate_key", + " raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname)", + "ApiFault: service(13): Unknown keyname user", + "" + ] + } +} diff --git a/providers/dns/namesurfer/internal/fixtures/listZones-request.json b/providers/dns/namesurfer/internal/fixtures/listZones-request.json new file mode 100644 index 000000000..06689de7a --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/listZones-request.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "method": "listZones", + "params": [ + "user", + "2739461ea1a3dc51302993f724f40228409c53b78025d8d7b1d7bba3c1bf2d66", + "value" + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/listZones.json b/providers/dns/namesurfer/internal/fixtures/listZones.json new file mode 100644 index 000000000..37fa2053b --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/listZones.json @@ -0,0 +1,17 @@ +{ + "id": 1, + "result": [ + { + "name": "example.com", + "view": "viewA" + }, + { + "name": "example.org", + "view": "viewB" + }, + { + "name": "example.net", + "view": "viewC" + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json new file mode 100644 index 000000000..4a88340e2 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "method": "searchDNSHosts", + "params": [ + "user", + "02cf1a2f6e124507d16738d595f583932185313fc96afc2d8404960acaec29b4", + "value" + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json new file mode 100644 index 000000000..822459148 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json @@ -0,0 +1,23 @@ +{ + "id": 1, + "result": [ + { + "name": "foo", + "type": "TXT", + "data": "xxx", + "ttl": 300 + }, + { + "name": "_acme-challenge", + "type": "TXT", + "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 300 + }, + { + "name": "bar", + "type": "A", + "data": "yyy", + "ttl": 300 + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json new file mode 100644 index 000000000..c99218ec5 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json @@ -0,0 +1,17 @@ +{ + "id": 1, + "method": "updateDNSHost", + "params": [ + "user", + "510e63288ac874c1d5ba313a9411591daa346e5621fb0153263adc278794e378", + "example.com", + "viewA", + { + "name": "_acme-challenge", + "type": "TXT", + "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 300 + }, + {} + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json b/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json new file mode 100644 index 000000000..f41779e30 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json @@ -0,0 +1,4 @@ +{ + "id": 1, + "result": true +} diff --git a/providers/dns/namesurfer/internal/types.go b/providers/dns/namesurfer/internal/types.go new file mode 100644 index 000000000..f95593745 --- /dev/null +++ b/providers/dns/namesurfer/internal/types.go @@ -0,0 +1,72 @@ +package internal + +import ( + "encoding/json" + "fmt" + "strings" +) + +// DNSNode represents a DNS record. +// http://95.128.3.201:8053/API/NSService_10#DNSNode +type DNSNode struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Data string `json:"data,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +// DNSZone represents a DNS zone. +// http://95.128.3.201:8053/API/NSService_10#DNSZone +type DNSZone struct { + Name string `json:"name,omitempty"` + View string `json:"view,omitempty"` +} + +// APIRequest represents a JSON-RPC request. +// https://www.jsonrpc.org/specification_v1#a1.1Requestmethodinvocation +type APIRequest struct { + ID any `json:"id"` // Can be int or string depending on API + Method string `json:"method"` + Params []any `json:"params"` +} + +// APIResponse represents a JSON-RPC response. +// https://www.jsonrpc.org/specification_v1#a1.2Response +type APIResponse struct { + ID any `json:"id"` // Can be int or string depending on API + Result json.RawMessage `json:"result"` + Error *APIError `json:"error"` +} + +// APIError represents an error. +type APIError struct { + Code any `json:"code"` // Can be int or string depending on API + Filename string `json:"filename"` + LineNumber int `json:"lineno"` + Message string `json:"string"` + Detail []string `json:"detail"` +} + +func (e *APIError) Error() string { + var msg strings.Builder + + msg.WriteString(fmt.Sprintf("code: %v", e.Code)) + + if e.Filename != "" { + msg.WriteString(fmt.Sprintf(", filename: %s", e.Filename)) + } + + if e.LineNumber > 0 { + msg.WriteString(fmt.Sprintf(", line: %d", e.LineNumber)) + } + + if e.Message != "" { + msg.WriteString(fmt.Sprintf(", message: %s", e.Message)) + } + + if len(e.Detail) > 0 { + msg.WriteString(fmt.Sprintf(", detail: %v", strings.Join(e.Detail, " "))) + } + + return msg.String() +} diff --git a/providers/dns/namesurfer/namesurfer.go b/providers/dns/namesurfer/namesurfer.go new file mode 100644 index 000000000..6b7f48402 --- /dev/null +++ b/providers/dns/namesurfer/namesurfer.go @@ -0,0 +1,214 @@ +// Package namesurfer implements a DNS provider for solving the DNS-01 challenge using FusionLayer NameSurfer API. +package namesurfer + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + "strings" + "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/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/namesurfer/internal" +) + +// Environment variables names. +const ( + envNamespace = "NAMESURFER_" + + EnvBaseURL = envNamespace + "BASE_URL" + EnvAPIKey = envNamespace + "API_KEY" + EnvAPISecret = envNamespace + "API_SECRET" + EnvView = envNamespace + "VIEW" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvInsecureSkipVerify = envNamespace + "INSECURE_SKIP_VERIFY" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + BaseURL string + APIKey string + APISecret string + View string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 300), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + 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 + + zones map[string]string + zonesMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for FusionLayer NameSurfer. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvBaseURL, EnvAPIKey, EnvAPISecret) + if err != nil { + return nil, fmt.Errorf("namesurfer: %w", err) + } + + config := NewDefaultConfig() + config.BaseURL = values[EnvBaseURL] + config.APIKey = values[EnvAPIKey] + config.APISecret = values[EnvAPISecret] + config.View = env.GetOrDefaultString(EnvView, "") + + if env.GetOrDefaultBool(EnvInsecureSkipVerify, false) { + config.HTTPClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for FusionLayer NameSurfer. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("namesurfer: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.BaseURL, config.APIKey, config.APISecret) + if err != nil { + return nil, fmt.Errorf("namesurfer: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + zones: make(map[string]string), + }, 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) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("namesurfer: %w", err) + } + + d.zonesMu.Lock() + d.zones[token] = zone + d.zonesMu.Unlock() + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("namesurfer: %w", err) + } + + record := internal.DNSNode{ + Name: subDomain, + Type: "TXT", + TTL: d.config.TTL, + Data: info.Value, + } + + err = d.client.AddDNSRecord(ctx, zone, d.config.View, record) + if err != nil { + return fmt.Errorf("namesurfer: add DNS 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) + + d.zonesMu.Lock() + zone, ok := d.zones[token] + d.zonesMu.Unlock() + + if !ok { + return fmt.Errorf("namesurfer: unknown zone for '%s'", info.EffectiveFQDN) + } + + d.zonesMu.Lock() + delete(d.zones, token) + d.zonesMu.Unlock() + + existing, err := d.client.SearchDNSHosts(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("namesurfer: search DNS hosts: %w", err) + } + + for _, node := range existing { + if node.Type != "TXT" || node.Data != info.Value { + continue + } + + err = d.client.UpdateDNSHost(ctx, zone, d.config.View, node, internal.DNSNode{}) + if err != nil { + return fmt.Errorf("namesurfer: update DNS host: %w", err) + } + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) { + zones, err := d.client.ListZones(ctx, "forward") + if err != nil { + return "", fmt.Errorf("list zones: %w", err) + } + + domain := dns01.UnFqdn(fqdn) + + var zoneName string + + for _, zone := range zones { + if strings.HasSuffix(domain, zone.Name) && len(zone.Name) > len(zoneName) { + zoneName = zone.Name + } + } + + if zoneName == "" { + return "", fmt.Errorf("no zone found for %s", fqdn) + } + + return zoneName, nil +} diff --git a/providers/dns/namesurfer/namesurfer.toml b/providers/dns/namesurfer/namesurfer.toml new file mode 100644 index 000000000..fd914ec0c --- /dev/null +++ b/providers/dns/namesurfer/namesurfer.toml @@ -0,0 +1,28 @@ +Name = "FusionLayer NameSurfer" +Description = '''''' +URL = "https://www.fusionlayer.com/" +Code = "namesurfer" +Since = "v4.32.0" + +Example = ''' +NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \ +NAMESURFER_API_KEY=xxx \ +NAMESURFER_API_SECRET=yyy \ +lego --dns namesurfer -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + NAMESURFER_BASE_URL = "The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)" + NAMESURFER_API_KEY = "API key name" + NAMESURFER_API_SECRET = "API secret" + [Configuration.Additional] + NAMESURFER_VIEW = "DNS view name (optional, default: empty string)" + NAMESURFER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NAMESURFER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + NAMESURFER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + NAMESURFER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + NAMESURFER_INSECURE_SKIP_VERIFY = "Whether to verify the API certificate" + +[Links] + API = "https://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10" diff --git a/providers/dns/namesurfer/namesurfer_test.go b/providers/dns/namesurfer/namesurfer_test.go new file mode 100644 index 000000000..ce3aa37af --- /dev/null +++ b/providers/dns/namesurfer/namesurfer_test.go @@ -0,0 +1,174 @@ +package namesurfer + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvBaseURL, + EnvAPIKey, + EnvAPISecret, + EnvView, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvAPIKey: "user", + EnvAPISecret: "secret", + }, + }, + { + desc: "missing base URL", + envVars: map[string]string{ + EnvBaseURL: "", + EnvAPIKey: "user", + EnvAPISecret: "secret", + }, + expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL", + }, + { + desc: "missing API key", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvAPIKey: "", + EnvAPISecret: "secret", + }, + expected: "namesurfer: some credentials information are missing: NAMESURFER_API_KEY", + }, + { + desc: "missing API secret", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvAPIKey: "user", + EnvAPISecret: "", + }, + expected: "namesurfer: some credentials information are missing: NAMESURFER_API_SECRET", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL,NAMESURFER_API_KEY,NAMESURFER_API_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 + baseURL string + apiKey string + apiSecret string + expected string + }{ + { + desc: "success", + baseURL: "https://example.com", + apiKey: "user", + apiSecret: "secret", + }, + { + desc: "missing base URL", + apiKey: "user", + apiSecret: "secret", + expected: "namesurfer: base URL missing", + }, + { + desc: "missing API key", + baseURL: "https://example.com", + apiSecret: "secret", + expected: "namesurfer: credentials missing", + }, + { + desc: "missing API secret", + baseURL: "https://example.com", + apiKey: "user", + expected: "namesurfer: credentials missing", + }, + { + desc: "missing credentials", + expected: "namesurfer: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.BaseURL = test.baseURL + config.APIKey = test.apiKey + config.APISecret = test.apiSecret + + 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 ae41f6a20..10fda2df1 100644 --- a/providers/dns/zz_gen_dns_providers.go +++ b/providers/dns/zz_gen_dns_providers.go @@ -125,6 +125,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/namecheap" "github.com/go-acme/lego/v4/providers/dns/namedotcom" "github.com/go-acme/lego/v4/providers/dns/namesilo" + "github.com/go-acme/lego/v4/providers/dns/namesurfer" "github.com/go-acme/lego/v4/providers/dns/nearlyfreespeech" "github.com/go-acme/lego/v4/providers/dns/neodigit" "github.com/go-acme/lego/v4/providers/dns/netcup" @@ -434,6 +435,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return namedotcom.NewDNSProvider() case "namesilo": return namesilo.NewDNSProvider() + case "namesurfer": + return namesurfer.NewDNSProvider() case "nearlyfreespeech": return nearlyfreespeech.NewDNSProvider() case "neodigit":