Repository: miekg/dns
Pull request #1457 (2023-05-02 09:30:22)

Use correct string format IPv4 encoded as IPv6 for for AAAA records
In order to parse an IPv4 address encoded as IPv6, it needs to have the prefix of "::ffff:", i.e. ::ffff:192.0.2.1

This allows export of parsed IPv4 encoded IPv6 AAAA records to be scanned back to a valid AAAA record.
Without it the following code fails as the address is "plain" dotted IPv4-format:
```
rr, err := dns.NewRR(". 1 IN AAAA ::ffff:192.0.2.1")
if err != nil {
    log.Fatalf("parse failure: %w", err)
}

_, err = dns.NewRR(rr.String())
if err != nil {
    log.Fatalf("parse failure: %w", err)
}
```

This comes from the std libs net.IP String() function which outputs IPv4-in-IPV6 as standard dotted-IPv4. net/netip.Addr type, in contrast, differs here in that the String() func outputs it with the proper prefix "::ffff:" Ref: https://pkg.go.dev/net/netip#Addr.String

The other attempt to fix this https://github.com/miekg/dns/issues/1107 and the code in PR https://github.com/miekg/dns/pull/1108 has worse performance than this PR, due to the use of fmt.Sprintf() which is not needed

miekg: I'm sympathetic to merging this as anything we output should be parsed back in.

Can you add a (small) test for this as well?
Chreo: Thanks and sorry for the late response. I've added a test with a few IPv6 addresses that should parse to a proper AAAA record, be able to export in presentation format and the result should be able to parsed again into a new AAAA record.
It may be worth noting that any viable net.IP address string is able to be used to parse into an AAAA record, including "::".
janik-cloudflare: Hi @miekg, just wondering if you've had a chance to look at the updated version yet. We'd love to get this pulled in!

Patch:
diff --git a/msg.go b/msg.go
index d5049a4f..b05cf14e 100644
--- a/msg.go
+++ b/msg.go
@@ -896,23 +896,38 @@ func (dns *Msg) String() string {
 		return "<nil> MsgHdr"
 	}
 	s := dns.MsgHdr.String() + " "
-	s += "QUERY: " + strconv.Itoa(len(dns.Question)) + ", "
-	s += "ANSWER: " + strconv.Itoa(len(dns.Answer)) + ", "
-	s += "AUTHORITY: " + strconv.Itoa(len(dns.Ns)) + ", "
-	s += "ADDITIONAL: " + strconv.Itoa(len(dns.Extra)) + "\n"
+	if dns.MsgHdr.Opcode == OpcodeUpdate {
+		s += "ZONE: " + strconv.Itoa(len(dns.Question)) + ", "
+		s += "PREREQ: " + strconv.Itoa(len(dns.Answer)) + ", "
+		s += "UPDATE: " + strconv.Itoa(len(dns.Ns)) + ", "
+		s += "ADDITIONAL: " + strconv.Itoa(len(dns.Extra)) + "\n"
+	} else {
+		s += "QUERY: " + strconv.Itoa(len(dns.Question)) + ", "
+		s += "ANSWER: " + strconv.Itoa(len(dns.Answer)) + ", "
+		s += "AUTHORITY: " + strconv.Itoa(len(dns.Ns)) + ", "
+		s += "ADDITIONAL: " + strconv.Itoa(len(dns.Extra)) + "\n"
+	}
 	opt := dns.IsEdns0()
 	if opt != nil {
 		// OPT PSEUDOSECTION
 		s += opt.String() + "\n"
 	}
 	if len(dns.Question) > 0 {
-		s += "\n;; QUESTION SECTION:\n"
+		if dns.MsgHdr.Opcode == OpcodeUpdate {
+			s += "\n;; ZONE SECTION:\n"
+		} else {
+			s += "\n;; QUESTION SECTION:\n"
+		}
 		for _, r := range dns.Question {
 			s += r.String() + "\n"
 		}
 	}
 	if len(dns.Answer) > 0 {
-		s += "\n;; ANSWER SECTION:\n"
+		if dns.MsgHdr.Opcode == OpcodeUpdate {
+			s += "\n;; PREREQUISITE SECTION:\n"
+		} else {
+			s += "\n;; ANSWER SECTION:\n"
+		}
 		for _, r := range dns.Answer {
 			if r != nil {
 				s += r.String() + "\n"
@@ -920,7 +935,11 @@ func (dns *Msg) String() string {
 		}
 	}
 	if len(dns.Ns) > 0 {
-		s += "\n;; AUTHORITY SECTION:\n"
+		if dns.MsgHdr.Opcode == OpcodeUpdate {
+			s += "\n;; UPDATE SECTION:\n"
+		} else {
+			s += "\n;; AUTHORITY SECTION:\n"
+		}
 		for _, r := range dns.Ns {
 			if r != nil {
 				s += r.String() + "\n"
diff --git a/types.go b/types.go
index 03afeccd..c9a03dec 100644
--- a/types.go
+++ b/types.go
@@ -236,6 +236,9 @@ var CertTypeToString = map[uint16]string{
 	CertOID:     "OID",
 }
 
+// Prefix for IPv4 encoded as IPv6 address
+const ipv4InIPv6Prefix = "::ffff:"
+
 //go:generate go run types_generate.go
 
 // Question holds a DNS question. Usually there is just one. While the
@@ -751,6 +754,11 @@ func (rr *AAAA) String() string {
 	if rr.AAAA == nil {
 		return rr.Hdr.String()
 	}
+
+	if rr.AAAA.To4() != nil {
+		return rr.Hdr.String() + ipv4InIPv6Prefix + rr.AAAA.String()
+	}
+
 	return rr.Hdr.String() + rr.AAAA.String()
 }
 
@@ -1517,7 +1525,7 @@ func (a *APLPrefix) str() string {
 	case net.IPv6len:
 		// add prefix for IPv4-mapped IPv6
 		if v4 := a.Network.IP.To4(); v4 != nil {
-			sb.WriteString("::ffff:")
+			sb.WriteString(ipv4InIPv6Prefix)
 		}
 		sb.WriteString(a.Network.IP.String())
 	}


Test patch:
diff --git a/parse_test.go b/parse_test.go
index 364e7be4..0096e004 100644
--- a/parse_test.go
+++ b/parse_test.go
@@ -1686,6 +1686,33 @@ func TestNULLRecord(t *testing.T) {
 		t.Fatalf("Expected packet to contain NULL record")
 	}
 }
+func TestAAAAParsing(t *testing.T) {
+	tests := []string{
+		"2001:db8::1",
+		"1::1",
+		"2001:db81:d2b4:b6ba:50db:49cc:a8d1:5bb1",
+		"::ffff:192.0.2.0",
+	}
+
+	rrPrefix := ".\t1\tIN\tAAAA\t"
+
+	for num, tc := range tests {
+		t.Run(fmt.Sprintf("Test %d", num), func(t *testing.T) {
+			rr, err := NewRR(rrPrefix + tc)
+			if err != nil {
+				t.Fatalf("failed to parse RR: %s", err)
+			}
+			// Output presentation format and try to parse again
+			reparseRR, err := NewRR(rr.String())
+			if err != nil {
+				t.Fatalf("failed to reparse RR: %s", err)
+			}
+			if reparseRR.String() != rrPrefix+tc {
+				t.Errorf("expected %s,got %s", rrPrefix+tc, reparseRR.String())
+			}
+		})
+	}
+}
 
 func TestParseAPL(t *testing.T) {
 	tests := []struct {
diff --git a/update_test.go b/update_test.go
index f62eac3a..69c26e8b 100644
--- a/update_test.go
+++ b/update_test.go
@@ -126,19 +126,19 @@ func TestPreReqAndRemovals(t *testing.T) {
 	// end and the Example function trim these, thus they never match.
 	// TODO(miek): don't print these tabs and make this into an Example function.
 	expect := `;; opcode: UPDATE, status: NOERROR, id: 1234
-;; flags:; QUERY: 1, ANSWER: 5, AUTHORITY: 4, ADDITIONAL: 0
+;; flags:; ZONE: 1, PREREQ: 5, UPDATE: 4, ADDITIONAL: 0
 
-;; QUESTION SECTION:
+;; ZONE SECTION:
 ;example.org.	IN	 SOA
 
-;; ANSWER SECTION:
+;; PREREQUISITE SECTION:
 name_used.	0	CLASS255	ANY	
 name_not_used.	0	NONE	ANY	
 rrset_used1.	0	CLASS255	A	
 rrset_used2.	0	IN	A	127.0.0.1
 rrset_not_used.	0	NONE	A	
 
-;; AUTHORITY SECTION:
+;; UPDATE SECTION:
 remove1.	0	CLASS255	ANY	
 remove2.	0	CLASS255	A	
 remove3.	0	NONE	A	127.0.0.1