From 3b9f85bb713e8183fbc4c71884bbd0a4ced8750b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mich=C3=A9e=20lengronne?= Date: Mon, 24 Aug 2026 02:55:27 +0200 Subject: [PATCH] feat(siit): Initial version (#8188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(siit): Initial version Signed-off-by: Michée Lengronne * cleaner readme Signed-off-by: Michée Lengronne * linting and generating Signed-off-by: Michée Lengronne * improving README and removing a useless case Signed-off-by: Michée Lengronne * improvements Signed-off-by: Michée Lengronne * improvements Signed-off-by: Michée Lengronne * linting Signed-off-by: Michée Lengronne * New fixes Signed-off-by: Michée Lengronne * improvements Signed-off-by: Michée Lengronne * improvements Signed-off-by: Michée Lengronne --------- Signed-off-by: Michée Lengronne --- core/dnsserver/zdirectives.go | 1 + core/plugin/zplugin.go | 1 + plugin.cfg | 1 + plugin/siit/README.md | 60 ++ plugin/siit/metrics.go | 18 + plugin/siit/setup.go | 138 ++++ plugin/siit/setup_test.go | 206 ++++++ plugin/siit/siit.go | 331 +++++++++ plugin/siit/siit_dns64_test.go | 97 +++ plugin/siit/siit_test.go | 1164 ++++++++++++++++++++++++++++++++ test/siit_test.go | 80 +++ 11 files changed, 2097 insertions(+) create mode 100644 plugin/siit/README.md create mode 100644 plugin/siit/metrics.go create mode 100644 plugin/siit/setup.go create mode 100644 plugin/siit/setup_test.go create mode 100644 plugin/siit/siit.go create mode 100644 plugin/siit/siit_dns64_test.go create mode 100644 plugin/siit/siit_test.go create mode 100644 test/siit_test.go diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index 2b2f5cf4c..aed84bfbc 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -37,6 +37,7 @@ var Directives = []string{ "dnstap", "local", "dns64", + "siit", "any", "chaos", "loadbalance", diff --git a/core/plugin/zplugin.go b/core/plugin/zplugin.go index 1efb33f62..e15eca03b 100644 --- a/core/plugin/zplugin.go +++ b/core/plugin/zplugin.go @@ -56,6 +56,7 @@ import ( _ "github.com/coredns/coredns/plugin/secondary" _ "github.com/coredns/coredns/plugin/shed" _ "github.com/coredns/coredns/plugin/sign" + _ "github.com/coredns/coredns/plugin/siit" _ "github.com/coredns/coredns/plugin/template" _ "github.com/coredns/coredns/plugin/timeouts" _ "github.com/coredns/coredns/plugin/tls" diff --git a/plugin.cfg b/plugin.cfg index 7e39166d4..6e7072737 100644 --- a/plugin.cfg +++ b/plugin.cfg @@ -46,6 +46,7 @@ log:log dnstap:dnstap local:local dns64:dns64 +siit:siit any:any chaos:chaos loadbalance:loadbalance diff --git a/plugin/siit/README.md b/plugin/siit/README.md new file mode 100644 index 000000000..85dfdff60 --- /dev/null +++ b/plugin/siit/README.md @@ -0,0 +1,60 @@ +# siit + +## Name + +*siit* - enables AAAA->A translation support for DNS records based on SIIT (IPv6->IPv4 translation). + +## Description + +The *siit* plugin will when asked for a domain's A record, +synthesizes it from a corresponding AAAA record if it belongs to a certain IP range. + +It also supports arbitrary mapping IPv6->IPv4. + +It is useful when published services are IPv6-only and emit their AAAA record accordingly +but IPv4 clients reach them through a siit routing. This plugin generates the associated A records +for these clients automatically. + +## Syntax + +~~~ +siit { + ipv6_prefix IPV6PREFIX + eam IPV4 IPV6 +} +~~~ + +* `ipv6_prefix` specifies any local IPv6 prefix to use, instead of the well known prefix (64:ff9b::/96) +* `eam` translates the ipv6 to the corresponding ipv4, it can be set multiple times + +## Examples + +~~~ corefile +. { + siit { + ipv6_prefix 64:1337::/96 + } +} +~~~ + +## Metrics + +If monitoring is enabled (via the _prometheus_ plugin) then the following metrics are exported: + +- `coredns_siit_requests_translated_total{server}` - counter of DNS requests translated + +The `server` label is explained in the _prometheus_ plugin documentation. + +## Bugs + +* Prefix matching in eam is not implemented yet. +* DNSSEC support is not implemented yet. The problem is the same as DNS64. See: [RFC 6147 Section 3](https://tools.ietf.org/html/rfc6147#section-3) + +## See Also + +See [RFC 6052](https://tools.ietf.org/html/rfc6052) for more information on the SIIT mechanism +and [RFC 7757](https://tools.ietf.org/html/rfc7757) about the explicit address mappings (eam) mechanism + +## Notes + +This plugin is heavily based on [dns64 plugin](../dns64). diff --git a/plugin/siit/metrics.go b/plugin/siit/metrics.go new file mode 100644 index 000000000..a78e79d45 --- /dev/null +++ b/plugin/siit/metrics.go @@ -0,0 +1,18 @@ +package siit + +import ( + "github.com/coredns/coredns/plugin" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // RequestsTranslatedCount is the number of DNS requests translated by siit. + RequestsTranslatedCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: pluginName, + Name: "requests_translated_total", + Help: "Counter of DNS requests translated by siit.", + }, []string{"server"}) +) diff --git a/plugin/siit/setup.go b/plugin/siit/setup.go new file mode 100644 index 000000000..a58a798f1 --- /dev/null +++ b/plugin/siit/setup.go @@ -0,0 +1,138 @@ +package siit + +import ( + "net" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/upstream" +) + +const pluginName = "siit" + +func init() { plugin.Register(pluginName, setup) } + +func setup(c *caddy.Controller) error { + siit, err := siitParse(c) + if err != nil { + return plugin.Error(pluginName, err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + siit.Next = next + return siit + }) + + return nil +} + +func siitParse(c *caddy.Controller) (*SIIT, error) { + _, defaultPref6, _ := net.ParseCIDR("64:ff9b::/96") + siit := &SIIT{ + Upstream: upstream.New(), + IPv6Prefix: defaultPref6, + } + + for c.Next() { + args := c.RemainingArgs() + if len(args) > 0 { + return nil, c.ArgErr() + } + + for c.NextBlock() { + switch c.Val() { + case "ipv6_prefix": + if !c.NextArg() { + return nil, c.ArgErr() + } + pref, err := parseIpv6Prefix(c, c.Val()) + + if err != nil { + return nil, err + } + siit.IPv6Prefix = pref + case "eam": + pref, err := parseEam(c) + + if err != nil { + return nil, err + } + + if siit.Eam4 == nil { + siit.Eam4 = make(map[string]net.IP) + } + + siit.Eam4[pref[1].String()] = pref[0] + default: + return nil, c.Errf("unknown property '%s'", c.Val()) + } + } + } + return siit, nil +} + +func parseIpv6Prefix(c *caddy.Controller, addr string) (*net.IPNet, error) { + ip, pref, err := net.ParseCIDR(addr) + if err != nil { + return nil, err + } + + // Test for valid prefix + n, total := pref.Mask.Size() + if total != 128 { + return nil, c.Errf("invalid netmask %d IPv6 address: %q", total, pref) + } + + ip16 := ip.To16() + if ip16 == nil { + return nil, c.Errf("invalid IPv6 prefix %q", addr) + } + + // RFC 6052 §2.2: only these lengths are valid. + switch n { + case 32, 40, 48, 56, 64, 96: + // ok + default: + return nil, c.Errf("invalid prefix length %q: must be one of /32, /40, /48, /56, /64, /96", pref) + } + + // RFC 6052 §2.2: byte 8 (bits 64-71, the "u" octet) is reserved and + // MUST be zero for every valid prefix length — including /96, where + // it's still part of the operator-configured prefix bits. + if pref.IP.To16()[8] != 0 { + return nil, c.Errf("invalid prefix %q: reserved octet (byte 8) must be zero", pref) + } + + return pref, nil +} + +func parseEam(c *caddy.Controller) (map[int]net.IP, error) { + args := c.RemainingArgs() + if len(args) != 2 { + return nil, c.ArgErr() + } + + pref0 := net.ParseIP(args[0]) + if pref0 == nil { + return nil, c.Errf("invalid IP address: %q", pref0) + } + + if pref0.To4() == nil { + return nil, c.Errf("invalid IPv4 address: %q", pref0) + } + + pref1 := net.ParseIP(args[1]) + if pref1 == nil { + return nil, c.Errf("invalid IP address: %q", pref1) + } + + if pref1.To4() != nil { + return nil, c.Errf("invalid IPv6 address: %q", pref1) + } + + pref := make(map[int]net.IP) + pref[0] = pref0 + pref[1] = pref1 + return pref, nil +} diff --git a/plugin/siit/setup_test.go b/plugin/siit/setup_test.go new file mode 100644 index 000000000..1a2951add --- /dev/null +++ b/plugin/siit/setup_test.go @@ -0,0 +1,206 @@ +package siit + +import ( + "reflect" + "testing" + + "github.com/coredns/caddy" +) + +func TestSetupSiit(t *testing.T) { + tests := []struct { + inputUpstreams string + shouldErr bool + wantIPv6Prefix string + wantEam map[string]string + }{ + { + `siit`, + false, + "64:ff9b::/96", + map[string]string{}, + }, + { + `siit { + ipv6_prefix 64:dead::/96 + }`, + false, + "64:dead::/96", + map[string]string{}, + }, + { + `siit { + ipv6_prefix 10.0.0.0/8 + }`, + true, + "10.0.0.0/8", + map[string]string{}, + }, + { + `siit { + ipv6_prefix foobar + }`, + true, + "foobar", + map[string]string{}, + }, + { + `siit { + eam 10.0.0.1 64:dead::1 + }`, + false, + "64:ff9b::/96", + map[string]string{ + "64:dead::1": "10.0.0.1", + }, + }, + { + `siit { + eam 10.0.0.1 64:dead::1 + eam 10.0.0.2 64:dead::2 + }`, + false, + "64:ff9b::/96", + map[string]string{ + "64:dead::1": "10.0.0.1", + "64:dead::2": "10.0.0.2", + }, + }, + { + `siit { + eam 64:dead::1 10.0.0.1 + }`, + true, + "64:ff9b::/96", + map[string]string{ + "64:dead::1": "10.0.0.1", + }, + }, + { + `siit { + eam foobar 64:dead::1 + }`, + true, + "64:ff9b::/96", + map[string]string{ + "foobar": "64:dead::1", + }, + }, + { + `siit { + eam 10.0.0.1 foobar + }`, + true, + "64:ff9b::/96", + map[string]string{ + "10.0.0.1": "foobar", + }, + }, + { + `siit { + ipv6_prefix 64:ff9b::/72 + }`, + true, // /72 not in the allowed set (32/40/48/56/64/96) + "64:ff9b::/72", + map[string]string{}, + }, + { + `siit { + ipv6_prefix 64:ff9b::/32 + }`, + false, + "64:ff9b::/32", + map[string]string{}, + }, + { + `siit { + ipv6_prefix 64:ff9b::/40 + }`, + false, + "64:ff9b::/40", + map[string]string{}, + }, + { + `siit { + ipv6_prefix 64:ff9b::/48 + }`, + false, + "64:ff9b::/48", + map[string]string{}, + }, + { + `siit { + ipv6_prefix 64:ff9b::/56 + }`, + false, + "64:ff9b::/56", + map[string]string{}, + }, + { + `siit { + ipv6_prefix 64:ff9b::/64 + }`, + false, + "64:ff9b::/64", + map[string]string{}, + }, + { + // /96 prefix with nonzero byte 8 must be rejected + `siit { + ipv6_prefix 2001:db8:122:344:ff00::/96 + }`, + true, + "2001:db8:122:344:ff00::/96", + map[string]string{}, + }, + { + // valid /96 prefix, byte 8 zero -- must still be accepted. + `siit { + ipv6_prefix 2001:db8::/96 + }`, + false, + "2001:db8::/96", + map[string]string{}, + }, + { + // eam missing the second argument must not panic + `siit { + eam 10.0.0.1 + }`, + true, + "64:ff9b::/96", + map[string]string{ + "10.0.0.1": "", + }, + }, + { + // eam with too many arguments should error, not silently ignore extras + `siit { + eam 10.0.0.1 64:dead::1 extra + }`, + true, + "64:ff9b::/96", + map[string]string{}, + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputUpstreams) + siit, err := siitParse(c) + if (err != nil) != test.shouldErr { + t.Errorf("Test %d expected %v error, got %v for %s", i+1, test.shouldErr, err, test.inputUpstreams) + } + if err == nil { + if siit.IPv6Prefix.String() != test.wantIPv6Prefix { + t.Errorf("Test %d expected ipv6 prefix %s, got %v", i+1, test.wantIPv6Prefix, siit.IPv6Prefix.String()) + } + gotEam := make(map[string]string, len(siit.Eam4)) + for v6, v4 := range siit.Eam4 { + gotEam[v6] = v4.String() + } + if !reflect.DeepEqual(gotEam, test.wantEam) { + t.Errorf("Test %d expected eam %v, got %v", i+1, test.wantEam, gotEam) + } + } + } +} diff --git a/plugin/siit/siit.go b/plugin/siit/siit.go new file mode 100644 index 000000000..f964ab543 --- /dev/null +++ b/plugin/siit/siit.go @@ -0,0 +1,331 @@ +// Package siit implements a plugin that performs AAAA to A translation. +// +// See: RFC 6052 (https://tools.ietf.org/html/rfc6052) +// See: RFC 7757 (https://tools.ietf.org/html/rfc7757) +package siit + +import ( + "context" + "errors" + "net" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/metrics" + "github.com/coredns/coredns/plugin/pkg/nonwriter" + "github.com/coredns/coredns/plugin/pkg/response" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// UpstreamInt wraps the Upstream API for dependency injection during testing +type UpstreamInt interface { + Lookup(ctx context.Context, state request.Request, name string, typ uint16) (*dns.Msg, error) +} + +// SIIT performs SIIT. +type SIIT struct { + Next plugin.Handler + IPv6Prefix *net.IPNet + Eam4 map[string]net.IP + Upstream UpstreamInt +} + +// synthesisCtxKey marks a context as belonging to a translation plugin's own +// internal synthesis lookup (as opposed to a query that arrived from a +// client). Unexported so it can't collide with keys set by other packages. +type synthesisCtxKey struct{} + +// markSynthesisLookup flags ctx as carrying a nested lookup issued by SIIT's +// own synthesis logic. The flag propagates through every +// plugin.Handler.ServeDNS(ctx, ...) call in the chain, including through +// other translation plugins such as dns64, whose own internal +// Upstream.Lookup calls reuse the context they were invoked with. +func markSynthesisLookup(ctx context.Context) context.Context { + return context.WithValue(ctx, synthesisCtxKey{}, true) +} + +// isSynthesisLookup reports whether ctx was produced by markSynthesisLookup. +func isSynthesisLookup(ctx context.Context) bool { + v, _ := ctx.Value(synthesisCtxKey{}).(bool) + return v +} + +// ServeDNS implements the plugin.Handler interface. +func (d *SIIT) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + // A nested lookup issued as part of an in-flight synthesis (SIIT's own, + // or another translation plugin's further up the chain, e.g. dns64) + // must not be intercepted again — just pass it through. Otherwise SIIT + // and dns64 chained together can trigger each other's internal lookups + // indefinitely: SIIT's AAAA synthesis lookup re-enters the server and + // reaches dns64, whose own internal A lookup (issued with that same + // context) re-enters the server and reaches SIIT again as a plain A + // query, which SIIT would otherwise translate by issuing yet another + // AAAA lookup, and so on with no bound. + if isSynthesisLookup(ctx) { + return plugin.NextOrFailure(d.Name(), d.Next, ctx, w, r) + } + + // Don't proxy if we don't need to. + if !d.requestShouldIntercept(&request.Request{W: w, Req: r}) { + return plugin.NextOrFailure(d.Name(), d.Next, ctx, w, r) + } + + // Pass the request to the next plugin in the chain, but intercept the response. + nw := nonwriter.New(w) + origRc, origErr := d.Next.ServeDNS(ctx, nw, r) + if nw.Msg == nil { // somehow we didn't get a response (or raw bytes were written) + return origRc, origErr + } + + // If the response doesn't need SIIT, short-circuit. + if !d.responseShouldSIIT(&request.Request{W: w, Req: r}, nw.Msg) { + w.WriteMsg(nw.Msg) + return origRc, origErr + } + + // otherwise do the actual SIIT request and response synthesis + msg, synthesized, err := d.DoSIIT(ctx, w, r, nw.Msg) + if err != nil { + // err means we weren't able to even issue the A or AAAA request + // to CoreDNS upstream + return dns.RcodeServerFailure, err + } + + if synthesized { + RequestsTranslatedCount.WithLabelValues(metrics.WithServer(ctx)).Inc() + } + w.WriteMsg(msg) + return msg.Rcode, nil +} + +// Name implements the Handler interface. +func (d *SIIT) Name() string { return "siit" } + +// requestShouldIntercept returns true if the request represents one that is eligible +// for SIIT rewriting: +// 2. The request is of type A +// 3. The request is of class INET +func (d *SIIT) requestShouldIntercept(req *request.Request) bool { + // Do not modify if question is not A or not of class IN. See RFC 6147 5.1 + return (req.QType() == dns.TypeA) && req.QClass() == dns.ClassINET +} + +// responseShouldSIIT returns true if the response indicates we should attempt +// SIIT rewriting: +// 1. The response has no valid (RFC 5.1.4) A records (RFC 5.1.1) +// 2. The response code (RCODE) is not 3 (Name Error) (RFC 5.1.2) +// +// Note that requestShouldIntercept must also have been true, so the request +// is known to be of type A. +func (d *SIIT) responseShouldSIIT(req *request.Request, origResponse *dns.Msg) bool { + if origResponse.Truncated { + return false + } + + ty, _ := response.Typify(origResponse, time.Now().UTC()) + + // Handle NameError normally. See RFC 6147 5.1.2 + // All other error types are "equivalent" to empty response + if ty == response.NameError { + return false + } + + // if response includes A record for an A request, no need to rewrite + for _, rr := range origResponse.Answer { + if rr.Header().Rrtype == dns.TypeA && req.QType() == dns.TypeA { + return false + } + } + + return true +} + +// DoSIIT takes an (empty) response to an A question, issues the AAAA request, +// and synthesizes the answer. Returns the response message, or error on internal failure. +func (d *SIIT) DoSIIT(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, origResponse *dns.Msg) (*dns.Msg, bool, error) { + req := request.Request{W: w, Req: r} + defaultreq := dns.TypeAAAA + + resp, err := d.Upstream.Lookup(markSynthesisLookup(ctx), req, req.Name(), defaultreq) + + if err != nil { + return nil, false, err + } + + if resp == nil { + // Upstream.Lookup isn't expected to return (nil, nil), but guard + // against it explicitly rather than let ServeDNS write a nil + // message and then dereference msg.Rcode. + return nil, false, errors.New("siit: upstream lookup returned no response") + } + + if resp.Rcode != dns.RcodeSuccess { + // Not a transport error — we got an answer, it's just SERVFAIL/ + // REFUSED/NXDOMAIN/etc. resp still carries the *internal* AAAA + // question we issued, not the client's original A question. + // RFC 6147 §5.4 requires the assembled response to carry the + // original initiator's question/ID/transaction framing, so we + // can't return resp as-is — rebuild the envelope from the + // original request and copy over resp's RCODE, flags, and + // authority/additional sections. + return d.copyErrorResponse(r, resp), false, nil + } + + out, synthesized := d.Synthesize(r, origResponse, resp) + return out, synthesized, nil +} + +// copyErrorResponse builds a response addressed to the client's original +// request — correct ID, Question, and QR/Opcode framing via SetReply — while +// carrying over the secondary AAAA lookup's RCODE, AA/RA/TC flags, answer, +// authority, and additional sections. Used when the internal AAAA lookup +// itself failed (SERVFAIL, NXDOMAIN, REFUSED, etc.) so the client never sees +// the internal AAAA question in reply to its A query. +func (d *SIIT) copyErrorResponse(origReq, resp *dns.Msg) *dns.Msg { + ret := new(dns.Msg) + ret.SetReply(origReq) // sets Id, Question, Opcode, RD/CD from origReq + + ret.Rcode = resp.Rcode + ret.Authoritative = resp.Authoritative + ret.RecursionAvailable = resp.RecursionAvailable + ret.Truncated = resp.Truncated + + ret.Answer = resp.Answer + ret.Ns = resp.Ns + ret.Extra = resp.Extra + + return ret +} + +// Synthesize merges the AAAA response and the records from the A response. +// The bool return reports whether at least one AAAA record was actually +// translated into a synthetic A record. false means the caller got back +// origResponse unchanged — nothing in the AAAA answer was mappable (no EAM +// entry, and the address wasn't in ipv6_prefix) — and this must not be +// counted as a translated request. +func (d *SIIT) Synthesize(origReq, origResponse, resp *dns.Msg) (*dns.Msg, bool) { + ret := dns.Msg{} + ret.SetReply(origReq) + + mappedAny := false + + ret.Authoritative = resp.Authoritative + ret.RecursionAvailable = resp.RecursionAvailable + ret.AuthenticatedData = false + + // persist truncated state of AAAA or A response + ret.Truncated = resp.Truncated + + // 5.3.2: SIIT MUST pass the additional section unchanged + ret.Extra = resp.Extra + ret.Ns = resp.Ns + + // 5.1.7: The TTL is the minimum of the A RR and the SOA RR. If SOA is + // unknown, then the TTL is the minimum of A TTL and 600 + SOATtl := uint32(600) // Default NS record TTL + for _, ns := range origResponse.Ns { + if ns.Header().Rrtype == dns.TypeSOA { + SOATtl = ns.Header().Ttl + } + } + + ret.Answer = make([]dns.RR, 0, len(resp.Answer)) + // convert A records to AAAA records + // and vice-versa + for _, rr := range resp.Answer { + header := rr.Header() + // 5.3.3: All other RR's MUST be returned unchanged + if header.Rrtype != dns.TypeAAAA { + ret.Answer = append(ret.Answer, rr) + continue + } + + if header.Rrtype == dns.TypeAAAA { + a, mapped := to4(d.Eam4, d.IPv6Prefix, rr.(*dns.AAAA).AAAA) + if !mapped { + // No EAM entry and address isn't in ipv6_prefix — nothing to synthesize + // for this record; skip it rather than emitting an empty-RDATA A record. + continue + } + + mappedAny = true + + // ttl is min of SOA TTL and A TTL + ttl := min(rr.Header().Ttl, SOATtl) + + // Replace AAAA answer with a SIIT A answer + ret.Answer = append(ret.Answer, &dns.A{ + Hdr: dns.RR_Header{ + Name: header.Name, + Rrtype: dns.TypeA, + Class: header.Class, + Ttl: ttl, + }, + A: a.To16(), + }) + } + } + + if !mappedAny { + return origResponse, false + } + + return &ret, true +} + +// extractIPv4 reverses CoreDNS's dns64 embedding logic: given a v6 address +// that was built by embedding a v4 address into prefix, it extracts that +// v4 address back out. prefix must be a valid NAT64 prefix length per +// RFC 6052 (/32, /40, /48, /56, /64, or /96). +func extractIPv4(v6 net.IP, prefix *net.IPNet) net.IP { + n, _ := prefix.Mask.Size() + v6 = v6.To16() + + addr := make([]byte, 4) + i, j := n/8, 0 // skip the prefix bytes, we don't need them back + + for ; i < 8; i, j = i+1, j+1 { + addr[j] = v6[i] + } + if i == 8 { + i++ // skip the reserved "u" byte + } + for ; j < 4; i, j = i+1, j+1 { + addr[j] = v6[i] + } + + return net.IP(addr) +} + +// to4 takes an IPv6 address and an eam and returns an IPv4 address. +func to4(eam map[string]net.IP, ipv6prefix *net.IPNet, addr net.IP) (net.IP, bool) { + addr = addr.To16() + if addr == nil || addr.To4() != nil { + return nil, false + } + + // RFC 7757 §3.3.2: search the EAM table first. + if eam[addr.String()] != nil { + v4 := eam[addr.String()] + return v4, true + } + + // Fall back to RFC 6052 algorithmic translation only if no EAM matched. + if ipv6prefix.Contains(addr) { + // RFC 6052 §2.2: byte 8 (bits 64-71, the "u" octet) is reserved and + // MUST be zero. A correctly-configured prefix guarantees this for + // its own bits (validated at setup time), but for prefix lengths + // shorter than /96 that byte belongs to the address, not the + // prefix, and an upstream AAAA answer isn't required to zero it. + // Reject rather than silently translating from a malformed address. + if addr[8] != 0 { + return nil, false + } + v4 := extractIPv4(addr, ipv6prefix) + return v4, true + } + + return nil, false +} diff --git a/plugin/siit/siit_dns64_test.go b/plugin/siit/siit_dns64_test.go new file mode 100644 index 000000000..0d106c685 --- /dev/null +++ b/plugin/siit/siit_dns64_test.go @@ -0,0 +1,97 @@ +package siit + +import ( + "context" + "net" + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/dns64" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// emptyBackend always answers NOERROR/no-data for whatever it's asked. +type emptyBackend struct{} + +func (emptyBackend) Name() string { return "empty" } +func (emptyBackend) ServeDNS(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + m := new(dns.Msg) + m.SetReply(r) + m.Rcode = dns.RcodeSuccess + w.WriteMsg(m) + return dns.RcodeSuccess, nil +} + +// reentrantUpstream re-invokes top.ServeDNS for every Lookup call, exactly +// as CoreDNS's real Upstream/lookup plugin re-enters the whole server. It +// fails the test if the recursion depth exceeds a small bound, so a +// regression shows up as a test failure instead of a hang/stack overflow. +type reentrantUpstream struct { + t *testing.T + top plugin.Handler + calls *int + maxOK int +} + +func (u reentrantUpstream) Lookup(ctx context.Context, state request.Request, name string, typ uint16) (*dns.Msg, error) { + *u.calls++ + if *u.calls > u.maxOK { + u.t.Fatalf("recursion exceeded bound: %d internal lookups (siit/dns64 re-entering each other)", *u.calls) + } + m := new(dns.Msg) + m.SetQuestion(name, typ) + rec := dnstest.NewRecorder(state.W) + _, err := u.top.ServeDNS(ctx, rec, m) + return rec.Msg, err +} + +// TestSIITDNS64NoRecursion reproduces the reported handler order +// (dns64 -> siit -> empty backend) and asserts the exchange completes +// without unbounded internal recursion. It's parameterized over both the +// IPv6-client / default-dns64 path and the IPv4-client / allow_ipv4 path, +// since they exercise different branches of dns64's interception logic and +// a fixed ResponseWriter family would silently only cover one of them. +func TestSIITDNS64NoRecursion(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + + cases := []struct { + name string + rw dns.ResponseWriter + allowIPv4 bool + }{ + {"IPv6 client, default dns64", &test.ResponseWriter6{}, false}, + {"IPv4 client, allow_ipv4", &test.ResponseWriter{}, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + + s := &SIIT{IPv6Prefix: prefix, Next: emptyBackend{}} + d := &dns64.DNS64{Prefix: prefix, AllowIPv4: tc.allowIPv4, Next: s} + + up := reentrantUpstream{t: t, top: d, calls: &calls, maxOK: 4} + s.Upstream = up + d.Upstream = up + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + + rec := dnstest.NewRecorder(tc.rw) + rc, err := d.ServeDNS(context.Background(), rec, r) + if err != nil { + t.Fatalf("ServeDNS returned error: %v", err) + } + if rc != dns.RcodeSuccess { + t.Fatalf("unexpected rcode: %v", rc) + } + if calls != 2 { + t.Fatalf("expected exactly 2 internal lookups (siit's AAAA lookup + dns64's nested A lookup), got %d", calls) + } + }) + } +} diff --git a/plugin/siit/siit_test.go b/plugin/siit/siit_test.go new file mode 100644 index 000000000..1f5131a43 --- /dev/null +++ b/plugin/siit/siit_test.go @@ -0,0 +1,1164 @@ +package siit + +import ( + "context" + "fmt" + "net" + "reflect" + "testing" + + "github.com/coredns/coredns/plugin/metrics" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// TestToUnmappedAAAA reproduces the empty-RDATA-A-record bug: an AAAA +// address that matches neither an EAM entry nor ipv6_prefix must not +// produce an A record at all. +func TestToUnmappedAAAA(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + eam := map[string]net.IP{} + + unrelated := net.ParseIP("2001:db8::1") + a, mapped := to4(eam, prefix, unrelated) + if mapped { + t.Fatalf("expected no mapping for unrelated AAAA address, got %v", a) + } +} + +// TestToEamPrecedence reproduces the reversed-lookup-order bug: when an +// address matches both ipv6_prefix and an explicit eam entry, the eam +// entry must win. +func TestToEamPrecedence(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + eamAddr := net.ParseIP("64:ff9b::192.0.2.1") + + eam := make(map[string]net.IP) + eam[eamAddr.String()] = net.ParseIP("203.0.113.9") + + a, mapped := to4(eam, prefix, eamAddr) + if !mapped { + t.Fatalf("expected eam mapping to be found") + } + want := net.ParseIP("203.0.113.9").To4() + if !a.Equal(want) { + t.Errorf("expected eam-mapped address %v, got %v (algorithmic translation was used instead)", want, a) + } +} + +// TestToAlgorithmicFallback verifies RFC 6052 translation still applies +// when no eam entry matches. +func TestToAlgorithmicFallback(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + eam := map[string]net.IP{} + addr := net.ParseIP("64:ff9b::192.0.2.42") + + a, mapped := to4(eam, prefix, addr) + if !mapped { + t.Fatalf("expected algorithmic translation to apply") + } + want := net.ParseIP("192.0.2.42").To4() + if !a.Equal(want) { + t.Errorf("expected %v, got %v", want, a) + } +} + +func TestSIIT(t *testing.T) { + var cases = []struct { + // a brief summary of the test case + name string + + // the request + req *dns.Msg + + // the initial response from the "downstream" server + initResp *dns.Msg + + // A response to provide + aResp *dns.Msg + + // the expected ultimate result + resp *dns.Msg + }{ + { + // no A record, yes AAAA record. Do SIIT + name: "standard flow", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + RecursionDesired: true, + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + initResp: &dns.Msg{ //success, no answers + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 70 IN SOA foo bar 1 1 1 1 1")}, + }, + aResp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 43, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.AAAA("example.com. 60 IN AAAA 64:ff9b::192.0.2.42"), + test.AAAA("example.com. 5000 IN AAAA 64:ff9b::192.0.2.43"), + }, + }, + + resp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.A("example.com. 60 IN A 192.0.2.42"), + // override RR ttl to SOA ttl, since it's lower + test.A("example.com. 70 IN A 192.0.2.43"), + }, + }, + }, + { + // name exists, but has neither A nor AAAA record + name: "aaaa empty", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + RecursionDesired: true, + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + initResp: &dns.Msg{ //success, no answers + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 3600 IN SOA foo bar 1 7200 900 1209600 86400")}, + }, + aResp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 43, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 3600 IN SOA foo bar 1 7200 900 1209600 86400")}, + }, + + resp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 3600 IN SOA foo bar 1 7200 900 1209600 86400")}, + }, + }, + { + // name exists, but AAAA record is not synthesized + name: "aaaa empty", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + RecursionDesired: true, + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + initResp: &dns.Msg{ //success, no answers + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 3600 IN SOA foo bar 1 7200 900 1209600 86400")}, + }, + aResp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 43, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.AAAA("example.com. 60 IN AAAA 2001:db8::1"), + }, + }, + + resp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 3600 IN SOA foo bar 1 7200 900 1209600 86400")}, + }, + }, + { + // name exists, but AAAA records are a mix of synthesized and not + name: "aaaa empty", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + RecursionDesired: true, + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + initResp: &dns.Msg{ //success, no answers + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 3600 IN SOA foo bar 1 7200 900 1209600 86400")}, + }, + aResp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 43, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.AAAA("example.com. 60 IN AAAA 2001:db8::1"), + test.AAAA("example.com. 60 IN AAAA 64:ff9b::192.0.2.42"), + }, + }, + + resp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.A("example.com. 60 IN A 192.0.2.42"), + }, + }, + }, + { + // Query error other than NameError + name: "non-nxdomain error", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + RecursionDesired: true, + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + initResp: &dns.Msg{ // failure + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeRefused, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + aResp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 43, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.AAAA("example.com. 60 IN AAAA 64:ff9b::192.0.2.42"), + test.AAAA("example.com. 5000 IN AAAA 64:ff9b::192.0.2.43"), + }, + }, + + resp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.A("example.com. 60 IN A 192.0.2.42"), + test.A("example.com. 600 IN A 192.0.2.43"), + }, + }, + }, + { + // nxdomain (NameError): don't even try an AAAA request. + name: "nxdomain", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + RecursionDesired: true, + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + initResp: &dns.Msg{ // failure + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeNameError, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 3600 IN SOA foo bar 1 7200 900 1209600 86400")}, + }, + resp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeNameError, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 3600 IN SOA foo bar 1 7200 900 1209600 86400")}, + }, + }, + { + // A record exists + name: "A record", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + RecursionDesired: true, + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + + initResp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.A("example.com. 60 IN A 127.0.0.1"), + }, + }, + + resp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.A("example.com. 60 IN A 127.0.0.1"), + }, + }, + }, + { + // no A records, AAAA record response truncated. + name: "truncated AAAA response", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + RecursionDesired: true, + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + initResp: &dns.Msg{ //success, no answers + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 70 IN SOA foo bar 1 1 1 1 1")}, + }, + aResp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 43, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Truncated: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.AAAA("example.com. 60 IN AAAA 64:ff9b::192.0.2.42"), + test.AAAA("example.com. 5000 IN AAAA 64:ff9b::192.0.2.43"), + }, + }, + + resp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Truncated: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.A("example.com. 60 IN A 192.0.2.42"), + // override RR ttl to SOA ttl, since it's lower + test.A("example.com. 70 IN A 192.0.2.43"), + }, + }, + }, + { + // no A records, AAAA record response via eam. + name: "eam AAAA response", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + RecursionDesired: true, + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + initResp: &dns.Msg{ //success, no answers + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Ns: []dns.RR{test.SOA("example.com. 70 IN SOA foo bar 1 1 1 1 1")}, + }, + aResp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 43, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Truncated: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.AAAA("example.com. 60 IN AAAA 64:dead::1"), + test.AAAA("example.com. 5000 IN AAAA 64:dead::2"), + }, + }, + + resp: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Truncated: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + Answer: []dns.RR{ + test.A("example.com. 60 IN A 10.0.0.1"), + // override RR ttl to SOA ttl, since it's lower + test.A("example.com. 70 IN A 10.0.0.2"), + }, + }, + }, + } + + _, pfx, _ := net.ParseCIDR("64:ff9b::/96") + + eam4 := make(map[string]net.IP) + eam4["64:dead::1"] = net.ParseIP("10.0.0.1") + eam4["64:dead::2"] = net.ParseIP("10.0.0.2") + + for idx, tc := range cases { + t.Run(fmt.Sprintf("%d_%s", idx, tc.name), func(t *testing.T) { + d := SIIT{ + Next: &fakeHandler{t, tc.initResp}, + IPv6Prefix: pfx, + Eam4: eam4, + Upstream: &fakeUpstream{t, tc.req.Question[0].Name, tc.aResp}, + } + + rec := dnstest.NewRecorder(&test.ResponseWriter{RemoteIP: "::1"}) + rc, err := d.ServeDNS(context.Background(), rec, tc.req) + if err != nil { + t.Fatal(err) + } + actual := rec.Msg + if actual.Rcode != rc { + t.Fatalf("ServeDNS should return real result code %q != %q", actual.Rcode, rc) + } + + if !reflect.DeepEqual(actual, tc.resp) { + t.Fatalf("Final answer should match expected %q != %q", actual, tc.resp) + } + }) + } +} + +type fakeHandler struct { + t *testing.T + reply *dns.Msg +} + +func (fh *fakeHandler) ServeDNS(_ context.Context, w dns.ResponseWriter, _ *dns.Msg) (int, error) { + if fh.reply == nil { + panic("fakeHandler ServeDNS with nil reply") + } + w.WriteMsg(fh.reply) + + return fh.reply.Rcode, nil +} +func (fh *fakeHandler) Name() string { + return "fake" +} + +type fakeUpstream struct { + t *testing.T + qname string + resp *dns.Msg +} + +func (fu *fakeUpstream) Lookup(_ context.Context, _ request.Request, name string, typ uint16) (*dns.Msg, error) { + if fu.qname == "" { + fu.t.Fatalf("Unexpected A lookup for %s", name) + } + if name != fu.qname { + fu.t.Fatalf("Wrong A lookup for %s, expected %s", name, fu.qname) + } + + if typ != dns.TypeA && typ != dns.TypeAAAA { + fu.t.Fatalf("Wrong lookup type %d, expected %d or %d", typ, dns.TypeA, dns.TypeAAAA) + } + + return fu.resp, nil +} + +func TestDoSIITNegativeResponse(t *testing.T) { + origResponse := new(dns.Msg) + origResponse.SetQuestion("example.org.", dns.TypeA) + origResponse.Rcode = dns.RcodeSuccess // the client's original A answer + + aaaaFailure := new(dns.Msg) + aaaaFailure.SetQuestion("example.org.", dns.TypeAAAA) // internal AAAA question + aaaaFailure.Rcode = dns.RcodeServerFailure // upstream AAAA lookup SERVFAILs + aaaaFailure.RecursionAvailable = true + + d := &SIIT{ + Upstream: &fakeUpstream{ + t: t, + qname: "example.org.", + resp: aaaaFailure, + }, + } + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + r.Id = 42 + w := &test.ResponseWriter{} + + out, synthesized, err := d.DoSIIT(context.Background(), w, r, origResponse) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if synthesized { + t.Errorf("expected synthesized=false, no A record was actually produced") + } + if out == aaaaFailure { + t.Fatalf("expected DoSIIT to build a new response addressed to the client, not return the internal AAAA lookup response as-is") + } + if out.Rcode != aaaaFailure.Rcode { + t.Errorf("expected Rcode %v to be carried over from the lookup, got %v", aaaaFailure.Rcode, out.Rcode) + } + if out.Id != r.Id { + t.Errorf("expected response ID %v to match the client's request, got %v", r.Id, out.Id) + } + if len(out.Question) != 1 || out.Question[0].Qtype != dns.TypeA || out.Question[0].Name != "example.org." { + t.Fatalf("expected the original A question to be preserved in the response, got %+v", out.Question) + } + if !out.RecursionAvailable { + t.Errorf("expected RecursionAvailable to be carried over from the lookup response") + } +} + +func TestDoSIITNXDOMAIN(t *testing.T) { + origResponse := new(dns.Msg) + origResponse.SetQuestion("example.org.", dns.TypeA) + origResponse.Rcode = dns.RcodeSuccess + + soa := test.SOA("example.org. 3600 IN SOA foo bar 1 7200 900 1209600 86400") + + aaaaNX := new(dns.Msg) + aaaaNX.SetQuestion("example.org.", dns.TypeAAAA) + aaaaNX.Rcode = dns.RcodeNameError + aaaaNX.Authoritative = true + aaaaNX.Ns = []dns.RR{soa} + + d := &SIIT{ + Upstream: &fakeUpstream{ + t: t, + qname: "example.org.", + resp: aaaaNX, + }, + } + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + r.Id = 43 + w := &test.ResponseWriter{} + + out, synthesized, err := d.DoSIIT(context.Background(), w, r, origResponse) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if synthesized { + t.Errorf("expected synthesized=false, no A record was actually produced") + } + if out == aaaaNX { + t.Fatalf("expected DoSIIT to build a new response addressed to the client, not return the internal AAAA lookup response as-is") + } + if out.Rcode != aaaaNX.Rcode { + t.Errorf("expected Rcode %v from lookup, got %v", aaaaNX.Rcode, out.Rcode) + } + if out.Id != r.Id { + t.Errorf("expected response ID %v to match the client's request, got %v", r.Id, out.Id) + } + if len(out.Question) != 1 || out.Question[0].Qtype != dns.TypeA || out.Question[0].Name != "example.org." { + t.Fatalf("expected the original A question to be preserved in the response, got %+v", out.Question) + } + if !out.Authoritative { + t.Errorf("expected Authoritative to be carried over from the lookup response") + } + if !reflect.DeepEqual(out.Ns, aaaaNX.Ns) { + t.Errorf("expected authority section (SOA) to be carried over, got %+v", out.Ns) + } +} + +// TestDoSIITNilResponse covers Upstream.Lookup returning (nil, nil): DoSIIT +// must surface this as an error rather than pass a nil message on to a +// caller that will dereference it. +func TestDoSIITNilResponse(t *testing.T) { + origResponse := new(dns.Msg) + origResponse.SetQuestion("example.org.", dns.TypeA) + origResponse.Rcode = dns.RcodeSuccess + + d := &SIIT{ + Upstream: &fakeUpstream{ + t: t, + qname: "example.org.", + resp: nil, // simulate a well-behaved Upstream returning (nil, nil) + }, + } + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + w := &test.ResponseWriter{} + + out, synthesized, err := d.DoSIIT(context.Background(), w, r, origResponse) + if err == nil { + t.Fatal("expected an error for a nil upstream response, got nil") + } + if synthesized { + t.Errorf("expected synthesized=false, no A record was actually produced") + } + if out != nil { + t.Errorf("expected a nil message alongside the error, got %+v", out) + } +} + +func TestServeDNSUpstreamSERVFAIL(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + + initResp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + } + + aaaaFailure := new(dns.Msg) + aaaaFailure.SetQuestion("example.org.", dns.TypeAAAA) + aaaaFailure.Rcode = dns.RcodeServerFailure + aaaaFailure.RecursionAvailable = true + + d := &SIIT{ + Next: &fakeHandler{t, initResp}, + IPv6Prefix: prefix, + Upstream: &fakeUpstream{ + t: t, + qname: "example.org.", + resp: aaaaFailure, + }, + } + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + r.Id = 42 + r.RecursionDesired = true + + rec := dnstest.NewRecorder(&test.ResponseWriter{RemoteIP: "::1"}) + rc, err := d.ServeDNS(context.Background(), rec, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rc != dns.RcodeServerFailure { + t.Fatalf("expected SERVFAIL rcode, got %v", rc) + } + if rec.Msg == nil { + t.Fatal("expected a response to be written") + } + if rec.Msg.Id != r.Id { + t.Errorf("expected response ID %v to match the client's request, got %v", r.Id, rec.Msg.Id) + } + if len(rec.Msg.Question) != 1 || rec.Msg.Question[0].Qtype != dns.TypeA || rec.Msg.Question[0].Name != "example.org." { + t.Fatalf("expected the original A question to be preserved in the response, got %+v", rec.Msg.Question) + } + if !rec.Msg.RecursionAvailable { + t.Errorf("expected RecursionAvailable to be carried over from the internal AAAA lookup") + } +} + +func TestServeDNSUpstreamNXDOMAIN(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + + initResp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 7, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + } + + soa := test.SOA("example.org. 3600 IN SOA foo bar 1 7200 900 1209600 86400") + aaaaNX := new(dns.Msg) + aaaaNX.SetQuestion("example.org.", dns.TypeAAAA) + aaaaNX.Rcode = dns.RcodeNameError + aaaaNX.Authoritative = true + aaaaNX.Ns = []dns.RR{soa} + + d := &SIIT{ + Next: &fakeHandler{t, initResp}, + IPv6Prefix: prefix, + Upstream: &fakeUpstream{ + t: t, + qname: "example.org.", + resp: aaaaNX, + }, + } + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + r.Id = 7 + r.RecursionDesired = true + + rec := dnstest.NewRecorder(&test.ResponseWriter{RemoteIP: "::1"}) + rc, err := d.ServeDNS(context.Background(), rec, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rc != dns.RcodeNameError { + t.Fatalf("expected NXDOMAIN rcode, got %v", rc) + } + if len(rec.Msg.Question) != 1 || rec.Msg.Question[0].Qtype != dns.TypeA { + t.Fatalf("expected the original A question to be preserved, got %+v", rec.Msg.Question) + } + if !rec.Msg.Authoritative { + t.Errorf("expected Authoritative to be carried over from the internal AAAA lookup") + } + if !reflect.DeepEqual(rec.Msg.Ns, aaaaNX.Ns) { + t.Errorf("expected authority (SOA) to be carried over, got %+v", rec.Msg.Ns) + } +} + +func TestServeDNSUpstreamNilResponse(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + + initResp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 99, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + } + + d := &SIIT{ + Next: &fakeHandler{t, initResp}, + IPv6Prefix: prefix, + Upstream: &fakeUpstream{ + t: t, + qname: "example.org.", + resp: nil, // simulate a well-behaved Upstream returning (nil, nil) + }, + } + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + r.Id = 99 + r.RecursionDesired = true + + rec := dnstest.NewRecorder(&test.ResponseWriter{RemoteIP: "::1"}) + rc, err := d.ServeDNS(context.Background(), rec, r) + if err == nil { + t.Fatal("expected an error from ServeDNS for a nil upstream response") + } + if rc != dns.RcodeServerFailure { + t.Fatalf("expected SERVFAIL rcode, got %v", rc) + } +} + +// TestSynthesizePreservesAuthoritativeAndRecursive verifies RFC 6147 §5.4: +// the synthesized response's AA/RA flags must reflect the secondary AAAA +// response (where the data actually came from), not be unconditionally +// cleared by SetReply. +func TestSynthesizePreservesAuthoritativeAndRecursive(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + + cases := []struct { + name string + authoritative bool + recursive bool + }{ + {"authoritative only", true, false}, + {"recursive only", false, true}, + {"both", true, true}, + {"neither", false, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := &SIIT{IPv6Prefix: prefix} + + origReq := new(dns.Msg) + origReq.SetQuestion("example.org.", dns.TypeA) + origReq.Id = 55 + + origResponse := new(dns.Msg) + origResponse.SetReply(origReq) + + resp := new(dns.Msg) + resp.SetQuestion("example.org.", dns.TypeAAAA) + resp.Rcode = dns.RcodeSuccess + resp.Authoritative = tc.authoritative + resp.RecursionAvailable = tc.recursive + resp.AuthenticatedData = true // must NOT survive synthesis + resp.Answer = []dns.RR{ + test.AAAA("example.org. 60 IN AAAA 64:ff9b::192.0.2.1"), + } + + out, _ := d.Synthesize(origReq, origResponse, resp) + + if out.Authoritative != tc.authoritative { + t.Errorf("Authoritative: expected %v, got %v", tc.authoritative, out.Authoritative) + } + if out.RecursionAvailable != tc.recursive { + t.Errorf("RecursionAvailable: expected %v, got %v", tc.recursive, out.RecursionAvailable) + } + if out.AuthenticatedData { + t.Errorf("expected AuthenticatedData to be cleared on a synthesized response, got true") + } + if out.Id != origReq.Id { + t.Errorf("expected ID %v to be preserved from the original request, got %v", origReq.Id, out.Id) + } + if len(out.Question) != 1 || out.Question[0].Qtype != dns.TypeA { + t.Fatalf("expected original A question to be preserved, got %+v", out.Question) + } + }) + } +} + +// TestServeDNSTruncatedInitialResponseNotSynthesized reproduces the +// hidden-truncation bug: an initial A response with TC=1 and no visible +// Answer must not be treated as NODATA and synthesized from a mapped AAAA +// record. Doing so would return TC=0 to the client, who would then never +// retry over TCP to recover the real A RR the original truncation hid. +func TestServeDNSTruncatedInitialResponseNotSynthesized(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + + initResp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 42, + Opcode: dns.OpcodeQuery, + RecursionDesired: true, + Truncated: true, + Rcode: dns.RcodeSuccess, + Response: true, + }, + Question: []dns.Question{{Name: "example.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + // no Answer -- looks like NODATA, but TC=1 means it's incomplete + } + + d := &SIIT{ + Next: &fakeHandler{t, initResp}, + IPv6Prefix: prefix, + // qname left empty: any Lookup call fails the test outright, since + // a truncated initial response must short-circuit before ever + // issuing the secondary AAAA lookup. + Upstream: &fakeUpstream{t: t}, + } + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + r.Id = 42 + r.RecursionDesired = true + + rec := dnstest.NewRecorder(&test.ResponseWriter{RemoteIP: "::1"}) + rc, err := d.ServeDNS(context.Background(), rec, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rc != dns.RcodeSuccess { + t.Fatalf("unexpected rcode: %v", rc) + } + if rec.Msg == nil { + t.Fatal("expected a response to be written") + } + if !rec.Msg.Truncated { + t.Fatalf("expected TC=1 to be preserved so the client retries over TCP, got TC=%v", rec.Msg.Truncated) + } + if len(rec.Msg.Answer) != 0 { + t.Fatalf("expected no synthesized answer for a truncated initial response, got %+v", rec.Msg.Answer) + } +} + +// TestServeDNSMetricCounting verifies RequestsTranslatedCount is +// incremented exactly when a synthetic A record was actually produced, and +// left untouched for the secondary-failure and unmapped-only cases that +// previously overcounted. +func TestServeDNSMetricCounting(t *testing.T) { + _, prefix, _ := net.ParseCIDR("64:ff9b::/96") + + emptyAResp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: 1, Opcode: dns.OpcodeQuery, RecursionDesired: true, + Rcode: dns.RcodeSuccess, Response: true, + }, + Question: []dns.Question{{Name: "example.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + } + + cases := []struct { + name string + aaaaResp *dns.Msg + wantIncrease float64 + }{ + { + name: "successful synthesis increments", + aaaaResp: func() *dns.Msg { + m := new(dns.Msg) + m.SetQuestion("example.org.", dns.TypeAAAA) + m.Rcode = dns.RcodeSuccess + m.Answer = []dns.RR{test.AAAA("example.org. 60 IN AAAA 64:ff9b::192.0.2.1")} + return m + }(), + wantIncrease: 1, + }, + { + name: "secondary SERVFAIL does not increment", + aaaaResp: func() *dns.Msg { + m := new(dns.Msg) + m.SetQuestion("example.org.", dns.TypeAAAA) + m.Rcode = dns.RcodeServerFailure + return m + }(), + wantIncrease: 0, + }, + { + name: "unmapped-only AAAA does not increment", + aaaaResp: func() *dns.Msg { + m := new(dns.Msg) + m.SetQuestion("example.org.", dns.TypeAAAA) + m.Rcode = dns.RcodeSuccess + m.Answer = []dns.RR{test.AAAA("example.org. 60 IN AAAA 2001:db8::1")} // outside prefix, no eam + return m + }(), + wantIncrease: 0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := &SIIT{ + Next: &fakeHandler{t, emptyAResp}, + IPv6Prefix: prefix, + Upstream: &fakeUpstream{t, "example.org.", tc.aaaaResp}, + } + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + ctx := context.Background() + + label := metrics.WithServer(ctx) + before := testutil.ToFloat64(RequestsTranslatedCount.WithLabelValues(label)) + + rec := dnstest.NewRecorder(&test.ResponseWriter{RemoteIP: "::1"}) + if _, err := d.ServeDNS(ctx, rec, r); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + after := testutil.ToFloat64(RequestsTranslatedCount.WithLabelValues(label)) + if got := after - before; got != tc.wantIncrease { + t.Errorf("expected counter to increase by %v, got %v (before=%v after=%v)", tc.wantIncrease, got, before, after) + } + }) + } +} + +// embedIPv4 mirrors the RFC 6052 §2.2 embedding algorithm — the exact +// mirror image of extractIPv4 — so these tests can construct known-good +// NAT64 addresses independently of production code. +func embedIPv4(prefix net.IP, prefixLen int, v4 net.IP) net.IP { + v4 = v4.To4() + out := make([]byte, 16) + copy(out, prefix.To16()[:prefixLen/8]) + + i, j := prefixLen/8, 0 + for ; i < 8; i, j = i+1, j+1 { + out[i] = v4[j] + } + if i == 8 { + i++ // reserved "u" byte stays zero + } + for ; j < 4; i, j = i+1, j+1 { + out[i] = v4[j] + } + return net.IP(out) +} + +// TestExtractIPv4RFC6052Vectors uses the six literal example addresses from +// RFC 6052 §2.2 Table 1 ("Text Representation of IPv4-Embedded IPv6 +// Addresses Using Network-Specific Prefixes"), rather than round-tripping +// through embedIPv4. embedIPv4 mirrors the same byte-placement algorithm as +// extractIPv4, so a shared offset bug in both helpers could still round-trip +// successfully and this test would never catch it. These fixtures are +// copied verbatim from the RFC, independent of any code in this package. +func TestExtractIPv4RFC6052Vectors(t *testing.T) { + want := net.ParseIP("192.0.2.33").To4() + + cases := []struct { + prefixCIDR string // Network-Specific Prefix column + addr string // IPv4-embedded IPv6 address column + }{ + {"2001:db8::/32", "2001:db8:c000:221::"}, + {"2001:db8:100::/40", "2001:db8:1c0:2:21::"}, + {"2001:db8:122::/48", "2001:db8:122:c000:2:2100::"}, + {"2001:db8:122:300::/56", "2001:db8:122:3c0:0:221::"}, + {"2001:db8:122:344::/64", "2001:db8:122:344:c0:2:2100::"}, + {"2001:db8:122:344::/96", "2001:db8:122:344::192.0.2.33"}, + } + + for _, tc := range cases { + t.Run(tc.prefixCIDR, func(t *testing.T) { + _, prefix, err := net.ParseCIDR(tc.prefixCIDR) + if err != nil { + t.Fatalf("bad prefix fixture %q: %v", tc.prefixCIDR, err) + } + addr := net.ParseIP(tc.addr) + if addr == nil { + t.Fatalf("bad address fixture %q", tc.addr) + } + + got := extractIPv4(addr, prefix) + if !got.Equal(want) { + t.Errorf("prefix %s, address %s: expected %v, got %v", tc.prefixCIDR, tc.addr, want, got) + } + }) + } +} + +// TestToRejectsNonzeroUOctet: an address +// whose reserved "u" byte (byte 8) is nonzero must not be algorithmically +// translated, even though it falls within the configured prefix. +func TestToRejectsNonzeroUOctet(t *testing.T) { + _, prefix, _ := net.ParseCIDR("2001:db8:122:344::/64") + eam := map[string]net.IP{} + + // bits 64-71 (byte 8) = 0xff, nonzero -- must be rejected + addr := net.ParseIP("2001:db8:122:344:ffc0:2:2100:0") + + a, mapped := to4(eam, prefix, addr) + if mapped { + t.Fatalf("expected nonzero-u address to be rejected, got mapped address %v", a) + } +} + +// TestToAcceptsZeroUOctet is the counterpart: a well-formed address with +// u == 0 for the same /64 prefix must still translate correctly. +func TestToAcceptsZeroUOctet(t *testing.T) { + _, prefix, _ := net.ParseCIDR("2001:db8:122:344::/64") + eam := map[string]net.IP{} + v4 := net.ParseIP("192.0.2.33").To4() + + addr := embedIPv4(net.ParseIP("2001:db8:122:344::"), 64, v4) + + a, mapped := to4(eam, prefix, addr) + if !mapped { + t.Fatalf("expected zero-u address to translate") + } + if !a.Equal(v4) { + t.Errorf("expected %v, got %v", v4, a) + } +} diff --git a/test/siit_test.go b/test/siit_test.go new file mode 100644 index 000000000..1f995be9d --- /dev/null +++ b/test/siit_test.go @@ -0,0 +1,80 @@ +package test + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnstest" + + "github.com/miekg/dns" +) + +// TestSIITAfterForward is a regression test for the plugin.cfg ordering bug: +// siit must run AFTER forward in the plugin chain so it can see (and rewrite) +// the AAAA answer forward returns, rather than running before it and never +// seeing the final response. +// +// Unlike the unit tests in plugin/siit, which instantiate SIIT directly and +// wire d.Next/d.Upstream to fakes, this test drives the real plugin chain +// built from plugin.cfg via a Corefile, so a future ordering regression +// (siit placed after cache/forward again) will fail here even though it +// can't be detected by the package-level unit tests. +func TestSIITAfterForward(t *testing.T) { + // Upstream "authoritative" server: NODATA for A, a mapped AAAA for AAAA. + upstream := dnstest.NewServer(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + + switch r.Question[0].Qtype { + case dns.TypeA: + // NODATA: success, no answer section -- this is what makes + // siit's responseShouldSIIT return true. + case dns.TypeAAAA: + rr, err := dns.NewRR("example.org. 60 IN AAAA 64:ff9b::192.0.2.42") + if err != nil { + t.Fatalf("failed to build AAAA record: %v", err) + } + m.Answer = []dns.RR{rr} + } + + w.WriteMsg(m) + }) + defer upstream.Close() + + corefile := `example.org:0 { + siit { + ipv6_prefix 64:ff9b::/96 + } + forward . ` + upstream.Addr + ` + }` + + server, udp, _, err := CoreDNSServerAndPorts(corefile) + if err != nil { + t.Fatalf("could not start CoreDNS test server: %v", err) + } + defer server.Stop() + + m := new(dns.Msg) + m.SetQuestion("example.org.", dns.TypeA) + + resp, err := dns.Exchange(m, udp) + if err != nil { + t.Fatalf("query failed: %v", err) + } + + if resp.Rcode != dns.RcodeSuccess { + t.Fatalf("expected RcodeSuccess, got %s", dns.RcodeToString[resp.Rcode]) + } + if len(resp.Answer) != 1 { + t.Fatalf("expected 1 answer record, got %d: %v", len(resp.Answer), resp.Answer) + } + + a, ok := resp.Answer[0].(*dns.A) + if !ok { + t.Fatalf("expected an A record in the answer, got %T: %v", resp.Answer[0], resp.Answer[0]) + } + + want := "192.0.2.42" + if a.A.String() != want { + t.Errorf("expected synthesized A record %s, got %s -- if this is empty/unset, siit likely ran before forward in the plugin chain and never saw the upstream's AAAA answer", want, a.A.String()) + } +}