Issue real certificates via ACME DNS-01

Tunnels now get a per-tunnel certificate from the configured ACME CA
(Let's Encrypt by default) with DNS-01 through Porkbun, keeping the
client's CSR and key. Issuance is asynchronous (issuing/ready/failed);
the account persists in the store, renewals run in a 30-day window with
retries, and a failed renewal keeps the old certificate serving.
Switching from the local CA reissues existing tunnels automatically,
and the admin dashboard shows the issuer and certificate state.

Verified against Pebble and Let's Encrypt staging.
This commit is contained in:
2026-10-25 23:33:52 +08:00
parent 05d326f47f
commit 437a2e447e
25 changed files with 1491 additions and 79 deletions
+57
View File
@@ -0,0 +1,57 @@
package server
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestChallengeTestSrvDNS(t *testing.T) {
var setBody, clearBody map[string]string
var setPath, clearPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
switch r.URL.Path {
case "/set-txt":
setPath = r.URL.Path
_ = json.Unmarshal(body, &setBody)
case "/clear-txt":
clearPath = r.URL.Path
_ = json.Unmarshal(body, &clearBody)
default:
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := &ChallengeTestSrvDNS{BaseURL: srv.URL, HTTP: srv.Client()}
ctx := context.Background()
if err := c.CreateTXT(ctx, "_acme-challenge.abc.localhost", "value"); err != nil {
t.Fatal(err)
}
if setPath != "/set-txt" || setBody["host"] != "_acme-challenge.abc.localhost" || setBody["value"] != "value" {
t.Errorf("set: path %q body %v", setPath, setBody)
}
if err := c.DeleteTXT(ctx, "_acme-challenge.abc.localhost"); err != nil {
t.Fatal(err)
}
if clearPath != "/clear-txt" || clearBody["host"] != "_acme-challenge.abc.localhost" {
t.Errorf("clear: path %q body %v", clearPath, clearBody)
}
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no", http.StatusInternalServerError)
}))
defer bad.Close()
c = &ChallengeTestSrvDNS{BaseURL: bad.URL, HTTP: bad.Client()}
if err := c.CreateTXT(ctx, "x", "y"); err == nil {
t.Error("expected an error for a non-200 response")
}
}