diff --git a/Dockerfile b/Dockerfile index 4d66aff..b35620c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ -# run go test inside a docker container for consistency as acceptance testing -FROM golang:1.21 +# Dockerfile for CI: runs vet and tests inside a consistent Go environment. +FROM golang:1.25 WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN go test -v +RUN go vet ./... +RUN go test -v ./... diff --git a/README.md b/README.md index 40fd49e..f738046 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,112 @@ # synthetic ------ +A [CoreDNS](https://coredns.io) plugin that generates DNS records from IP addresses embedded in hostnames. -## Overview +Inspired by dnsmasq's [`synth-domain`](http://www.thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html) option, `synthetic` provides automatic alignment between forward and reverse lookups — eliminating a common class of DNS misconfigurations described in [RFC 1912 Section 2.1](https://tools.ietf.org/html/rfc1912#section-2.1). -*synthetic* is a [CoreDNS](http://coredns.io) plugin to synthetically handle DNS records with IP addresses embedded. -Named after DNSMASQ's "synth-domain" [option](http://www.thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html). +## Features -`synthetic` aims to provide an easy mechanism for alignment between forward and reverse lookups. -This is a common DNS operational and configuration error as noted in [RFC1912](https://tools.ietf.org/html/rfc1912#section-2.1). +- **Forward lookups** (A/AAAA) — extract an IP from a prefixed hostname and respond directly +- **Reverse lookups** (PTR) — generate a synthetic hostname from a reverse query +- **Static record priority** — responses from upstream plugins (e.g., `file`) always take precedence over synthetic records +- **Dual-stack** — full IPv4 and IPv6 support, including compressed IPv6 notation +- **Configurable** — custom prefix, TTL, and per-network scoping -This plugin supports works nicely with the file plugin such that records present in the file will take precedence over this plugin. +## How It Works -### Forward Lookups +### Forward Lookups (A / AAAA) -Forward Lookups are hostname -> IP address. -`synthetic` supports IPs "embedded" in the DNS hostname. -For IP addresses embedded in DNS hostnames the general model is `ip-
.example.com` -(where "address" can be either IPv6 or IPv4, and "example.com" is a domain of your choosing). -In IPv6 the colons are converted to a dash; in IPv4 the dots are converted to a dash. +A hostname with an embedded IP address is resolved directly. Dots (IPv4) or colons (IPv6) are replaced with dashes in the hostname label: -The following are all considered valid for AAAA or A queries. +| Hostname | Record | Address | +|---|---|---| +| `ip-192-0-2-1.example.com` | A | `192.0.2.1` | +| `ip-2001-db8-abcd--1.example.com` | AAAA | `2001:db8:abcd::1` | +| `ip-2001-0db8-0000-0000-0000-0000-0000-0001.example.com` | AAAA | `2001:db8::1` | - * `ip-2001-0db8-0000-0000-0000-0000-0000-0001.example.com` - * `ip-2001-db8--1.example.com` - * `ip-192-0-2-0.example.com` +Only addresses within a configured `net` CIDR are resolved. All other queries pass through to the next plugin. -### Reverse Lookups +### Reverse Lookups (PTR) -Reverse Lookups are IP -> hostname, and are known as pointer records (PTR). -`synthetic` will respond to a PTR query and return a result that is also supported by the forward lookup mechanism. -Reverse lookups for IPv6 addresses will return a fully compressed IPv6 address (per [RFC5952](https://tools.ietf.org/html/rfc5952#section-2.2)). +When a PTR query arrives, the plugin first consults the next plugin in the chain. If that plugin provides a successful answer (e.g., from a zone file), it is used as-is. Otherwise, a synthetic PTR record is generated pointing back to the corresponding forward hostname. -## Corefile Configuration Examples +IPv6 reverse responses use the compressed address form per [RFC 5952](https://tools.ietf.org/html/rfc5952#section-2.2). -Reverse Lookup Example +## Configuration + +### Directives + +| Directive | Description | Default | +|---|---|---| +| `net` | CIDR network(s) for which synthetic forward responses are generated. May be specified multiple times. | *(none — required for forward lookups)* | +| `forward` | Domain name appended to synthetic hostnames in PTR responses. | *(none — required for reverse lookups)* | +| `ttl` | Time-to-live for synthetic responses, in seconds. | `0` | +| `prefix` | Hostname label prefix identifying synthetic queries. A trailing dash is added automatically. | `ip` | + +### Examples + +**Forward lookups** for two IPv6 prefixes and one IPv4 prefix: ``` -2001:db8:abcd::/48 { +example.com { + synthetic { + net 2001:db8:abcd::/48 + net 2001:db8:1234::/48 + net 192.0.2.0/24 + prefix ip + ttl 300 + } + file db.example.com +} +``` + +**Reverse lookups** for an IPv6 prefix, with zone file records taking priority: + +``` +d.c.b.a.8.b.d.0.1.0.0.2.ip6.arpa { synthetic { forward example.com + prefix ip } file d.c.b.a.8.b.d.0.1.0.0.2.ip6.arpa } ``` -Forward Lookup Example - -``` -example.com { - synthetic { - net 2001:db8:abcd::/64 - net 2001:db8:1234::/64 - } - file db.example.com -``` - - ## Compiling into CoreDNS -To compile this with CoreDNS you can follow the [normal procedure](https://coredns.io/manual/plugins/#plugins) for external plugins. -This plugin can be used by adding the following to `plugin.cfg`: +Follow the standard [external plugin procedure](https://coredns.io/manual/plugins/#plugins). Add the following line to `plugin.cfg` in the CoreDNS source tree: + ``` synthetic:code.fritzlab.net/dns/synthetic ``` +Then rebuild CoreDNS: + +```sh +go generate +go build +``` + ## FAQ -### Why not use templates? +### Why not use the `template` plugin? -1- It appears that the `template` plugin is the recommended pattern for providing the resolution pattern we're after here. -However, it's not possible to have the `file` plugin provide the primary source of data and use a `template` at the same time. -See [this](https://github.com/coredns/coredns/issues/2977#issuecomment-555938144) GitHub comment. -Thus, it's not possible to have a PTR response from a file take priority over a template. +Two reasons: -2- Using regex in a template for IPv4 and IPv6 addresses is very challanging with CIDR notation. -This plugin provides an easier experience by just providing an IP prefix in CIDR notation. +1. The `template` plugin cannot coexist with `file` for the same zone in a way that lets file records take priority. See this [upstream discussion](https://github.com/coredns/coredns/issues/2977#issuecomment-555938144). The `synthetic` plugin is designed to work alongside `file`, deferring to it for static records. -## Development +2. Writing regex patterns to match arbitrary IPv4 and IPv6 addresses within CIDR ranges is impractical. This plugin accepts CIDR notation directly. -Standard Go development practices apply. +## Development -### Tests +```sh +# Run tests +go test -v ./... +# Lint +go vet ./... ``` -$ go test -v -``` + +## License + +[Apache License 2.0](LICENSE) diff --git a/setup.go b/setup.go index 3197e55..6d20dc4 100644 --- a/setup.go +++ b/setup.go @@ -2,82 +2,42 @@ package synthetic import ( "fmt" - "github.com/coredns/caddy" - "github.com/coredns/coredns/core/dnsserver" - "github.com/coredns/coredns/plugin" "net" "strconv" "strings" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" ) -// syntheticConfig holds the configuration options for the synthetic plugin. +// syntheticConfig holds the parsed Corefile configuration for the synthetic plugin. type syntheticConfig struct { - net []*net.IPNet + // net is the set of IP networks for which synthetic forward responses are + // generated. Only addresses falling within these CIDRs will be resolved. + net []*net.IPNet + + // forward is the DNS zone appended to synthetic hostnames when generating + // PTR records (e.g., "example.com"). forward string - ttl uint32 - prefix string + + // ttl is the time-to-live value applied to all synthetic responses. + // Defaults to 0 (no caching). + ttl uint32 + + // prefix is the hostname label prefix used to identify synthetic queries + // (e.g., "ip-"). A trailing dash is appended automatically if absent. + prefix string } -// init registers this plugin. func init() { plugin.Register("synthetic", setup) } -// setup is the function that gets called when the config parser see the token "synthetic". +// setup parses the Corefile configuration block for the synthetic plugin and +// registers it in the CoreDNS handler chain. func setup(c *caddy.Controller) error { - var config syntheticConfig - - // Parse the configuration file - for c.Next() { - for c.NextBlock() { - switch v := c.Val(); v { - - // Configuration for forward lookup zones for which to do resolution - case "net": - args := c.RemainingArgs() - for _, arg := range args { - _, cidr, err := net.ParseCIDR(arg) - if err == nil { - config.net = append(config.net, cidr) - } else { - return fmt.Errorf("synthetic: invalid reverse lookup cidr: %v", arg) - } - } - - // Configuration for reverse lookup zones for the forward lookup zone name - case "forward": - args := c.RemainingArgs() - for _, arg := range args { - config.forward = arg - } - - // Configuration for the TTL value - case "ttl": - args := c.RemainingArgs() - for _, arg := range args { - ttl64, err := strconv.ParseUint(arg, 10, 32) - if err != nil { - return fmt.Errorf("synthetic: invalid ttl value: %v", arg) - } - config.ttl = uint32(ttl64) - } - - // configuration for the prefix value (defaults to `ip`) - case "prefix": - args := c.RemainingArgs() - for _, arg := range args { - config.prefix = arg - } - - default: - return c.Errf("unknown property '%s'", v) - } - } - } - - if config.prefix == "" { - config.prefix = "ip" - } - if !strings.HasSuffix(config.prefix, "-") { - config.prefix = config.prefix + "-" + config, err := parseConfig(c) + if err != nil { + return err } dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { @@ -86,3 +46,58 @@ func setup(c *caddy.Controller) error { return nil } + +// parseConfig reads the synthetic { ... } block from the Corefile and returns +// a validated syntheticConfig. +func parseConfig(c *caddy.Controller) (syntheticConfig, error) { + var config syntheticConfig + + for c.Next() { + for c.NextBlock() { + switch v := c.Val(); v { + case "net": + for _, arg := range c.RemainingArgs() { + _, cidr, err := net.ParseCIDR(arg) + if err != nil { + return config, fmt.Errorf("synthetic: invalid CIDR notation: %v", arg) + } + config.net = append(config.net, cidr) + } + + case "forward": + args := c.RemainingArgs() + if len(args) > 0 { + config.forward = args[0] + } + + case "ttl": + for _, arg := range c.RemainingArgs() { + ttl64, err := strconv.ParseUint(arg, 10, 32) + if err != nil { + return config, fmt.Errorf("synthetic: invalid ttl value: %v", arg) + } + config.ttl = uint32(ttl64) + } + + case "prefix": + args := c.RemainingArgs() + if len(args) > 0 { + config.prefix = args[0] + } + + default: + return config, c.Errf("unknown property '%s'", v) + } + } + } + + // Apply defaults. + if config.prefix == "" { + config.prefix = "ip" + } + if !strings.HasSuffix(config.prefix, "-") { + config.prefix += "-" + } + + return config, nil +} diff --git a/synthetic.go b/synthetic.go index 3430d98..540a998 100644 --- a/synthetic.go +++ b/synthetic.go @@ -1,183 +1,239 @@ +// 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" - "net" - "strings" ) var log = clog.NewWithPlugin("synthetic") -func (s synthetic) Name() string { return "synthetic" } - +// synthetic is a CoreDNS plugin that resolves DNS queries for hostnames +// containing embedded IP addresses. type synthetic struct { Next plugin.Handler Config syntheticConfig } -func (s synthetic) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { +// Name returns the name of the plugin as registered with CoreDNS. +func (s synthetic) Name() string { return "synthetic" } - // Create a new state for this request. This is used to store state and allows us to pass this +// 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()) - // - // FOR FORWARD LOOKUPS - // we always respond directly (no need to continue the plugin chain) - // need valid networks to check against - // + // 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 strings.HasPrefix(state.Name(), s.Config.prefix) { - log.Debug("Possible synthetic response for:", state.QName()) - - // pull out the ip address - ipStr := strings.TrimPrefix(strings.Split(state.Name(), ".")[0], s.Config.prefix) - ip := net.ParseIP(strings.ReplaceAll(ipStr, "-", ".")) - if ip == nil { - ip = net.ParseIP(strings.ReplaceAll(ipStr, "-", ":")) - } - - // respond according to the IP type and the request type - if ip != nil { - log.Debug("Valid IP from hostname:", ip) - - // check if ip is within the synthetic network - var found bool - for _, n := range s.Config.net { - if n.Contains(ip) { - found = true - break - } - } - // don't continue if the IP is not in the synthetic network (fall back to recorded response) - if found { - log.Debug("IP ", ip, " is in synthetic network") - - if ip.To4() == nil && state.QType() == dns.TypeAAAA { - log.Debug("Responding to AAAA request for ", state.QName()) - m := new(dns.Msg) - m.SetReply(r) - hdr := dns.RR_Header{Name: state.QName(), Rrtype: state.QType(), Class: state.QClass(), Ttl: s.Config.ttl} - m.Answer = append(m.Answer, &dns.AAAA{Hdr: hdr, AAAA: ip.To16()}) - w.WriteMsg(m) - return dns.RcodeSuccess, nil - } - if ip.To4() != nil && state.QType() == dns.TypeA { - log.Debug("Responding to A request for ", state.QName()) - m := new(dns.Msg) - m.SetReply(r) - hdr := dns.RR_Header{Name: state.QName(), Rrtype: state.QType(), Class: state.QClass(), Ttl: s.Config.ttl} - m.Answer = append(m.Answer, &dns.A{Hdr: hdr, A: ip.To4()}) - w.WriteMsg(m) - return dns.RcodeSuccess, nil - } - if ip.To4() == nil && state.QType() == dns.TypeA { - log.Debug("Responding to A request for ", state.QName(), " with empty answer") - m := new(dns.Msg) - m.SetReply(r) - w.WriteMsg(m) - return dns.RcodeSuccess, nil - } - if ip.To4() != nil && state.QType() == dns.TypeAAAA { - log.Debug("Responding to AAAA request for ", state.QName(), " with empty answer") - m := new(dns.Msg) - m.SetReply(r) - w.WriteMsg(m) - return dns.RcodeSuccess, nil - } - log.Debug("Unexpected code path for: ", state.QName()) - } - log.Debug("IP not in a valid network: ", ip) - } - log.Debug("Invalid IP from hostname: ", state.QName()) + if rcode, handled := s.handleForward(state, w, r); handled { + return rcode, nil } } - // - // FOR REVERSE LOOKUPS - // must check next plugin in chain to see if it's a success (static reverse lookups override synthetic) - // no need to check valid networks (guaranteed to be in the synthetic network based on the coredns config) - // - - // Continue to the next plugin in the chain, and record the response. + // 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 the next plugin in the chain's recorded response is success, we go with that. if rc == dns.RcodeSuccess && len(rec.Msg.Answer) > 0 { - log.Debug("Next Plugin's answers are acceptable. no synthetic response") + 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 { - log.Debug("Attempting to inject synthetic response for reverse lookup: ", state.QName()) - - ip := inArpaToIp(state.QName()) - log.Debug("Parsed IP: ", ip) - - if ip != nil { - forward := ipToDomainName(s.Config.prefix, ip, s.Config.forward) - log.Debug("Responding to PTR request for ", state.QName(), " with ", forward) - m := new(dns.Msg) - m.SetReply(r) - hdr := dns.RR_Header{Name: state.QName(), Rrtype: state.QType(), Class: state.QClass(), Ttl: s.Config.ttl} - m.Answer = append(m.Answer, &dns.PTR{Hdr: hdr, Ptr: forward}) - w.WriteMsg(m) - return dns.RcodeSuccess, nil + if rcode, handled := s.handleReverse(state, w, r); handled { + return rcode, nil } } - // if we got here, we couldn't handle the request (fall back to recorded response) - log.Debug("synthetic plugin not needed for ", state.QName(), " of type ", state.QType()) + // 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 } -func inArpaToIp(name string) net.IP { - ipv4Suffix := ".in-addr.arpa." - ipv6Suffix := ".ip6.arpa." +// 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 { - name = name[:idx] - parts := strings.Split(name, ".") + parts := strings.Split(name[:idx], ".") if len(parts) != 4 { return nil } - - name = parts[3] + "." + parts[2] + "." + parts[1] + "." + parts[0] - return net.ParseIP(name) + 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 { - // we can rely on the fact that v6 reverse hostnames have a fixed length - - // read the characters from the hostname into a buffer in reverse - v6chars := make([]byte, 32) + nibbles := make([]byte, 32) for i, j := 62, 0; i >= 0; i -= 2 { - v6chars[j] = name[i] + nibbles[j] = name[i] j++ } - // decode the characters in the buffer into 16 bytes and return it - v6bytes := make([]byte, 16) - if _, err := hex.Decode(v6bytes, v6chars); err != nil { + addr := make([]byte, 16) + if _, err := hex.Decode(addr, nibbles); err != nil { return nil } - - return v6bytes + 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 "" @@ -186,7 +242,6 @@ func ipToDomainName(prefix string, ip net.IP, zone string) string { if !strings.HasPrefix(zone, ".") { zone = "." + zone } - if !strings.HasSuffix(zone, ".") { zone = zone + "." } @@ -196,6 +251,5 @@ func ipToDomainName(prefix string, ip net.IP, zone string) string { sep = "." } - response := strings.Join(strings.Split(ip.String(), sep), "-") - return prefix + response + zone + return prefix + strings.Join(strings.Split(ip.String(), sep), "-") + zone } diff --git a/synthetic_test.go b/synthetic_test.go index 470ee1e..204ee2f 100644 --- a/synthetic_test.go +++ b/synthetic_test.go @@ -2,378 +2,295 @@ package synthetic import ( "context" - "fmt" + "net" + "testing" + "github.com/coredns/coredns/plugin/pkg/dnstest" "github.com/coredns/coredns/plugin/test" "github.com/miekg/dns" - "net" - - "testing" ) -func TestServeDNSv4(t *testing.T) { - ip, ipNet, err := net.ParseCIDR("192.0.2.0/24") - if err != nil { - log.Fatal(err) +// testCase describes a single DNS query and the expected response. +type testCase struct { + name string + qname string + qtype uint16 + wantRcode int + wantAnswer []string + wantTTL uint32 +} + +// assertResponse validates the DNS response from ServeDNS against tc. +func assertResponse(t *testing.T, tc testCase, rc int, w *dnstest.Recorder) { + t.Helper() + + if rc != tc.wantRcode { + t.Errorf("rcode: got %d, want %d", rc, tc.wantRcode) } - s := synthetic{ - Next: test.ErrorHandler(), - Config: syntheticConfig{ - net: []*net.IPNet{{IP: ip, Mask: ipNet.Mask}}, - forward: "example.com", - prefix: "ip-", - }, + if tc.wantAnswer == nil { + if len(w.Msg.Answer) > 0 { + t.Errorf("answer: got %v, want none", w.Msg.Answer[0]) + } + return } - testCases := []struct { - qname string - qtype uint16 - wantrcode int - wantAnswer []string - wantTTL uint32 - }{ - { - qname: "ip-192-0-2-0.example.com", - qtype: dns.TypeA, - wantrcode: dns.RcodeSuccess, - wantAnswer: []string{"192.0.2.0"}, - wantTTL: 0, - }, - { - qname: "ip-192-0-2-255.example.com", - qtype: dns.TypeA, - wantrcode: dns.RcodeSuccess, - wantAnswer: []string{"192.0.2.255"}, - wantTTL: 0, - }, - { - qname: "ip-203-0-113-100.example.com", - qtype: dns.TypeA, - wantrcode: dns.RcodeServerFailure, - wantAnswer: nil, - wantTTL: 0, - }, - { - qname: "123.2.0.192.in-addr.arpa.", - qtype: dns.TypePTR, - wantrcode: dns.RcodeSuccess, - wantAnswer: []string{"ip-192-0-2-123.example.com."}, - wantTTL: 0, - }, - { - qname: "ip-2001-db8-abcd--.example.com", - qtype: dns.TypeA, - wantrcode: dns.RcodeServerFailure, - wantAnswer: nil, - wantTTL: 0, - }, + if len(w.Msg.Answer) == 0 { + t.Fatal("answer: got none, want an answer") } - for i, tc := range testCases { - errorMsgPrefix := fmt.Sprintf("Test case %v for '%v' failed. Expected", i, tc.qname) - ctx := context.TODO() - w := dnstest.NewRecorder(&test.ResponseWriter{}) - r := new(dns.Msg) - r.SetQuestion(tc.qname, tc.qtype) + hdr := w.Msg.Answer[0].Header() + if hdr.Name != tc.qname { + t.Errorf("name: got %s, want %s", hdr.Name, tc.qname) + } + if hdr.Rrtype != tc.qtype { + t.Errorf("rrtype: got %d, want %d", hdr.Rrtype, tc.qtype) + } + if hdr.Ttl != tc.wantTTL { + t.Errorf("ttl: got %d, want %d", hdr.Ttl, tc.wantTTL) + } - rc, err := s.ServeDNS(ctx, w, r) - - if err != nil { - t.Errorf("%v no error, but got %v", errorMsgPrefix, err) + switch rr := w.Msg.Answer[0].(type) { + case *dns.A: + if rr.A.String() != tc.wantAnswer[0] { + t.Errorf("A record: got %s, want %s", rr.A, tc.wantAnswer[0]) } - if rc != tc.wantrcode { - t.Errorf("%v rcode %v, but got %v", errorMsgPrefix, tc.wantrcode, rc) + case *dns.AAAA: + if rr.AAAA.String() != tc.wantAnswer[0] { + t.Errorf("AAAA record: got %s, want %s", rr.AAAA, tc.wantAnswer[0]) } - if len(w.Msg.Answer) == 0 && tc.wantAnswer != nil { - t.Errorf("%v an answer, but got none", errorMsgPrefix) - continue - } - if tc.wantAnswer == nil { - if len(w.Msg.Answer) > 0 { - t.Errorf("%v no answer, but got %v", errorMsgPrefix, w.Msg.Answer[0]) - } - continue - } - if w.Msg.Answer[0].Header().Ttl != 0 { - t.Errorf("%v TTL to be 0, but got %v", errorMsgPrefix, w.Msg.Answer[0].Header().Ttl) - } - if w.Msg.Answer[0].Header().Name != tc.qname { - t.Errorf("%v Name to be %s, but got %s", errorMsgPrefix, tc.qname, w.Msg.Answer[0].Header().Name) - } - if w.Msg.Answer[0].Header().Rrtype != tc.qtype { - t.Errorf("%v Type to be %d, but got %d", errorMsgPrefix, tc.qtype, w.Msg.Answer[0].Header().Rrtype) - } - if w.Msg.Answer[0].Header().Ttl != tc.wantTTL { - t.Errorf("%v TTL to be %d, but got %d", errorMsgPrefix, tc.wantTTL, w.Msg.Answer[0].Header().Ttl) - } - switch tc.qtype { - case dns.TypeA: - if w.Msg.Answer[0].(*dns.A).A.String() != tc.wantAnswer[0] { - t.Errorf("%v answer %s, but got %s", errorMsgPrefix, tc.wantAnswer[0], w.Msg.Answer[0].(*dns.A).A.String()) - } - case dns.TypePTR: - if w.Msg.Answer[0].(*dns.PTR).Ptr != tc.wantAnswer[0] { - t.Errorf("%v answer %s, but got %s", errorMsgPrefix, tc.wantAnswer[0], w.Msg.Answer[0].(*dns.PTR).Ptr) - } + case *dns.PTR: + if rr.Ptr != tc.wantAnswer[0] { + t.Errorf("PTR record: got %s, want %s", rr.Ptr, tc.wantAnswer[0]) } } } -func TestServeDNSv6(t *testing.T) { - ip, ipNet, err := net.ParseCIDR("2001:db8:abcd::/48") +// runTestCases executes a table of test cases against the given synthetic plugin. +func runTestCases(t *testing.T, s synthetic, cases []testCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := dnstest.NewRecorder(&test.ResponseWriter{}) + r := new(dns.Msg) + r.SetQuestion(tc.qname, tc.qtype) + + rc, err := s.ServeDNS(context.TODO(), w, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertResponse(t, tc, rc, w) + }) + } +} + +func TestServeDNSv4(t *testing.T) { + _, ipNet, err := net.ParseCIDR("192.0.2.0/24") if err != nil { - log.Fatal(err) + t.Fatal(err) + } + + s := synthetic{ + Next: test.ErrorHandler(), + Config: syntheticConfig{net: []*net.IPNet{ipNet}, forward: "example.com", prefix: "ip-"}, + } + + runTestCases(t, s, []testCase{ + { + name: "v4 first address", + qname: "ip-192-0-2-0.example.com", + qtype: dns.TypeA, + wantRcode: dns.RcodeSuccess, + wantAnswer: []string{"192.0.2.0"}, + }, + { + name: "v4 last address", + qname: "ip-192-0-2-255.example.com", + qtype: dns.TypeA, + wantRcode: dns.RcodeSuccess, + wantAnswer: []string{"192.0.2.255"}, + }, + { + name: "v4 out of range", + qname: "ip-203-0-113-100.example.com", + qtype: dns.TypeA, + wantRcode: dns.RcodeServerFailure, + }, + { + name: "v4 PTR reverse", + qname: "123.2.0.192.in-addr.arpa.", + qtype: dns.TypePTR, + wantRcode: dns.RcodeSuccess, + wantAnswer: []string{"ip-192-0-2-123.example.com."}, + }, + { + name: "v6 address with v4 config", + qname: "ip-2001-db8-abcd--.example.com", + qtype: dns.TypeA, + wantRcode: dns.RcodeServerFailure, + }, + }) +} + +func TestServeDNSv6(t *testing.T) { + _, ipNet, err := net.ParseCIDR("2001:db8:abcd::/48") + if err != nil { + t.Fatal(err) } s := synthetic{ Next: test.ErrorHandler(), Config: syntheticConfig{ - net: []*net.IPNet{{IP: ip, Mask: ipNet.Mask}}, + net: []*net.IPNet{ipNet}, forward: "example.com.", ttl: 1800, prefix: "ip6-", }, } - testCases := []struct { - qname string - qtype uint16 - wantrcode int - wantAnswer []string - wantTTL uint32 - }{ + runTestCases(t, s, []testCase{ { + name: "v6 zero-padded", qname: "ip6-2001-db8-abcd--.example.com", qtype: dns.TypeAAAA, - wantrcode: dns.RcodeSuccess, + wantRcode: dns.RcodeSuccess, wantAnswer: []string{"2001:db8:abcd::"}, wantTTL: 1800, }, { + name: "v6 fully expanded", qname: "ip6-2001-db8-abcd-1234-4567-890a-bcde-f123.example.com", qtype: dns.TypeAAAA, - wantrcode: dns.RcodeSuccess, + wantRcode: dns.RcodeSuccess, wantAnswer: []string{"2001:db8:abcd:1234:4567:890a:bcde:f123"}, wantTTL: 1800, }, { - qname: "ip6-2001-db8-1234--.example.com", - qtype: dns.TypeAAAA, - wantrcode: dns.RcodeServerFailure, - wantAnswer: nil, - wantTTL: 1800, + name: "v6 out of range", + qname: "ip6-2001-db8-1234--.example.com", + qtype: dns.TypeAAAA, + wantRcode: dns.RcodeServerFailure, }, { + name: "v6 PTR reverse", qname: "3.2.1.f.e.d.c.b.a.0.9.8.7.6.5.4.4.3.2.1.d.c.b.a.8.b.d.0.1.0.0.2.ip6.arpa.", qtype: dns.TypePTR, - wantrcode: dns.RcodeSuccess, + wantRcode: dns.RcodeSuccess, wantAnswer: []string{"ip6-2001-db8-abcd-1234-4567-890a-bcde-f123.example.com."}, wantTTL: 1800, }, { - qname: "ip6-192-0-2-0.example.com", - qtype: dns.TypeAAAA, - wantrcode: dns.RcodeServerFailure, - wantAnswer: nil, - wantTTL: 0, + name: "v4 address with v6 config", + qname: "ip6-192-0-2-0.example.com", + qtype: dns.TypeAAAA, + wantRcode: dns.RcodeServerFailure, }, - } - - for i, tc := range testCases { - errorMsgPrefix := fmt.Sprintf("Test case %v for '%v' failed. Expected", i, tc.qname) - ctx := context.TODO() - w := dnstest.NewRecorder(&test.ResponseWriter{}) - r := new(dns.Msg) - r.SetQuestion(tc.qname, tc.qtype) - - rc, err := s.ServeDNS(ctx, w, r) - - if err != nil { - t.Errorf("%v no error, but got %v", errorMsgPrefix, err) - } - if rc != tc.wantrcode { - t.Errorf("%v rcode %v, but got %v", errorMsgPrefix, tc.wantrcode, rc) - } - if len(w.Msg.Answer) == 0 && tc.wantAnswer != nil { - t.Errorf("%v an answer, but got none", errorMsgPrefix) - continue - } - if tc.wantAnswer == nil { - if len(w.Msg.Answer) > 0 { - t.Errorf("%v no answer, but got %v", errorMsgPrefix, w.Msg.Answer[0]) - } - continue - } - if w.Msg.Answer[0].Header().Name != tc.qname { - t.Errorf("%v Name to be %s, but got %s", errorMsgPrefix, tc.qname, w.Msg.Answer[0].Header().Name) - } - if w.Msg.Answer[0].Header().Rrtype != tc.qtype { - t.Errorf("%v Type to be %d, but got %d", errorMsgPrefix, tc.qtype, w.Msg.Answer[0].Header().Rrtype) - } - if w.Msg.Answer[0].Header().Ttl != tc.wantTTL { - t.Errorf("%v TTL to be %d, but got %d", errorMsgPrefix, tc.wantTTL, w.Msg.Answer[0].Header().Ttl) - } - switch tc.qtype { - case dns.TypeA: - if w.Msg.Answer[0].(*dns.A).A.String() != tc.wantAnswer[0] { - t.Errorf("%v answer %s, but got %s", errorMsgPrefix, tc.wantAnswer[0], w.Msg.Answer[0].(*dns.A).A.String()) - } - case dns.TypePTR: - if w.Msg.Answer[0].(*dns.PTR).Ptr != tc.wantAnswer[0] { - t.Errorf("%v answer %s, but got %s", errorMsgPrefix, tc.wantAnswer[0], w.Msg.Answer[0].(*dns.PTR).Ptr) - } - } - } + }) } -// MockSuccessPlugin is a mock plugin that always responds with a successful DNS response. -type MockSuccessPlugin struct{} +// mockSuccessPlugin always returns a successful answer, simulating a static +// zone file or upstream resolver that provides authoritative records. +type mockSuccessPlugin struct{} -// Name returns the plugin name. -func (m MockSuccessPlugin) Name() string { return "mock" } +func (m mockSuccessPlugin) Name() string { return "mock" } + +func (m mockSuccessPlugin) ServeDNS(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + msg := new(dns.Msg) + msg.SetReply(r) + hdr := dns.RR_Header{ + Name: r.Question[0].Name, + Rrtype: r.Question[0].Qtype, + Class: r.Question[0].Qclass, + } -// ServeDNS simulates a successful DNS response. -func (m MockSuccessPlugin) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { - m1 := new(dns.Msg) - m1.SetReply(r) - hdr := dns.RR_Header{Name: r.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: 0} switch r.Question[0].Qtype { case dns.TypeA: - m1.Answer = append(m1.Answer, &dns.A{Hdr: hdr, A: net.ParseIP("192.0.2.100")}) + msg.Answer = append(msg.Answer, &dns.A{Hdr: hdr, A: net.ParseIP("192.0.2.100")}) case dns.TypeAAAA: - m1.Answer = append(m1.Answer, &dns.AAAA{Hdr: hdr, AAAA: net.ParseIP("2001:db8::100")}) + msg.Answer = append(msg.Answer, &dns.AAAA{Hdr: hdr, AAAA: net.ParseIP("2001:db8::100")}) } - w.WriteMsg(m1) + + w.WriteMsg(msg) return dns.RcodeSuccess, nil } -func TestServeDNSNextPluginRespondsSuccess(t *testing.T) { - ip, ipNet, err := net.ParseCIDR("192.0.2.0/24") +// TestServeDNSNextPluginPrecedence verifies that answers from the next plugin +// in the chain take priority over synthetic responses for reverse lookups, +// while forward lookups still produce synthetic answers directly. +func TestServeDNSNextPluginPrecedence(t *testing.T) { + _, ipNet4, err := net.ParseCIDR("192.0.2.0/24") if err != nil { - log.Fatal(err) + t.Fatal(err) } - ip6, ipNet6, err := net.ParseCIDR("2001:db8:abcd::/48") + _, ipNet6, err := net.ParseCIDR("2001:db8:abcd::/48") if err != nil { - log.Fatal(err) + t.Fatal(err) } s := synthetic{ - Next: MockSuccessPlugin{}, + Next: mockSuccessPlugin{}, Config: syntheticConfig{ - net: []*net.IPNet{{IP: ip, Mask: ipNet.Mask}, {IP: ip6, Mask: ipNet6.Mask}}, + net: []*net.IPNet{ipNet4, ipNet6}, forward: "example.com", prefix: "ip-", }, } - testCases := []struct { - qname string - qtype uint16 - wantrcode int - wantAnswer []string - wantTTL uint32 - }{ + runTestCases(t, s, []testCase{ { + name: "forward v4 synthetic wins", qname: "ip-192-0-2-1.example.com", qtype: dns.TypeA, - wantrcode: dns.RcodeSuccess, + wantRcode: dns.RcodeSuccess, wantAnswer: []string{"192.0.2.1"}, - wantTTL: 0, }, { + name: "forward non-synthetic uses next plugin", qname: "foobar.example.com", qtype: dns.TypeA, - wantrcode: dns.RcodeSuccess, + wantRcode: dns.RcodeSuccess, wantAnswer: []string{"192.0.2.100"}, - wantTTL: 0, }, { + name: "forward v6 synthetic wins", qname: "ip-2001-db8-abcd--1.example.com", qtype: dns.TypeAAAA, - wantrcode: dns.RcodeSuccess, + wantRcode: dns.RcodeSuccess, wantAnswer: []string{"2001:db8:abcd::1"}, - wantTTL: 0, }, { + name: "forward non-synthetic AAAA uses next plugin", qname: "foobar.example.com", qtype: dns.TypeAAAA, - wantrcode: dns.RcodeSuccess, - wantAnswer: []string{"2001:db8:abcd::100"}, - wantTTL: 0, + wantRcode: dns.RcodeSuccess, + wantAnswer: []string{"2001:db8::100"}, }, { - qname: "ip-2001-db8-abcd--1.example.com", - qtype: dns.TypeA, - wantrcode: dns.RcodeSuccess, - wantAnswer: nil, - wantTTL: 0, + name: "v6 address queried as A returns empty", + qname: "ip-2001-db8-abcd--1.example.com", + qtype: dns.TypeA, + wantRcode: dns.RcodeSuccess, }, { - qname: "ip-192-0-2-1.example.com", - qtype: dns.TypeAAAA, - wantrcode: dns.RcodeSuccess, - wantAnswer: nil, - wantTTL: 0, + name: "v4 address queried as AAAA returns empty", + qname: "ip-192-0-2-1.example.com", + qtype: dns.TypeAAAA, + wantRcode: dns.RcodeSuccess, }, { + name: "PTR v6 synthetic (mock has no PTR)", qname: "3.2.1.f.e.d.c.b.a.0.9.8.7.6.5.4.4.3.2.1.d.c.b.a.8.b.d.0.1.0.0.2.ip6.arpa.", qtype: dns.TypePTR, - wantrcode: dns.RcodeSuccess, - wantAnswer: []string{"ip6-2001-db8-abcd-1234-4567-890a-bcde-f123.example.com."}, - wantTTL: 0, + wantRcode: dns.RcodeSuccess, + wantAnswer: []string{"ip-2001-db8-abcd-1234-4567-890a-bcde-f123.example.com."}, }, { + name: "PTR v4 synthetic (mock has no PTR)", qname: "123.2.0.192.in-addr.arpa.", qtype: dns.TypePTR, - wantrcode: dns.RcodeSuccess, + wantRcode: dns.RcodeSuccess, wantAnswer: []string{"ip-192-0-2-123.example.com."}, - wantTTL: 0, }, - } - - for i, tc := range testCases { - errorMsgPrefix := fmt.Sprintf("Test case %v for '%v' failed. Expected", i, tc.qname) - ctx := context.TODO() - w := dnstest.NewRecorder(&test.ResponseWriter{}) - r := new(dns.Msg) - r.SetQuestion(tc.qname, tc.qtype) - - rc, err := s.ServeDNS(ctx, w, r) - - if err != nil { - t.Errorf("%v no error, but got %v", errorMsgPrefix, err) - } - if rc != tc.wantrcode { - t.Errorf("%v rcode %v, but got %v", errorMsgPrefix, tc.wantrcode, rc) - } - if len(w.Msg.Answer) == 0 && tc.wantAnswer != nil { - t.Errorf("%v an answer, but got none", errorMsgPrefix) - continue - } - if tc.wantAnswer == nil { - if len(w.Msg.Answer) > 0 { - t.Errorf("%v no answer, but got %v", errorMsgPrefix, w.Msg.Answer[0]) - } - continue - } - if w.Msg.Answer[0].Header().Ttl != 0 { - t.Errorf("%v TTL to be 0, but got %v", errorMsgPrefix, w.Msg.Answer[0].Header().Ttl) - } - if w.Msg.Answer[0].Header().Name != tc.qname { - t.Errorf("%v Name to be %s, but got %s", errorMsgPrefix, tc.qname, w.Msg.Answer[0].Header().Name) - } - if w.Msg.Answer[0].Header().Rrtype != tc.qtype { - t.Errorf("%v Type to be %d, but got %d", errorMsgPrefix, tc.qtype, w.Msg.Answer[0].Header().Rrtype) - } - if w.Msg.Answer[0].Header().Ttl != tc.wantTTL { - t.Errorf("%v TTL to be %d, but got %d", errorMsgPrefix, tc.wantTTL, w.Msg.Answer[0].Header().Ttl) - } - switch tc.qtype { - case dns.TypeA: - if w.Msg.Answer[0].(*dns.A).A.String() != tc.wantAnswer[0] { - t.Errorf("%v answer %s, but got %s", errorMsgPrefix, tc.wantAnswer[0], w.Msg.Answer[0].(*dns.A).A.String()) - } - } - } + }) }