// Package synthetic implements a CoreDNS plugin that generates DNS records // from IP addresses embedded in hostnames, inspired by dnsmasq's "synth-domain" // option. It provides automatic alignment between forward (A/AAAA) and reverse // (PTR) lookups, eliminating a common class of DNS misconfigurations described // in RFC 1912 Section 2.1. // // Forward lookups extract an IP address from a prefixed hostname // (e.g., ip-192-0-2-1.example.com -> 192.0.2.1) and respond only if the // address falls within a configured network. Reverse lookups generate a // synthetic PTR record, but defer to the next plugin in the chain when it // returns a successful answer, allowing static zone entries to take precedence. package synthetic import ( "context" "encoding/hex" "net" "strings" "github.com/coredns/coredns/plugin" "github.com/coredns/coredns/plugin/pkg/dnstest" clog "github.com/coredns/coredns/plugin/pkg/log" "github.com/coredns/coredns/plugin/test" "github.com/coredns/coredns/request" "github.com/miekg/dns" ) var log = clog.NewWithPlugin("synthetic") // synthetic is a CoreDNS plugin that resolves DNS queries for hostnames // containing embedded IP addresses. type synthetic struct { Next plugin.Handler Config syntheticConfig } // Name returns the name of the plugin as registered with CoreDNS. func (s synthetic) Name() string { return "synthetic" } // ServeDNS implements the plugin.Handler interface. It processes A, AAAA, and // PTR queries, generating synthetic responses for IP-embedded hostnames while // deferring all other query types to the next plugin in the chain. func (s synthetic) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { state := request.Request{W: w, Req: r} log.Debug("Received request for ", state.QName(), " of type ", state.QType()) // Forward lookups (A/AAAA): respond directly when the hostname embeds // a valid IP within a configured network. No chain continuation needed. if state.QType() == dns.TypeA || state.QType() == dns.TypeAAAA { if rcode, handled := s.handleForward(state, w, r); handled { return rcode, nil } } // For all other query types (including PTR), invoke the next plugin first. // A successful response from the chain takes priority, ensuring that // static zone entries always win over synthetic records. rec := dnstest.NewRecorder(&test.ResponseWriter{}) rc, err := plugin.NextOrFailure(s.Name(), s.Next, ctx, rec, r) if rc == dns.RcodeSuccess && len(rec.Msg.Answer) > 0 { log.Debug("Next plugin returned authoritative answer; skipping synthetic response") w.WriteMsg(rec.Msg) return rc, err } // Reverse lookups (PTR): generate a synthetic hostname from the queried // address when the chain did not provide a definitive answer. if state.QType() == dns.TypePTR { if rcode, handled := s.handleReverse(state, w, r); handled { return rcode, nil } } // Fall back to whatever the chain produced. log.Debug("Synthetic plugin not applicable for ", state.QName(), " of type ", state.QType()) w.WriteMsg(rec.Msg) return rc, err } // handleForward attempts to resolve an A or AAAA query from an IP-embedded // hostname. It returns the DNS rcode and true if the request was handled, or // (0, false) if the request should fall through to the plugin chain. func (s synthetic) handleForward(state request.Request, w dns.ResponseWriter, r *dns.Msg) (int, bool) { if !strings.HasPrefix(state.Name(), s.Config.prefix) { return 0, false } log.Debug("Possible synthetic response for: ", state.QName()) // Extract the IP address from the hostname label. IPv4 separators are // dots converted to dashes; IPv6 separators are colons converted to dashes. label := strings.Split(state.Name(), ".")[0] ipStr := strings.TrimPrefix(label, s.Config.prefix) ip := net.ParseIP(strings.ReplaceAll(ipStr, "-", ".")) if ip == nil { ip = net.ParseIP(strings.ReplaceAll(ipStr, "-", ":")) } if ip == nil { log.Debug("Invalid IP from hostname: ", state.QName()) return 0, false } log.Debug("Valid IP from hostname: ", ip) // Verify the parsed IP falls within at least one configured network. if !s.containsIP(ip) { log.Debug("IP not in a configured network: ", ip) return 0, false } log.Debug("IP ", ip, " is in synthetic network") // Build a response appropriate to the query type and address family. isV4 := ip.To4() != nil switch { case !isV4 && state.QType() == dns.TypeAAAA: return s.writeAnswer(state, w, r, &dns.AAAA{ Hdr: s.rrHeader(state), AAAA: ip.To16(), }), true case isV4 && state.QType() == dns.TypeA: return s.writeAnswer(state, w, r, &dns.A{ Hdr: s.rrHeader(state), A: ip.To4(), }), true default: // Type mismatch (e.g., AAAA query for an IPv4 address). Return an // empty success response to prevent further lookups. log.Debug("Type mismatch for ", state.QName(), "; returning empty answer") return s.writeEmpty(w, r), true } } // handleReverse attempts to resolve a PTR query by constructing a synthetic // forward hostname from the queried reverse address. It returns the DNS rcode // and true if the request was handled. func (s synthetic) handleReverse(state request.Request, w dns.ResponseWriter, r *dns.Msg) (int, bool) { log.Debug("Attempting synthetic reverse response for: ", state.QName()) ip := inArpaToIP(state.QName()) if ip == nil { return 0, false } forward := ipToDomainName(s.Config.prefix, ip, s.Config.forward) log.Debug("Responding to PTR request for ", state.QName(), " with ", forward) return s.writeAnswer(state, w, r, &dns.PTR{ Hdr: s.rrHeader(state), Ptr: forward, }), true } // containsIP reports whether ip falls within any of the configured networks. func (s synthetic) containsIP(ip net.IP) bool { for _, n := range s.Config.net { if n.Contains(ip) { return true } } return false } // rrHeader constructs a dns.RR_Header from the current request state and the // plugin's configured TTL. func (s synthetic) rrHeader(state request.Request) dns.RR_Header { return dns.RR_Header{ Name: state.QName(), Rrtype: state.QType(), Class: state.QClass(), Ttl: s.Config.ttl, } } // writeAnswer writes a single-record DNS response to the client. func (s synthetic) writeAnswer(state request.Request, w dns.ResponseWriter, r *dns.Msg, rr dns.RR) int { m := new(dns.Msg) m.SetReply(r) m.Answer = append(m.Answer, rr) w.WriteMsg(m) return dns.RcodeSuccess } // writeEmpty writes an empty success response (NOERROR with no answer section). func (s synthetic) writeEmpty(w dns.ResponseWriter, r *dns.Msg) int { m := new(dns.Msg) m.SetReply(r) w.WriteMsg(m) return dns.RcodeSuccess } // inArpaToIP converts a reverse DNS name (in-addr.arpa or ip6.arpa) to the // corresponding net.IP. It returns nil if the name cannot be parsed. func inArpaToIP(name string) net.IP { const ( ipv4Suffix = ".in-addr.arpa." ipv6Suffix = ".ip6.arpa." ) // IPv4 reverse: four dot-separated octets in reverse order. if idx := strings.Index(name, ipv4Suffix); idx > 6 { parts := strings.Split(name[:idx], ".") if len(parts) != 4 { return nil } reversed := parts[3] + "." + parts[2] + "." + parts[1] + "." + parts[0] return net.ParseIP(reversed) } // IPv6 reverse: exactly 73 characters — 32 hex nibbles separated by dots // in reverse order, followed by ".ip6.arpa.". if len(name) == 73 && name[63:] == ipv6Suffix { nibbles := make([]byte, 32) for i, j := 62, 0; i >= 0; i -= 2 { nibbles[j] = name[i] j++ } addr := make([]byte, 16) if _, err := hex.Decode(addr, nibbles); err != nil { return nil } return net.IP(addr) } return nil } // ipToDomainName converts an IP address to a synthetic forward hostname by // joining the address octets/groups with dashes and appending the zone suffix. // For example, 192.0.2.1 with prefix "ip-" and zone "example.com" becomes // "ip-192-0-2-1.example.com.". func ipToDomainName(prefix string, ip net.IP, zone string) string { if ip == nil { return "" } if !strings.HasPrefix(zone, ".") { zone = "." + zone } if !strings.HasSuffix(zone, ".") { zone = zone + "." } sep := ":" if ip.To4() != nil { sep = "." } return prefix + strings.Join(strings.Split(ip.String(), sep), "-") + zone }