feat: add record deletions for removed clients

This commit is contained in:
Aaron U'Ren
2025-11-30 21:53:21 -06:00
committed by Aaron U'Ren
parent 9ec4b34a91
commit 6405f2745c
5 changed files with 484 additions and 24 deletions
+6
View File
@@ -278,6 +278,7 @@ zone "tailscale.example.com" {
type master;
file "tailscale.example.com.zone";
allow-update { key "tailscale-bind-ddns-key"; };
allow-transfer { key "tailscale-bind-ddns-key"; }; // Required for inventory sync
};
// OPTIONAL: if you are enabling reverse DNS functionality (note this would only cover addresses
@@ -287,9 +288,14 @@ zone "64.100.in-addr.arpa" {
type master;
file "/etc/bind/zones/64.100.in-addr.arpa.zone";
allow-update { key "tailscale-bind-ddns-key"; };
allow-transfer { key "tailscale-bind-ddns-key"; };
};
```
The AXFR permission (`allow-transfer`) is necessary because the application performs an authenticated zone transfer at startup
to learn about any records that were added while it was offline. Apply similar access rules to every zone the application
manages so stale entries can be cleaned up automatically.
3. **Create Zone File** (`/var/lib/bind/tailscale.example.com.zone`):
This may be in a different location depending on the settings of your Linux package distributor or
+122 -12
View File
@@ -24,6 +24,9 @@ type App struct {
recordChan chan []bind.DNSRecord
metricsManager *metrics.Manager
wg sync.WaitGroup
stateMu sync.Mutex
currentRecords map[string]bind.DNSRecord
desiredRecords map[string]bind.DNSRecord
}
// NewApp creates a new application instance
@@ -75,6 +78,10 @@ func (a *App) Run(ctx context.Context) error {
return fmt.Errorf("bind connection validation failed: %w", err)
}
if err := a.loadExistingDNSRecords(ctx); err != nil {
return fmt.Errorf("loading existing DNS records: %w", err)
}
// Start the machine-to-record converter
a.wg.Add(1)
go func() {
@@ -126,20 +133,20 @@ func (a *App) convertMachinesToRecords(ctx context.Context) {
records := a.machinesToRecords(machines)
ptrRecords := a.createPTRRecords(machines)
// Combine A/AAAA and PTR records
allRecords := make([]bind.DNSRecord, 0, len(records)+len(ptrRecords))
allRecords = append(allRecords, records...)
allRecords = append(allRecords, ptrRecords...)
//nolint:gocritic // we understand that we're appending to a new array here
allRecords := append(records, ptrRecords...)
updates := a.prepareRecordUpdates(allRecords)
if len(updates) == 0 {
continue
}
if len(allRecords) > 0 {
select {
case a.recordChan <- allRecords:
if a.metricsManager != nil {
a.metricsManager.ObserveRecordQueue(len(a.recordChan))
}
case <-ctx.Done():
return
select {
case a.recordChan <- updates:
if a.metricsManager != nil {
a.metricsManager.ObserveRecordQueue(len(a.recordChan))
}
case <-ctx.Done():
return
}
case <-ctx.Done():
@@ -246,6 +253,8 @@ func (a *App) processBindUpdate(ctx context.Context, records []bind.DNSRecord) {
if err != nil {
klog.Errorf("Failed to update DNS records: %v", err)
} else {
a.markStateApplied()
}
if a.metricsManager != nil {
@@ -402,6 +411,107 @@ func (a *App) GetStatus() map[string]interface{} {
}
}
func (a *App) loadExistingDNSRecords(ctx context.Context) error {
records, err := a.bindClient.FetchInventory(ctx)
if err != nil {
return err
}
a.stateMu.Lock()
defer a.stateMu.Unlock()
a.currentRecords = recordsToMap(records)
a.desiredRecords = cloneRecordMap(a.currentRecords)
klog.Infof("Initialized DNS state with %d records", len(a.currentRecords))
return nil
}
func (a *App) prepareRecordUpdates(records []bind.DNSRecord) []bind.DNSRecord {
a.stateMu.Lock()
defer a.stateMu.Unlock()
desired := recordsToMap(records)
var updates []bind.DNSRecord
for key, desiredRecord := range desired {
if current, ok := a.currentRecords[key]; !ok ||
current.Value != desiredRecord.Value ||
current.TTL != desiredRecord.TTL {
updates = append(updates, desiredRecord)
}
}
for key, current := range a.currentRecords {
if _, ok := desired[key]; !ok {
toDelete := current
toDelete.Delete = true
updates = append(updates, toDelete)
}
}
a.desiredRecords = desired
if len(updates) == 0 && mapsEqual(desired, a.currentRecords) {
return nil
}
return updates
}
func (a *App) markStateApplied() {
a.stateMu.Lock()
defer a.stateMu.Unlock()
a.currentRecords = cloneRecordMap(a.desiredRecords)
}
func recordsToMap(records []bind.DNSRecord) map[string]bind.DNSRecord {
if len(records) == 0 {
return make(map[string]bind.DNSRecord)
}
result := make(map[string]bind.DNSRecord, len(records))
for _, record := range records {
record.Delete = false
result[recordMapKey(record)] = record
}
return result
}
func cloneRecordMap(src map[string]bind.DNSRecord) map[string]bind.DNSRecord {
if len(src) == 0 {
return make(map[string]bind.DNSRecord)
}
clone := make(map[string]bind.DNSRecord, len(src))
for key, record := range src {
clone[key] = record
}
return clone
}
func mapsEqual(a, b map[string]bind.DNSRecord) bool {
if len(a) != len(b) {
return false
}
for key, recordA := range a {
recordB, ok := b[key]
if !ok {
return false
}
if recordA.Value != recordB.Value || recordA.TTL != recordB.TTL {
return false
}
}
return true
}
func recordMapKey(record bind.DNSRecord) string {
return fmt.Sprintf("%s|%s", record.Type, record.Name)
}
// sanitizeDNSName sanitizes a string to be a valid DNS record name
func sanitizeDNSName(name string) string {
// Extract only the hostname (leftmost part) from FQDN
+66
View File
@@ -390,6 +390,72 @@ func TestAppRun(t *testing.T) {
assert.NotNil(t, app.recordChan)
}
func TestPrepareRecordUpdatesHandlesAddsAndDeletes(t *testing.T) {
app := &App{}
app.currentRecords = recordsToMap([]bind.DNSRecord{
{Name: "old", Value: "100.64.0.1", TTL: 300, Type: "A"},
})
app.desiredRecords = cloneRecordMap(app.currentRecords)
updates := app.prepareRecordUpdates([]bind.DNSRecord{
{Name: "new", Value: "100.64.0.2", TTL: 300, Type: "A"},
})
require.Len(t, updates, 2)
var addCount, deleteCount int
for _, update := range updates {
if update.Delete {
deleteCount++
assert.Equal(t, "old", update.Name)
} else {
addCount++
assert.Equal(t, "new", update.Name)
assert.Equal(t, "100.64.0.2", update.Value)
}
}
assert.Equal(t, 1, addCount)
assert.Equal(t, 1, deleteCount)
}
func TestPrepareRecordUpdatesNoChanges(t *testing.T) {
initial := []bind.DNSRecord{
{Name: "machine", Value: "100.64.0.5", TTL: 300, Type: "A"},
}
app := &App{}
app.currentRecords = recordsToMap(initial)
app.desiredRecords = cloneRecordMap(app.currentRecords)
updates := app.prepareRecordUpdates(initial)
assert.Nil(t, updates)
}
func TestPrepareRecordUpdatesRetriesAfterFailure(t *testing.T) {
app := &App{}
app.currentRecords = recordsToMap([]bind.DNSRecord{
{Name: "old", Value: "100.64.0.1", TTL: 300, Type: "A"},
})
app.desiredRecords = recordsToMap([]bind.DNSRecord{
{Name: "new", Value: "100.64.0.2", TTL: 300, Type: "A"},
})
updates := app.prepareRecordUpdates([]bind.DNSRecord{
{Name: "new", Value: "100.64.0.2", TTL: 300, Type: "A"},
})
require.Len(t, updates, 2)
var deleteFound bool
for _, update := range updates {
if update.Delete && update.Name == "old" {
deleteFound = true
}
}
assert.True(t, deleteFound, "expected deletion of old record to retry after failure")
}
func mustMetricsManager(t *testing.T, cfg config.MetricsConfig) *metrics.Manager {
manager, err := metrics.NewManager(cfg)
require.NoError(t, err)
+214 -12
View File
@@ -33,6 +33,10 @@ const (
IPv6NibbleShift = 4 // Nibble shift amount
)
const tsigDefaultTimeout = 300
type axfrFunc func(ctx context.Context, zone string) ([]dns.RR, error)
// Client represents a Bind DDNS client
type Client struct {
server string
@@ -45,6 +49,8 @@ type Client struct {
// PTR configuration
ptrConfig *config.PTRConfig
axfr axfrFunc
}
// DNSRecord represents a DNS record (A or PTR)
@@ -52,7 +58,9 @@ type DNSRecord struct {
Name string
Value string
TTL uint32
Type string // "A" or "PTR"
Type string // "A", "AAAA", or "PTR"
// Delete indicates that the record should be removed without inserting a replacement.
Delete bool
}
// NewClient creates a new Bind DDNS client
@@ -76,7 +84,7 @@ func NewClient(
return nil, fmt.Errorf("key secret is required")
}
return &Client{
client := &Client{
server: server,
port: port,
zone: zone,
@@ -85,20 +93,199 @@ func NewClient(
algorithm: algorithm,
ttl: uint32(ttl.Seconds()),
ptrConfig: ptrConfig,
}, nil
}
client.init()
return client, nil
}
// init injects defaults that require the client instance.
func (c *Client) init() {
if c.axfr == nil {
c.axfr = c.axfrTransfer
}
}
func (c *Client) serverAddress() string {
return net.JoinHostPort(c.server, fmt.Sprintf("%d", c.port))
}
// FetchInventory retrieves the current DNS records from the managed zones via AXFR.
func (c *Client) FetchInventory(ctx context.Context) ([]DNSRecord, error) {
c.init()
var inventory []DNSRecord
forwardRecords, err := c.collectZoneRecords(ctx, c.zone, func(rr dns.RR) *DNSRecord {
switch v := rr.(type) {
case *dns.A:
name := trimZoneSuffix(v.Hdr.Name, c.zone)
if name == "" {
return nil
}
return &DNSRecord{
Name: name,
Value: v.A.String(),
TTL: v.Hdr.Ttl,
Type: "A",
}
case *dns.AAAA:
name := trimZoneSuffix(v.Hdr.Name, c.zone)
if name == "" {
return nil
}
return &DNSRecord{
Name: name,
Value: v.AAAA.String(),
TTL: v.Hdr.Ttl,
Type: "AAAA",
}
default:
return nil
}
})
if err != nil {
return nil, fmt.Errorf("fetching zone %s: %w", c.zone, err)
}
inventory = append(inventory, forwardRecords...)
if c.ptrConfig != nil && c.ptrConfig.Enabled {
if c.ptrConfig.IPv4Zone != "" {
ipv4Records, err := c.collectZoneRecords(ctx, c.ptrConfig.IPv4Zone, func(rr dns.RR) *DNSRecord {
ptr, ok := rr.(*dns.PTR)
if !ok {
return nil
}
return &DNSRecord{
Name: dns.Fqdn(ptr.Hdr.Name),
Value: strings.TrimSuffix(ptr.Ptr, "."),
TTL: ptr.Hdr.Ttl,
Type: "PTR",
}
})
if err != nil {
return nil, fmt.Errorf("fetching IPv4 PTR zone %s: %w", c.ptrConfig.IPv4Zone, err)
}
inventory = append(inventory, ipv4Records...)
}
if c.ptrConfig.IPv6Enabled && c.ptrConfig.IPv6Zone != "" {
ipv6Records, err := c.collectZoneRecords(ctx, c.ptrConfig.IPv6Zone, func(rr dns.RR) *DNSRecord {
ptr, ok := rr.(*dns.PTR)
if !ok {
return nil
}
return &DNSRecord{
Name: dns.Fqdn(ptr.Hdr.Name),
Value: strings.TrimSuffix(ptr.Ptr, "."),
TTL: ptr.Hdr.Ttl,
Type: "PTR",
}
})
if err != nil {
return nil, fmt.Errorf("fetching IPv6 PTR zone %s: %w", c.ptrConfig.IPv6Zone, err)
}
inventory = append(inventory, ipv6Records...)
}
}
return inventory, nil
}
func (c *Client) collectZoneRecords(ctx context.Context,
zone string,
normalize func(dns.RR) *DNSRecord) ([]DNSRecord, error) {
if zone == "" {
return nil, nil
}
rrs, err := c.axfr(ctx, zone)
if err != nil {
return nil, err
}
var records []DNSRecord
for _, rr := range rrs {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
record := normalize(rr)
if record == nil {
continue
}
records = append(records, *record)
}
return records, nil
}
func trimZoneSuffix(name, zone string) string {
name = strings.TrimSuffix(name, ".")
zoneFqdn := strings.TrimSuffix(dns.Fqdn(zone), ".")
if name == zoneFqdn {
return ""
}
suffix := "." + zoneFqdn
return strings.TrimSuffix(name, suffix)
}
func (c *Client) axfrTransfer(ctx context.Context, zone string) ([]dns.RR, error) {
key, err := c.createTSIGKey()
if err != nil {
return nil, fmt.Errorf("creating TSIG key: %w", err)
}
msg := new(dns.Msg)
msg.SetAxfr(dns.Fqdn(zone))
msg.SetTsig(key.Hdr.Name, key.Algorithm, tsigDefaultTimeout, time.Now().Unix())
transfer := &dns.Transfer{
DialTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
}
transfer.TsigSecret = map[string]string{key.Hdr.Name: c.keySecret}
envChan, err := transfer.In(msg, c.serverAddress())
if err != nil {
return nil, fmt.Errorf("requesting AXFR: %w", err)
}
var records []dns.RR
for env := range envChan {
if env.Error != nil {
return nil, fmt.Errorf("AXFR envelope error: %w", env.Error)
}
records = append(records, env.RR...)
}
return records, nil
}
// UpdateRecords updates DNS records for the given machines
func (c *Client) UpdateRecords(ctx context.Context, records []DNSRecord, dryRun bool) error {
c.init()
if dryRun {
klog.Infof("DRY RUN: Would update %d DNS records", len(records))
for _, record := range records {
if record.Type == "PTR" {
klog.V(1).Infof("DRY RUN: Would create/update PTR record %s -> %s (TTL: %d)",
record.Name, record.Value, record.TTL)
} else {
klog.V(1).Infof("DRY RUN: Would create/update A record %s.%s -> %s (TTL: %d)",
record.Name, c.zone, record.Value, record.TTL)
action := "create/update"
if record.Delete {
action = "delete"
}
switch record.Type {
case "PTR":
klog.V(1).Infof("DRY RUN: Would %s PTR record %s -> %s (TTL: %d)",
action, record.Name, record.Value, record.TTL)
case "AAAA":
klog.V(1).Infof("DRY RUN: Would %s AAAA record %s.%s -> %s (TTL: %d)",
action, record.Name, c.zone, record.Value, record.TTL)
default:
klog.V(1).Infof("DRY RUN: Would %s A record %s.%s -> %s (TTL: %d)",
action, record.Name, c.zone, record.Value, record.TTL)
}
}
return nil
@@ -163,7 +350,11 @@ func (c *Client) sendZoneUpdate(ctx context.Context, zone string, records []DNSR
for _, record := range records {
if record.Type == "PTR" {
// Handle PTR records
klog.V(1).Infof("Processing PTR record: %s -> %s", record.Name, record.Value)
if record.Delete {
klog.V(1).Infof("Deleting PTR record: %s", record.Name)
} else {
klog.V(1).Infof("Processing PTR record: %s -> %s", record.Name, record.Value)
}
// Remove any existing PTR record for this name
rrset := &dns.PTR{
@@ -176,6 +367,10 @@ func (c *Client) sendZoneUpdate(ctx context.Context, zone string, records []DNSR
}
msg.RemoveRRset([]dns.RR{rrset})
if record.Delete {
continue
}
// Add new PTR record
ptrRecord := &dns.PTR{
Hdr: dns.RR_Header{
@@ -189,8 +384,11 @@ func (c *Client) sendZoneUpdate(ctx context.Context, zone string, records []DNSR
msg.Insert([]dns.RR{ptrRecord})
} else {
// Handle A/AAAA records (default)
klog.V(1).Infof("Processing %s record: %s.%s -> %s", record.Type, record.Name, zone, record.Value)
action := "Processing"
if record.Delete {
action = "Deleting"
}
klog.V(1).Infof("%s %s record: %s.%s -> %s", action, record.Type, record.Name, zone, record.Value)
// Remove any existing A/AAAA record for this name
var rrset dns.RR
@@ -215,6 +413,10 @@ func (c *Client) sendZoneUpdate(ctx context.Context, zone string, records []DNSR
}
msg.RemoveRRset([]dns.RR{rrset})
if record.Delete {
continue
}
// Add new A/AAAA record
var newRecord dns.RR
if record.Type == "AAAA" {
+76
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/aauren/tailscale-bind-ddns/pkg/config"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
)
@@ -360,6 +361,72 @@ func TestClientFields(t *testing.T) {
assert.Equal(t, uint32(300), client.ttl)
}
func TestFetchInventoryForwardAndPTR(t *testing.T) {
client := &Client{
server: "dns.example.com",
port: 53,
zone: "tail.example.com",
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
ptrConfig: &config.PTRConfig{
Enabled: true,
IPv4Zone: "64.100.in-addr.arpa",
IPv6Enabled: true,
IPv6Zone: "0.e.1.a.1.c.5.1.1.a.7.d.f.ip6.arpa",
},
}
axfrData := map[string][]dns.RR{
"tail.example.com": {
mustRR(t, "host1.tail.example.com. 300 IN A 100.64.0.1"),
mustRR(t, "host2.tail.example.com. 200 IN AAAA fd7a:115c:a1e0::2"),
mustRR(t, "tail.example.com. 3600 IN SOA ns tail 1 2 3 4 5"),
},
"64.100.in-addr.arpa": {
mustRR(t, "1.0.64.100.in-addr.arpa. 300 IN PTR host1.tail.example.com."),
},
"0.e.1.a.1.c.5.1.1.a.7.d.f.ip6.arpa": {
mustRR(t, "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0.0.0.0.0.0.0.0.0.5.c.1.5. 150 IN PTR host2.tail.example.com."),
},
}
client.axfr = func(ctx context.Context, zone string) ([]dns.RR, error) {
if records, ok := axfrData[zone]; ok {
return records, nil
}
return nil, fmt.Errorf("unexpected zone %s", zone)
}
inventory, err := client.FetchInventory(context.Background())
assert.NoError(t, err)
assert.ElementsMatch(t, []DNSRecord{
{Name: "host1", Value: "100.64.0.1", TTL: 300, Type: "A"},
{Name: "host2", Value: "fd7a:115c:a1e0::2", TTL: 200, Type: "AAAA"},
{Name: "1.0.64.100.in-addr.arpa.", Value: "host1.tail.example.com", TTL: 300, Type: "PTR"},
{Name: "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0.0.0.0.0.0.0.0.0.5.c.1.5.", Value: "host2.tail.example.com", TTL: 150, Type: "PTR"},
}, inventory)
}
func TestFetchInventoryError(t *testing.T) {
client := &Client{
server: "dns.example.com",
port: 53,
zone: "tail.example.com",
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
}
client.axfr = func(ctx context.Context, zone string) ([]dns.RR, error) {
return nil, fmt.Errorf("zone transfer failed")
}
_, err := client.FetchInventory(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "zone transfer failed")
}
func TestCreatePTRRecord(t *testing.T) {
tests := []struct {
name string
@@ -768,3 +835,12 @@ func TestExtractIPv6ZoneFromPTRName(t *testing.T) {
})
}
}
func mustRR(t *testing.T, record string) dns.RR {
t.Helper()
rr, err := dns.NewRR(record)
if err != nil {
t.Fatalf("failed to parse RR %q: %v", record, err)
}
return rr
}