Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
Expand Down
54 changes: 50 additions & 4 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions module.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package technitium

import (
"time"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
)
Expand Down Expand Up @@ -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())
}
Expand Down
27 changes: 27 additions & 0 deletions technitium.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ package technitium
import (
"context"
"fmt"
"strings"
"sync"
"time"

"github.com/libdns/libdns"
)
Expand All @@ -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()
Expand All @@ -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)
Expand Down
106 changes: 84 additions & 22 deletions technitium_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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")
}
}

Expand Down
Loading