Files
tailscale-bind-ddns/pkg/bind/client_test.go
2025-11-30 21:53:21 -06:00

847 lines
18 KiB
Go

package bind
import (
"context"
"fmt"
"net"
"testing"
"time"
"github.com/aauren/tailscale-bind-ddns/pkg/config"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
)
func TestNewClient(t *testing.T) {
tests := []struct {
name string
server string
port int
zone string
keyName string
keySecret string
algorithm string
ttl time.Duration
wantErr bool
wantError string
}{
{
name: "valid client",
server: "dns.example.com",
port: 53,
zone: "test.example.com",
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
ttl: 300 * time.Second,
wantErr: false,
},
{
name: "missing server",
server: "",
port: 53,
zone: "test.example.com",
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
ttl: 300 * time.Second,
wantErr: true,
wantError: "server is required",
},
{
name: "missing zone",
server: "dns.example.com",
port: 53,
zone: "",
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
ttl: 300 * time.Second,
wantErr: true,
wantError: "zone is required",
},
{
name: "missing key name",
server: "dns.example.com",
port: 53,
zone: "test.example.com",
keyName: "",
keySecret: "test-secret",
algorithm: "hmac-sha256",
ttl: 300 * time.Second,
wantErr: true,
wantError: "key name is required",
},
{
name: "missing key secret",
server: "dns.example.com",
port: 53,
zone: "test.example.com",
keyName: "test-key",
keySecret: "",
algorithm: "hmac-sha256",
ttl: 300 * time.Second,
wantErr: true,
wantError: "key secret is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewClient(tt.server, tt.port, tt.zone, tt.keyName, tt.keySecret, tt.algorithm, tt.ttl, nil)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, client)
if tt.wantError != "" {
assert.Contains(t, err.Error(), tt.wantError)
}
} else {
assert.NoError(t, err)
assert.NotNil(t, client)
assert.Equal(t, tt.server, client.server)
assert.Equal(t, tt.port, client.port)
assert.Equal(t, tt.zone, client.zone)
assert.Equal(t, tt.keyName, client.keyName)
assert.Equal(t, tt.keySecret, client.keySecret)
assert.Equal(t, tt.algorithm, client.algorithm)
assert.Equal(t, uint32(tt.ttl.Seconds()), client.ttl)
}
})
}
}
func TestCreateTSIGKey(t *testing.T) {
client := &Client{
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
}
tests := []struct {
name string
algorithm string
wantErr bool
wantError string
}{
{
name: "valid hmac-sha256",
algorithm: "hmac-sha256",
wantErr: false,
},
{
name: "valid hmac-sha1",
algorithm: "hmac-sha1",
wantErr: false,
},
{
name: "valid hmac-md5",
algorithm: "hmac-md5",
wantErr: false,
},
{
name: "valid hmac-sha384",
algorithm: "hmac-sha384",
wantErr: false,
},
{
name: "valid hmac-sha512",
algorithm: "hmac-sha512",
wantErr: false,
},
{
name: "invalid algorithm",
algorithm: "invalid-algorithm",
wantErr: true,
wantError: "unsupported TSIG algorithm",
},
{
name: "empty algorithm uses default",
algorithm: "",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client.algorithm = tt.algorithm
key, err := client.createTSIGKey()
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, key)
if tt.wantError != "" {
assert.Contains(t, err.Error(), tt.wantError)
}
} else {
assert.NoError(t, err)
assert.NotNil(t, key)
assert.Equal(t, client.keyName, key.Hdr.Name)
expectedAlgorithm := tt.algorithm
if expectedAlgorithm == "" {
expectedAlgorithm = "hmac-sha256."
} else {
expectedAlgorithm = fmt.Sprintf("%s.", tt.algorithm)
}
assert.Equal(t, expectedAlgorithm, key.Algorithm)
}
})
}
}
func TestUpdateRecordsDryRun(t *testing.T) {
client := &Client{
server: "dns.example.com",
port: 53,
zone: "test.example.com",
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
ttl: 300,
}
records := []DNSRecord{
{
Name: "machine1",
Value: "100.64.1.1",
TTL: 300,
},
{
Name: "machine2",
Value: "100.64.1.2",
TTL: 300,
},
}
ctx := context.Background()
err := client.UpdateRecords(ctx, records, true) // dry run = true
// Dry run should not return an error
assert.NoError(t, err)
}
func TestUpdateRecordsEmpty(t *testing.T) {
client := &Client{
server: "dns.example.com",
port: 53,
zone: "test.example.com",
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
ttl: 300,
}
ctx := context.Background()
err := client.UpdateRecords(ctx, []DNSRecord{}, false)
// Empty records should not return an error
assert.NoError(t, err)
}
func TestDNSRecordValidation(t *testing.T) {
tests := []struct {
name string
record DNSRecord
wantErr bool
}{
{
name: "valid IPv4 record",
record: DNSRecord{
Name: "test.example.com",
Value: "192.168.1.1",
TTL: 300,
},
wantErr: false,
},
{
name: "valid IPv6 record",
record: DNSRecord{
Name: "test.example.com",
Value: "2001:db8::1",
TTL: 300,
},
wantErr: false,
},
{
name: "invalid IP address",
record: DNSRecord{
Name: "test.example.com",
Value: "invalid-ip",
TTL: 300,
},
wantErr: true,
},
{
name: "empty name",
record: DNSRecord{
Name: "",
Value: "192.168.1.1",
TTL: 300,
},
wantErr: false, // Empty name is valid, will be handled by DNS library
},
{
name: "zero TTL",
record: DNSRecord{
Name: "test.example.com",
Value: "192.168.1.1",
TTL: 0,
},
wantErr: false, // Zero TTL is valid
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test IP address parsing
ip := net.ParseIP(tt.record.Value)
if tt.wantErr {
assert.Nil(t, ip, "Expected invalid IP address")
} else {
assert.NotNil(t, ip, "Expected valid IP address")
}
})
}
}
func TestStartUpdating(t *testing.T) {
client := &Client{
server: "dns.example.com",
port: 53,
zone: "test.example.com",
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
ttl: 300,
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
recordChan := make(chan []DNSRecord, 1)
// Send some test records
testRecords := []DNSRecord{
{
Name: "test-machine",
Value: "100.64.1.1",
TTL: 300,
},
}
go func() {
recordChan <- testRecords
}()
// Start updating with a very short interval
go client.StartUpdating(ctx, 10*time.Millisecond, recordChan, true) // dry run
// Wait for context cancellation
<-ctx.Done()
}
func TestClientFields(t *testing.T) {
client := &Client{
server: "dns.example.com",
port: 53,
zone: "test.example.com",
keyName: "test-key",
keySecret: "test-secret",
algorithm: "hmac-sha256",
ttl: 300,
}
assert.Equal(t, "dns.example.com", client.server)
assert.Equal(t, 53, client.port)
assert.Equal(t, "test.example.com", client.zone)
assert.Equal(t, "test-key", client.keyName)
assert.Equal(t, "test-secret", client.keySecret)
assert.Equal(t, "hmac-sha256", client.algorithm)
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
ipStr string
hostname string
ptrConfig *config.PTRConfig
wantRecord bool
wantErr bool
wantError string
}{
{
name: "IPv4 PTR record enabled",
ipStr: "100.64.1.1",
hostname: "test.example.com",
ptrConfig: &config.PTRConfig{
Enabled: true,
IPv4Zone: "64.100.in-addr.arpa",
IPv4Subnet: "100.64.0.0/10",
},
wantRecord: true,
wantErr: false,
},
{
name: "IPv4 PTR record disabled",
ipStr: "100.64.1.1",
hostname: "test.example.com",
ptrConfig: &config.PTRConfig{
Enabled: false,
},
wantRecord: false,
wantErr: false,
},
{
name: "IPv4 address not in subnet",
ipStr: "192.168.1.1",
hostname: "test.example.com",
ptrConfig: &config.PTRConfig{
Enabled: true,
IPv4Zone: "64.100.in-addr.arpa",
IPv4Subnet: "100.64.0.0/10",
},
wantRecord: false,
wantErr: false,
},
{
name: "IPv6 PTR record enabled",
ipStr: "fd7a:115c:a1e0::1",
hostname: "test.example.com",
ptrConfig: &config.PTRConfig{
Enabled: true,
IPv6Enabled: true,
IPv6Zone: "0.e.1.a.1.c.5.1.1.a.7.d.f.ip6.arpa",
IPv6Subnet: "fd7a:115c:a1e0::/64",
},
wantRecord: true,
wantErr: false,
},
{
name: "IPv6 PTR record disabled",
ipStr: "fd7a:115c:a1e0::1",
hostname: "test.example.com",
ptrConfig: &config.PTRConfig{
Enabled: true,
IPv6Enabled: false,
},
wantRecord: false,
wantErr: false,
},
{
name: "Invalid IP address",
ipStr: "invalid-ip",
hostname: "test.example.com",
ptrConfig: &config.PTRConfig{
Enabled: true,
},
wantRecord: false,
wantErr: true,
wantError: "invalid IP address",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &Client{
ptrConfig: tt.ptrConfig,
ttl: 300,
}
record, err := client.CreatePTRRecord(tt.ipStr, tt.hostname)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, record)
if tt.wantError != "" {
assert.Contains(t, err.Error(), tt.wantError)
}
} else {
assert.NoError(t, err)
if tt.wantRecord {
assert.NotNil(t, record)
assert.Equal(t, "PTR", record.Type)
assert.Equal(t, tt.hostname, record.Value)
assert.Equal(t, uint32(300), record.TTL)
} else {
assert.Nil(t, record)
}
}
})
}
}
func TestIsIPInSubnet(t *testing.T) {
tests := []struct {
name string
ipStr string
subnet string
want bool
}{
{
name: "IPv4 in subnet",
ipStr: "100.64.1.1",
subnet: "100.64.0.0/10",
want: true,
},
{
name: "IPv4 not in subnet",
ipStr: "192.168.1.1",
subnet: "100.64.0.0/10",
want: false,
},
{
name: "IPv6 in subnet",
ipStr: "fd7a:115c:a1e0::1",
subnet: "fd7a:115c:a1e0::/64",
want: true,
},
{
name: "IPv6 not in subnet",
ipStr: "2001:db8::1",
subnet: "fd7a:115c:a1e0::/64",
want: false,
},
{
name: "Invalid IP",
ipStr: "invalid-ip",
subnet: "100.64.0.0/10",
want: false,
},
{
name: "Empty subnet",
ipStr: "100.64.1.1",
subnet: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isIPInSubnet(tt.ipStr, tt.subnet)
assert.Equal(t, tt.want, result)
})
}
}
func TestIPv6ToReverseDNS(t *testing.T) {
tests := []struct {
name string
ipStr string
want string
wantErr bool
}{
{
name: "Valid IPv6",
ipStr: "2001:db8::1",
want: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
},
{
name: "Invalid IPv6",
ipStr: "invalid-ipv6",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ipv6ToReverseDNS(tt.ipStr)
if tt.wantErr {
assert.Empty(t, result)
} else {
assert.Equal(t, tt.want, result)
}
})
}
}
func TestGenerateIPv4PTRZone(t *testing.T) {
tests := []struct {
name string
ipStr string
subnetSize int
want string
wantErr bool
}{
{
name: "IPv4 /8 subnet",
ipStr: "100.64.0.1",
subnetSize: 8,
want: "100.in-addr.arpa",
wantErr: false,
},
{
name: "IPv4 /16 subnet",
ipStr: "100.64.0.1",
subnetSize: 16,
want: "64.100.in-addr.arpa",
wantErr: false,
},
{
name: "IPv4 /24 subnet",
ipStr: "100.64.0.1",
subnetSize: 24,
want: "0.64.100.in-addr.arpa",
wantErr: false,
},
{
name: "Invalid subnet size",
ipStr: "100.64.0.1",
subnetSize: 32,
want: "",
wantErr: true,
},
{
name: "Invalid IP address",
ipStr: "invalid",
subnetSize: 16,
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &Client{}
result, err := client.generateIPv4PTRZone(tt.ipStr, tt.subnetSize)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.want, result)
}
})
}
}
func TestGenerateIPv6PTRZone(t *testing.T) {
tests := []struct {
name string
ipStr string
subnetSize int
want string
wantErr bool
}{
{
name: "IPv6 /32 subnet",
ipStr: "2001:db8::1",
subnetSize: 32,
want: "8.b.d.0.1.0.0.2.ip6.arpa",
wantErr: false,
},
{
name: "IPv6 /48 subnet",
ipStr: "2001:db8::1",
subnetSize: 48,
want: "0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa",
wantErr: false,
},
{
name: "IPv6 /64 subnet",
ipStr: "2001:db8::1",
subnetSize: 64,
want: "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa",
wantErr: false,
},
{
name: "Invalid subnet size",
ipStr: "2001:db8::1",
subnetSize: 128,
want: "",
wantErr: true,
},
{
name: "Invalid IPv6 address",
ipStr: "192.168.1.1",
subnetSize: 64,
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &Client{}
result, err := client.generateIPv6PTRZone(tt.ipStr, tt.subnetSize)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.want, result)
}
})
}
}
func TestExtractIPv4ZoneFromPTRName(t *testing.T) {
tests := []struct {
name string
ptrName string
subnetSize int
want string
}{
{
name: "IPv4 /8 PTR name",
ptrName: "1.0.64.100.in-addr.arpa.",
subnetSize: 8,
want: "100.in-addr.arpa",
},
{
name: "IPv4 /16 PTR name",
ptrName: "1.0.64.100.in-addr.arpa.",
subnetSize: 16,
want: "64.100.in-addr.arpa",
},
{
name: "IPv4 /24 PTR name",
ptrName: "1.0.64.100.in-addr.arpa.",
subnetSize: 24,
want: "0.64.100.in-addr.arpa",
},
{
name: "Invalid PTR name",
ptrName: "invalid",
subnetSize: 16,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &Client{
ptrConfig: &config.PTRConfig{
Enabled: true,
IPv4SubnetSize: tt.subnetSize,
},
}
result := client.extractIPv4ZoneFromPTRName(tt.ptrName)
assert.Equal(t, tt.want, result)
})
}
}
func TestExtractIPv6ZoneFromPTRName(t *testing.T) {
tests := []struct {
name string
ptrName string
subnetSize int
want string
}{
{
name: "IPv6 /32 PTR name",
ptrName: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
subnetSize: 32,
want: "8.b.d.0.1.0.0.2.ip6.arpa",
},
{
name: "IPv6 /48 PTR name",
ptrName: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
subnetSize: 48,
want: "0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa",
},
{
name: "IPv6 /64 PTR name",
ptrName: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
subnetSize: 64,
want: "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa",
},
{
name: "Invalid PTR name",
ptrName: "invalid",
subnetSize: 64,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &Client{
ptrConfig: &config.PTRConfig{
Enabled: true,
IPv6Enabled: true,
IPv6SubnetSize: tt.subnetSize,
},
}
result := client.extractIPv6ZoneFromPTRName(tt.ptrName)
assert.Equal(t, tt.want, result)
})
}
}
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
}