Repository: go-acme/lego
Pull request #2437 (2025-02-15 19:50:30)

acme-dns: allow the HTTP storage server to create the CNAME
This allows the HTTP storage server to control the CNAME creation.

if the `Put` returns a 201, lego expects the CNAME to be created automatically by the server.

Closes #2436

skippyprime: I think the HTTP 201 Created logic needs to be moved to the Put function.  The save function won’t have the context of which domain it is responding to.
ldez: The Save will be always called because of the file implementation, so an implementation of the `Save` is required.
ldez: :thinking: I can skip the `Save` but it will be weird.
skippyprime: The reason I suggested the check in Put vs Save is because when we have multiple domain challenges.  One the first domain challenge, everything works as intended 1. Put the domain + acme dns data and then 2. Save would create the single CNAME record.

When 2+ domains are registered, the Save would have to iterate all domain challenges stored and attempt to create the CNAME because it doesn’t have the context on Save to know which CNAME needs created.  If the Save had the domain attempting a minimum this wouldn’t be an issue.
ldez: Can you check the new implementation?
ldez: When I created the initial HTTP storage implementation the `Save` I wondered if I should skip the step.

My thinking was that if a DNS provider doesn't provide an API usable by lego, there is no possibility to automate the CNAME process.

So, I'm interested in more details about your context/use case, could you give me more details?

skippyprime: I think the logic in HTTP response is way better than the environment variable approach.

While we know that there are only 2 storage implementations and the HTTP Save is a no-op, we could still call save to keep the client logic clean.  You could do:

```go
cnameCreated := errors.Is(internal.ErrCNAMECreated)

// call save and check for error

if !cnameCreated {
  // return error
}

return nil
```

Sorry for any errors in code above, on a phone.

ldez: I cannot do that because no `ErrCNAMECreated` doesn't mean there is no error or an error.
I should handle the error first.
ldez: I think I understand your suggestion, your snippet was a bit confusing, sorry.

Could you answer my previous question? https://github.com/go-acme/lego/pull/2437#issuecomment-2661083787
skippyprime: The use case is for a generic ingress service for local development.

I end up working on a number of different projects (typically a mix of related micro-services or isolated projects) that either I need to access from the host browser or the services need to communicate directly.  Since most of these are separate git repos, it is ideal for them to be loosely coupled so they can run independently or together.  Rather than juggling lots of different exposed ports, it is preferred to use standard ports (80, 443) and depend of SNI or Host matching to route traffic to the correct services.

I am releasing an open source service kit (traefik + coredns + stepca + acme-dns) that developers can run locally to dynamically add routes to traefik, allow traefik to automatically obtain certificates (HTTP-01 and DNS-01 challenges).  The DNS challenges were required to support wildcard certificates.

The acme-dns HTTP storage layer was the piece that is making this possible.  I wrote a simple HTTP storage server that will save the acme-dns accounts in the same format as though you are using the file storage backend.  It will take care of registering the CNAME with coredns before it returns the HTTP 201 Created to the Put request.
ldez: Your stack is interesting.

>  It will take care of registering the CNAME with coredns before it returns the HTTP 201 Created to the Put request.

How do you create the CNAMEs? (coredns has no API to add/remove records)
Is it a global forward?


Patch:
diff --git a/providers/dns/acmedns/acmedns.go b/providers/dns/acmedns/acmedns.go
index 8aabaa14..9dd63d0a 100644
--- a/providers/dns/acmedns/acmedns.go
+++ b/providers/dns/acmedns/acmedns.go
@@ -178,16 +178,26 @@ func (d *DNSProvider) register(ctx context.Context, domain, fqdn string) error {
 		return err
 	}
 
+	var cnameCreated bool
+
 	// Store the new account in the storage and call save to persist the data.
 	err = d.storage.Put(ctx, domain, newAcct)
 	if err != nil {
-		return err
+		cnameCreated = errors.Is(err, internal.ErrCNAMEAlreadyCreated)
+		if !cnameCreated {
+			return err
+		}
 	}
+
 	err = d.storage.Save(ctx)
 	if err != nil {
 		return err
 	}
 
+	if cnameCreated {
+		return nil
+	}
+
 	// Stop issuance by returning an error.
 	// The user needs to perform a manual one-time CNAME setup in their DNS zone
 	// to complete the setup of the new account we created.
diff --git a/providers/dns/acmedns/internal/http_storage.go b/providers/dns/acmedns/internal/http_storage.go
index 1a1e8e2e..7a535eb2 100644
--- a/providers/dns/acmedns/internal/http_storage.go
+++ b/providers/dns/acmedns/internal/http_storage.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -17,6 +18,8 @@ import (
 
 var _ goacmedns.Storage = (*HTTPStorage)(nil)
 
+var ErrCNAMEAlreadyCreated = errors.New("the CNAME has already been created")
+
 // HTTPStorage is an implementation of [acmedns.Storage] over HTTP.
 type HTTPStorage struct {
 	client  *http.Client
@@ -98,6 +101,11 @@ func (s *HTTPStorage) do(req *http.Request, result any) error {
 	}
 
 	if result == nil {
+		// Hack related to `Put`.
+		if resp.StatusCode == http.StatusCreated {
+			return ErrCNAMEAlreadyCreated
+		}
+
 		return nil
 	}
 
diff --git a/providers/dns/acmedns/internal/readme.md b/providers/dns/acmedns/internal/readme.md
index bccdc538..b667d3d2 100644
--- a/providers/dns/acmedns/internal/readme.md
+++ b/providers/dns/acmedns/internal/readme.md
@@ -61,7 +61,9 @@ Endpoint: `POST <BaseURL>/<domain>`
 
 ### Response
 
-Response status code 200.
+Response status code:
+- 200: the process will be stopped to allow the user to create the CNAME.
+- 201: the process will continue without error (the CNAME should be created by the server)
 
 No expected body.
 


Test patch:
diff --git a/providers/dns/acmedns/internal/http_storage_test.go b/providers/dns/acmedns/internal/http_storage_test.go
index a628f9a6..7f936772 100644
--- a/providers/dns/acmedns/internal/http_storage_test.go
+++ b/providers/dns/acmedns/internal/http_storage_test.go
@@ -137,3 +137,18 @@ func TestHTTPStorage_Put_error(t *testing.T) {
 	err := storage.Put(context.Background(), "example.com", account)
 	require.Error(t, err)
 }
+
+func TestHTTPStorage_Put_CNAME_created(t *testing.T) {
+	storage := setupTest(t, "POST /example.com", "", http.StatusCreated)
+
+	account := goacmedns.Account{
+		FullDomain: "foo.example.com",
+		SubDomain:  "foo",
+		Username:   "user",
+		Password:   "secret",
+		ServerURL:  "https://example.com",
+	}
+
+	err := storage.Put(context.Background(), "example.com", account)
+	require.ErrorIs(t, err, ErrCNAMEAlreadyCreated)
+}