diff --git a/README.md b/README.md index f6b0234..e4508b7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pkg/app/app.go b/pkg/app/app.go index e0f5463..02a7d7e 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -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 diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 910f666..90fdf94 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -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) diff --git a/pkg/bind/client.go b/pkg/bind/client.go index 1353f2a..48edf2f 100644 --- a/pkg/bind/client.go +++ b/pkg/bind/client.go @@ -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" { diff --git a/pkg/bind/client_test.go b/pkg/bind/client_test.go index b9f0bb1..b1ab6a2 100644 --- a/pkg/bind/client_test.go +++ b/pkg/bind/client_test.go @@ -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 +}