mirror of
https://github.com/aauren/tailscale-bind-ddns.git
synced 2026-10-24 04:42:22 +00:00
feat: add record deletions for removed clients
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user