From d05607ea7d91ac089f6df70a30b6326e12957e5b Mon Sep 17 00:00:00 2001 From: Aleksandar Spasojevic <46940849+spaaleks@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:15:20 +0200 Subject: [PATCH] [Feature] Cleanup stale record (#1) --- README.md | 10 +++-- client.go | 54 +++++++++++++++++++++-- module.go | 11 +++++ technitium.go | 27 ++++++++++++ technitium_test.go | 106 +++++++++++++++++++++++++++++++++++---------- 5 files changed, 178 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 2b402a9..8fa7102 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,18 @@ xcaddy build \ ```caddyfile { acme_dns technitium { - server_url http://your-technitium-server:5380 - api_token YOUR_API_TOKEN_HERE + server_url http://your-technitium-server:5380 + api_token YOUR_API_TOKEN_HERE + # stale_record_timeout 15m # optional, default 15m, set to 0 to disable } } example.com { tls { dns technitium { - server_url http://your-technitium-server:5380 - api_token YOUR_API_TOKEN_HERE + server_url http://your-technitium-server:5380 + api_token YOUR_API_TOKEN_HERE + # stale_record_timeout 15m } } respond "Hello, HTTPS!" diff --git a/client.go b/client.go index 596d88b..e6a8c42 100644 --- a/client.go +++ b/client.go @@ -45,10 +45,11 @@ type zoneInfo struct { } type dnsRecord struct { - Name string `json:"name"` - Type string `json:"type"` - TTL int `json:"ttl"` - RData json.RawMessage `json:"rData"` + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl"` + RData json.RawMessage `json:"rData"` + LastModified string `json:"lastModified"` } type txtRData struct { @@ -160,6 +161,51 @@ func (c *technitiumClient) updateOrCreateRecord(ctx context.Context, zone string return rec, nil } +// cleanupStaleRecords queries for existing TXT records at the given domain +// and deletes any that are older than maxAge. This prevents stale _acme-challenge +// records from accumulating after failed or interrupted ACME challenges. +func (c *technitiumClient) cleanupStaleRecords(ctx context.Context, zone string, domain string, maxAge time.Duration) { + params := url.Values{ + "domain": {domain}, + } + + apiResp, err := c.apiGet(ctx, "/api/zones/records/get", params) + if err != nil { + return // best-effort: if we can't query, skip cleanup + } + + var listResp listRecordsResponse + if err := json.Unmarshal(apiResp.Response, &listResp); err != nil { + return + } + + now := time.Now() + for _, r := range listResp.Records { + if r.Type != "TXT" || r.LastModified == "" { + continue + } + modified, err := time.Parse(time.RFC3339, r.LastModified) + if err != nil { + // Technitium uses a format like "2026-04-05T17:04:57.2744853Z" + // which time.RFC3339Nano handles + modified, err = time.Parse(time.RFC3339Nano, r.LastModified) + if err != nil { + continue + } + } + if now.Sub(modified) > maxAge { + var rdata txtRData + if err := json.Unmarshal(r.RData, &rdata); err != nil { + continue + } + _ = c.deleteRecord(ctx, zone, libdns.TXT{ + Name: libdns.RelativeName(strings.TrimSuffix(r.Name, "."), strings.TrimSuffix(zone, ".")), + Text: rdata.Text, + }) + } + } +} + func (c *technitiumClient) deleteRecord(ctx context.Context, zone string, rec libdns.Record) error { params := buildDeleteParams(zone, rec) _, err := c.apiGet(ctx, "/api/zones/records/delete", params) diff --git a/module.go b/module.go index 549650e..fa8819e 100644 --- a/module.go +++ b/module.go @@ -1,6 +1,8 @@ package technitium import ( + "time" + "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" ) @@ -45,6 +47,15 @@ func (p *Provider) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.ArgErr() } p.APIToken = d.Val() + case "stale_record_timeout": + if !d.NextArg() { + return d.ArgErr() + } + dur, err := time.ParseDuration(d.Val()) + if err != nil { + return d.Errf("invalid stale_record_timeout: %v", err) + } + p.StaleRecordTimeout = dur default: return d.Errf("unrecognized subdirective %q", d.Val()) } diff --git a/technitium.go b/technitium.go index 1bbd181..d89ebae 100644 --- a/technitium.go +++ b/technitium.go @@ -6,7 +6,9 @@ package technitium import ( "context" "fmt" + "strings" "sync" + "time" "github.com/libdns/libdns" ) @@ -21,10 +23,17 @@ type Provider struct { // This is used instead of TSIG — all requests are authenticated via this token. APIToken string `json:"api_token"` + // StaleRecordTimeout is how old an _acme-challenge TXT record must be + // before it is considered stale and automatically cleaned up. + // Set to 0 to disable stale record cleanup. Default: 15 minutes. + StaleRecordTimeout time.Duration `json:"stale_record_timeout,omitempty"` + client *technitiumClient mu sync.Mutex } +const defaultStaleRecordTimeout = 15 * time.Minute + func (p *Provider) getClient() *technitiumClient { p.mu.Lock() defer p.mu.Unlock() @@ -44,8 +53,26 @@ func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record } // AppendRecords adds records to the zone. It returns the records that were added. +// Before adding _acme-challenge TXT records, it cleans up any stale challenge +// records that are older than StaleRecordTimeout. func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { client := p.getClient() + + timeout := p.StaleRecordTimeout + if timeout == 0 { + timeout = defaultStaleRecordTimeout + } + if timeout > 0 { + for _, rec := range records { + rr := rec.RR() + if rr.Type == "TXT" && strings.HasPrefix(rr.Name, "_acme-challenge") { + fqdn := libdns.AbsoluteName(rr.Name, strings.TrimSuffix(zone, ".")) + fqdn = strings.TrimSuffix(fqdn, ".") + client.cleanupStaleRecords(ctx, zone, fqdn, timeout) + } + } + } + var added []libdns.Record for _, rec := range records { r, err := client.addRecord(ctx, zone, rec) diff --git a/technitium_test.go b/technitium_test.go index 460635c..12c7876 100644 --- a/technitium_test.go +++ b/technitium_test.go @@ -71,29 +71,40 @@ func TestGetRecords(t *testing.T) { } func TestAppendRecords(t *testing.T) { - called := false + addCalled := false srv, p := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/zones/records/add" { + switch r.URL.Path { + case "/api/zones/records/get": + // Stale record cleanup query — return empty records + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "response": map[string]any{ + "zone": map[string]any{"name": "example.com"}, + "records": []any{}, + }, + }) + case "/api/zones/records/add": + q := r.URL.Query() + if q.Get("token") != "test-token" { + t.Error("token not passed") + } + if q.Get("domain") != "_acme-challenge.example.com" { + t.Errorf("unexpected domain: %s", q.Get("domain")) + } + if q.Get("type") != "TXT" { + t.Errorf("unexpected type: %s", q.Get("type")) + } + if q.Get("text") != "challengetoken" { + t.Errorf("unexpected text: %s", q.Get("text")) + } + if q.Get("ttl") != "300" { + t.Errorf("unexpected ttl: %s", q.Get("ttl")) + } + addCalled = true + json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) + default: t.Errorf("unexpected path: %s", r.URL.Path) } - q := r.URL.Query() - if q.Get("token") != "test-token" { - t.Error("token not passed") - } - if q.Get("domain") != "_acme-challenge.example.com" { - t.Errorf("unexpected domain: %s", q.Get("domain")) - } - if q.Get("type") != "TXT" { - t.Errorf("unexpected type: %s", q.Get("type")) - } - if q.Get("text") != "challengetoken" { - t.Errorf("unexpected text: %s", q.Get("text")) - } - if q.Get("ttl") != "300" { - t.Errorf("unexpected ttl: %s", q.Get("ttl")) - } - called = true - json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) }) defer srv.Close() @@ -112,8 +123,59 @@ func TestAppendRecords(t *testing.T) { if len(added) != 1 { t.Fatalf("expected 1 added record, got %d", len(added)) } - if !called { - t.Error("API was not called") + if !addCalled { + t.Error("add API was not called") + } +} + +func TestAppendRecords_CleansStaleRecords(t *testing.T) { + staleTime := time.Now().Add(-15 * time.Minute).UTC().Format(time.RFC3339Nano) + deleteCalled := false + + srv, p := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/zones/records/get": + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "response": map[string]any{ + "zone": map[string]any{"name": "example.com"}, + "records": []map[string]any{ + { + "name": "_acme-challenge.example.com", + "type": "TXT", + "ttl": 120, + "rData": map[string]any{"text": "old-stale-token"}, + "lastModified": staleTime, + }, + }, + }, + }) + case "/api/zones/records/delete": + q := r.URL.Query() + if q.Get("text") == "old-stale-token" { + deleteCalled = true + } + json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) + case "/api/zones/records/add": + json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) + } + }) + defer srv.Close() + + records := []libdns.Record{ + libdns.TXT{ + Name: "_acme-challenge", + Text: "new-challenge-token", + TTL: 120 * time.Second, + }, + } + + _, err := p.AppendRecords(context.Background(), "example.com.", records) + if err != nil { + t.Fatalf("AppendRecords error: %v", err) + } + if !deleteCalled { + t.Error("stale record was not deleted") } }