package bind import ( "context" "fmt" "net" "slices" "strings" "time" "github.com/aauren/tailscale-bind-ddns/pkg/config" "github.com/miekg/dns" "k8s.io/klog/v2" ) // IPv4 subnet size constants const ( IPv4Subnet8 = 8 // /8 subnet IPv4Subnet16 = 16 // /16 subnet IPv4Subnet24 = 24 // /24 subnet ) // IPv6 subnet size constants const ( IPv6Subnet32 = 32 // /32 subnet IPv6Subnet48 = 48 // /48 subnet IPv6Subnet64 = 64 // /64 subnet ) // IPv6 nibble constants const ( IPv6NibbleMaskLow = 0x0f // Low nibble mask IPv6NibbleMaskHigh = 0xf0 // High nibble mask 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 port int zone string keyName string keySecret string algorithm string ttl uint32 // PTR configuration ptrConfig *config.PTRConfig axfr axfrFunc } // DNSRecord represents a DNS record (A or PTR) type DNSRecord struct { Name string Value string TTL uint32 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 func NewClient( server string, port int, zone, keyName, keySecret, algorithm string, ttl time.Duration, ptrConfig *config.PTRConfig, ) (*Client, error) { if server == "" { return nil, fmt.Errorf("server is required") } if zone == "" { return nil, fmt.Errorf("zone is required") } if keyName == "" { return nil, fmt.Errorf("key name is required") } if keySecret == "" { return nil, fmt.Errorf("key secret is required") } client := &Client{ server: server, port: port, zone: zone, keyName: keyName, keySecret: keySecret, algorithm: algorithm, ttl: uint32(ttl.Seconds()), ptrConfig: ptrConfig, } 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 { 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 } if len(records) == 0 { klog.V(1).Info("No records to update") return nil } klog.Infof("Updating %d DNS records", len(records)) // Group records by zone recordsByZone := make(map[string][]DNSRecord) for _, record := range records { var zone string if record.Type == "PTR" { // For PTR records, determine the zone dynamically based on the record name if strings.Contains(record.Name, ".in-addr.arpa.") { // IPv4 PTR record - extract zone from the record name zone = c.extractIPv4ZoneFromPTRName(record.Name) } else if strings.Contains(record.Name, ".ip6.arpa.") { // IPv6 PTR record - extract zone from the record name zone = c.extractIPv6ZoneFromPTRName(record.Name) } } else { // A/AAAA records go to the main zone zone = c.zone } if zone != "" { recordsByZone[zone] = append(recordsByZone[zone], record) } } // Create TSIG key key, err := c.createTSIGKey() if err != nil { return fmt.Errorf("creating TSIG key: %w", err) } // Send updates for each zone for zone, zoneRecords := range recordsByZone { klog.V(1).Infof("Sending %d records to zone %s", len(zoneRecords), zone) if err := c.sendZoneUpdate(ctx, zone, zoneRecords, key); err != nil { return fmt.Errorf("sending update to zone %s: %w", zone, err) } } return nil } // sendZoneUpdate sends DNS updates for a specific zone func (c *Client) sendZoneUpdate(ctx context.Context, zone string, records []DNSRecord, key *dns.TSIG) error { // Create dynamic update message for this zone msg := new(dns.Msg) msg.SetUpdate(dns.Fqdn(zone)) // Add records to the update message klog.V(2).Infof("Adding %d records to zone %s", len(records), zone) for _, record := range records { if record.Type == "PTR" { // Handle PTR records 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{ Hdr: dns.RR_Header{ Name: dns.Fqdn(record.Name), Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: 0, // TTL 0 for removal }, } msg.RemoveRRset([]dns.RR{rrset}) if record.Delete { continue } // Add new PTR record ptrRecord := &dns.PTR{ Hdr: dns.RR_Header{ Name: dns.Fqdn(record.Name), Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: record.TTL, }, Ptr: dns.Fqdn(record.Value), } msg.Insert([]dns.RR{ptrRecord}) } else { 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 if record.Type == "AAAA" { rrset = &dns.AAAA{ Hdr: dns.RR_Header{ Name: dns.Fqdn(record.Name + "." + zone), Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 0, // TTL 0 for removal }, } } else { rrset = &dns.A{ Hdr: dns.RR_Header{ Name: dns.Fqdn(record.Name + "." + zone), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0, // TTL 0 for removal }, } } msg.RemoveRRset([]dns.RR{rrset}) if record.Delete { continue } // Add new A/AAAA record var newRecord dns.RR if record.Type == "AAAA" { newRecord = &dns.AAAA{ Hdr: dns.RR_Header{ Name: dns.Fqdn(record.Name + "." + zone), Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: record.TTL, }, AAAA: net.ParseIP(record.Value), } } else { newRecord = &dns.A{ Hdr: dns.RR_Header{ Name: dns.Fqdn(record.Name + "." + zone), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: record.TTL, }, A: net.ParseIP(record.Value), } } msg.Insert([]dns.RR{newRecord}) } } // Sign the message with TSIG (300 seconds timeout) const tsigTimeout = 300 msg.SetTsig(key.Hdr.Name, key.Algorithm, tsigTimeout, time.Now().Unix()) // Send the update klog.V(2).Infof("Sending DNS update message to zone %s: %s", zone, msg.String()) klog.V(2).Infof("Message Question Section: %v", msg.Question) client := new(dns.Client) client.TsigSecret = map[string]string{key.Hdr.Name: c.keySecret} response, _, err := client.ExchangeContext(ctx, msg, net.JoinHostPort(c.server, fmt.Sprintf("%d", c.port))) if err != nil { return fmt.Errorf("sending DNS update: %w", err) } if response.Rcode != dns.RcodeSuccess { return fmt.Errorf("DNS update failed with Rcode %d: %s", response.Rcode, dns.RcodeToString[response.Rcode]) } klog.V(1).Infof("Successfully updated %d records in zone %s", len(records), zone) return nil } // createTSIGKey creates a TSIG key for authentication func (c *Client) createTSIGKey() (*dns.TSIG, error) { algorithm := c.algorithm if algorithm == "" { algorithm = "hmac-sha256" } // Validate algorithm switch algorithm { case "hmac-md5", "hmac-sha1", "hmac-sha256", "hmac-sha384", "hmac-sha512": // Valid algorithms default: return nil, fmt.Errorf("unsupported TSIG algorithm: %s", algorithm) } return &dns.TSIG{ Hdr: dns.RR_Header{ Name: c.keyName, Rrtype: dns.TypeTSIG, Class: dns.ClassANY, }, Algorithm: fmt.Sprintf("%s.", algorithm), }, nil } // ValidateConnection tests the connection to the Bind server func (c *Client) ValidateConnection(ctx context.Context) error { klog.V(1).Infof("Validating connection to Bind server %s:%d", c.server, c.port) // Create a simple query to test connectivity msg := new(dns.Msg) msg.SetQuestion(dns.Fqdn(c.zone), dns.TypeSOA) client := new(dns.Client) client.Timeout = 5 * time.Second response, _, err := client.ExchangeContext(ctx, msg, net.JoinHostPort(c.server, fmt.Sprintf("%d", c.port))) if err != nil { return fmt.Errorf("connection test failed: %w", err) } if response.Rcode != dns.RcodeSuccess { return fmt.Errorf("connection test failed with Rcode %d: %s", response.Rcode, dns.RcodeToString[response.Rcode]) } klog.V(1).Info("Successfully validated connection to Bind server") return nil } // StartUpdating starts the DDNS update process func (c *Client) StartUpdating( ctx context.Context, updateInterval time.Duration, recordChan <-chan []DNSRecord, dryRun bool, ) { ticker := time.NewTicker(updateInterval) defer ticker.Stop() klog.Infof("Starting DDNS updates with interval %v", updateInterval) // Process initial records if available select { case records := <-recordChan: if err := c.UpdateRecords(ctx, records, dryRun); err != nil { klog.Errorf("Failed to update initial records: %v", err) } case <-ctx.Done(): return } for { select { case records := <-recordChan: if err := c.UpdateRecords(ctx, records, dryRun); err != nil { klog.Errorf("Failed to update records: %v", err) } case <-ticker.C: // Periodic update - check if there are any pending records select { case records := <-recordChan: if err := c.UpdateRecords(ctx, records, dryRun); err != nil { klog.Errorf("Failed to update records: %v", err) } default: // No records to process } case <-ctx.Done(): klog.Info("DDNS updating stopped") return } } } // isIPInSubnet checks if an IP address is within the specified subnet func isIPInSubnet(ipStr, subnetStr string) bool { if subnetStr == "" { return false } ip := net.ParseIP(ipStr) if ip == nil { return false } _, network, err := net.ParseCIDR(subnetStr) if err != nil { return false } return network.Contains(ip) } // generateIPv4PTRZone generates the PTR zone name for IPv4 based on subnet size func (c *Client) generateIPv4PTRZone(ipStr string, subnetSize int) (string, error) { ip := net.ParseIP(ipStr) if ip == nil || ip.To4() == nil { return "", fmt.Errorf("invalid IPv4 address: %s", ipStr) } ipv4 := ip.To4() switch subnetSize { case IPv4Subnet8: // /8: Use first octet (e.g., 100.64.0.1 -> 100.in-addr.arpa) return fmt.Sprintf("%d.in-addr.arpa", ipv4[0]), nil case IPv4Subnet16: // /16: Use first two octets (e.g., 100.64.0.1 -> 64.100.in-addr.arpa) return fmt.Sprintf("%d.%d.in-addr.arpa", ipv4[1], ipv4[0]), nil case IPv4Subnet24: // /24: Use first three octets (e.g., 100.64.0.1 -> 0.64.100.in-addr.arpa) return fmt.Sprintf("%d.%d.%d.in-addr.arpa", ipv4[2], ipv4[1], ipv4[0]), nil default: return "", fmt.Errorf("unsupported IPv4 subnet size: %d", subnetSize) } } // generateIPv6PTRZone generates the PTR zone name for IPv6 based on subnet size func (c *Client) generateIPv6PTRZone(ipStr string, subnetSize int) (string, error) { ip := net.ParseIP(ipStr) if ip == nil || ip.To4() != nil { return "", fmt.Errorf("invalid IPv6 address: %s", ipStr) } // Convert IPv6 to nibble format nibbles := ipv6ToReverseDNS(ipStr) if nibbles == "" { return "", fmt.Errorf("failed to convert IPv6 to reverse DNS format") } // Remove the trailing dot and .ip6.arpa nibbles = strings.TrimSuffix(nibbles, ".ip6.arpa.") // Calculate how many nibbles to use based on subnet size var nibblesToUse int switch subnetSize { case IPv6Subnet32: nibblesToUse = 8 // /32 = 8 nibbles case IPv6Subnet48: nibblesToUse = 12 // /48 = 12 nibbles case IPv6Subnet64: nibblesToUse = 16 // /64 = 16 nibbles default: return "", fmt.Errorf("unsupported IPv6 subnet size: %d", subnetSize) } // Split nibbles and take the required number from the end (network portion) nibbleParts := strings.Split(nibbles, ".") if len(nibbleParts) < nibblesToUse { return "", fmt.Errorf("not enough nibbles for subnet size %d", subnetSize) } // Take the last nibblesToUse nibbles (network portion) selectedNibbles := nibbleParts[len(nibbleParts)-nibblesToUse:] return strings.Join(selectedNibbles, ".") + ".ip6.arpa", nil } // extractIPv4ZoneFromPTRName extracts the zone name from an IPv4 PTR record name func (c *Client) extractIPv4ZoneFromPTRName(ptrName string) string { if c.ptrConfig == nil || !c.ptrConfig.Enabled { return "" } // Remove trailing dot and .in-addr.arpa name := strings.TrimSuffix(ptrName, ".in-addr.arpa.") name = strings.TrimSuffix(name, ".") // Split by dots to get octets octets := strings.Split(name, ".") if len(octets) != 4 { return "" } // Generate zone based on subnet size switch c.ptrConfig.IPv4SubnetSize { case IPv4Subnet8: // /8: Use first octet (e.g., 4.3.2.1 -> 1.in-addr.arpa) return fmt.Sprintf("%s.in-addr.arpa", octets[3]) case IPv4Subnet16: // /16: Use first two octets (e.g., 4.3.2.1 -> 2.1.in-addr.arpa) return fmt.Sprintf("%s.%s.in-addr.arpa", octets[2], octets[3]) case IPv4Subnet24: // /24: Use first three octets (e.g., 4.3.2.1 -> 3.2.1.in-addr.arpa) return fmt.Sprintf("%s.%s.%s.in-addr.arpa", octets[1], octets[2], octets[3]) default: return "" } } // extractIPv6ZoneFromPTRName extracts the zone name from an IPv6 PTR record name func (c *Client) extractIPv6ZoneFromPTRName(ptrName string) string { if c.ptrConfig == nil || !c.ptrConfig.Enabled || !c.ptrConfig.IPv6Enabled { return "" } // Remove trailing dot and .ip6.arpa name := strings.TrimSuffix(ptrName, ".ip6.arpa.") name = strings.TrimSuffix(name, ".") // Split by dots to get nibbles nibbles := strings.Split(name, ".") if len(nibbles) < 8 { return "" } // Calculate how many nibbles to use based on subnet size var nibblesToUse int switch c.ptrConfig.IPv6SubnetSize { case IPv6Subnet32: nibblesToUse = 8 // /32 = 8 nibbles case IPv6Subnet48: nibblesToUse = 12 // /48 = 12 nibbles case IPv6Subnet64: nibblesToUse = 16 // /64 = 16 nibbles default: return "" } if len(nibbles) < nibblesToUse { return "" } // Take the last nibblesToUse nibbles (network portion) selectedNibbles := nibbles[len(nibbles)-nibblesToUse:] return strings.Join(selectedNibbles, ".") + ".ip6.arpa" } // CreatePTRRecord creates a PTR record for the given IP address and hostname func (c *Client) CreatePTRRecord(ipStr, hostname string) (*DNSRecord, error) { if c.ptrConfig == nil || !c.ptrConfig.Enabled { return nil, nil } ip := net.ParseIP(ipStr) if ip == nil { return nil, fmt.Errorf("invalid IP address: %s", ipStr) } var ptrName string var subnet string if ip.To4() != nil { // IPv4 address if !isIPInSubnet(ipStr, c.ptrConfig.IPv4Subnet) { klog.Warningf("IPv4 address %s is not in configured subnet %s, skipping PTR record", ipStr, c.ptrConfig.IPv4Subnet) return nil, nil } // Create reverse DNS name for IPv4 (e.g., 1.2.3.4 -> 4.3.2.1.in-addr.arpa.) parts := strings.Split(ipStr, ".") if len(parts) != 4 { return nil, fmt.Errorf("invalid IPv4 address format: %s", ipStr) } // Reverse the IP address parts ptrName = fmt.Sprintf("%s.%s.%s.%s.in-addr.arpa.", parts[3], parts[2], parts[1], parts[0]) subnet = c.ptrConfig.IPv4Subnet } else { // IPv6 address if !c.ptrConfig.IPv6Enabled { klog.V(2).Infof("IPv6 PTR records disabled, skipping IPv6 address %s", ipStr) return nil, nil } if !isIPInSubnet(ipStr, c.ptrConfig.IPv6Subnet) { klog.Warningf("IPv6 address %s is not in configured subnet %s, skipping PTR record", ipStr, c.ptrConfig.IPv6Subnet) return nil, nil } // Create reverse DNS name for IPv6 ptrName = ipv6ToReverseDNS(ipStr) subnet = c.ptrConfig.IPv6Subnet } klog.V(2).Infof("Creating PTR record for %s -> %s (subnet: %s)", ptrName, hostname, subnet) return &DNSRecord{ Name: ptrName, Value: hostname, TTL: c.ttl, Type: "PTR", }, nil } // ipv6ToReverseDNS converts an IPv6 address to reverse DNS format func ipv6ToReverseDNS(ipStr string) string { ip := net.ParseIP(ipStr) if ip == nil { return "" } // Convert to 16-byte representation ipv6 := ip.To16() if ipv6 == nil { return "" } // Convert each byte to two nibbles and reverse the order var nibbles []string for _, i := range slices.Backward(ipv6) { nibbles = append(nibbles, fmt.Sprintf("%x", i&IPv6NibbleMaskLow)) nibbles = append(nibbles, fmt.Sprintf("%x", (i&IPv6NibbleMaskHigh)>>IPv6NibbleShift)) } return strings.Join(nibbles, ".") + ".ip6.arpa." }