feat: save CNAME into archival data format (#877)

* feat: save CNAME into archival data format

When a DNSResponse contains a non-empty CNAME, we include it
into the related list of answers.

Closes https://github.com/ooni/probe/issues/2227

* doc: add design note

While there, make code more compact and robust to a case where
we're going to extract additional answers.

* doc: document the expected growth of extraction function

Based on feedback by @DecFox
This commit is contained in:
Simone Basso 2022-08-23 15:13:37 +02:00 committed by GitHub
parent 080abf90d9
commit 60b7d1f87b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 205 additions and 44 deletions

View File

@ -114,7 +114,7 @@ type DNSNetworkAddresser interface {
func NewArchivalDNSLookupResultFromRoundTrip(index int64, started time.Duration, reso DNSNetworkAddresser, query model.DNSQuery, func NewArchivalDNSLookupResultFromRoundTrip(index int64, started time.Duration, reso DNSNetworkAddresser, query model.DNSQuery,
response model.DNSResponse, addrs []string, err error, finished time.Duration) *model.ArchivalDNSLookupResult { response model.DNSResponse, addrs []string, err error, finished time.Duration) *model.ArchivalDNSLookupResult {
return &model.ArchivalDNSLookupResult{ return &model.ArchivalDNSLookupResult{
Answers: archivalAnswersFromAddrs(addrs), Answers: newArchivalDNSAnswers(addrs, response),
Engine: reso.Network(), Engine: reso.Network(),
Failure: tracex.NewFailure(err), Failure: tracex.NewFailure(err),
Hostname: query.Domain(), Hostname: query.Domain(),
@ -125,8 +125,16 @@ func NewArchivalDNSLookupResultFromRoundTrip(index int64, started time.Duration,
} }
} }
// archivalAnswersFromAddrs generates model.ArchivalDNSAnswer from an array of addresses // newArchivalDNSAnswers generates []model.ArchivalDNSAnswer from [addrs] and [resp].
func archivalAnswersFromAddrs(addrs []string) (out []model.ArchivalDNSAnswer) { func newArchivalDNSAnswers(addrs []string, resp model.DNSResponse) (out []model.ArchivalDNSAnswer) {
// Design note: in principle we might want to extract everything from the
// response but, when we're called by netxlite, netxlite has already extracted
// the addresses to return them to the caller, so I think it's fine to keep
// this extraction code as such rather than suppressing passing the addrs from
// netxlite. Also, a wrong IP address is a bug because netxlite should not
// return invalid IP addresses from its resolvers, so we want to know about that.
// Include IP addresses extracted by netxlite
for _, addr := range addrs { for _, addr := range addrs {
ipv6, err := netxlite.IsIPv6(addr) ipv6, err := netxlite.IsIPv6(addr)
if err != nil { if err != nil {
@ -142,6 +150,7 @@ func archivalAnswersFromAddrs(addrs []string) (out []model.ArchivalDNSAnswer) {
AnswerType: "A", AnswerType: "A",
Hostname: "", Hostname: "",
IPv4: addr, IPv4: addr,
IPv6: "",
TTL: nil, TTL: nil,
}) })
case true: case true:
@ -150,11 +159,32 @@ func archivalAnswersFromAddrs(addrs []string) (out []model.ArchivalDNSAnswer) {
ASOrgName: org, ASOrgName: org,
AnswerType: "AAAA", AnswerType: "AAAA",
Hostname: "", Hostname: "",
IPv4: "",
IPv6: addr, IPv6: addr,
TTL: nil, TTL: nil,
}) })
} }
} }
// Include additional answer types when a response is available
if resp != nil {
// Include CNAME if available
if cname, err := resp.DecodeCNAME(); err == nil && cname != "" {
out = append(out, model.ArchivalDNSAnswer{
ASN: 0,
ASOrgName: "",
AnswerType: "CNAME",
Hostname: cname,
IPv4: "",
IPv6: "",
TTL: nil,
})
}
// TODO(bassosimone): what other fields generally present inside A/AAAA replies
// would it be useful to extract here? Perhaps, the SoA field?
}
return return
} }

View File

@ -135,6 +135,9 @@ func TestNewResolver(t *testing.T) {
} }
return []string{"1.1.1.1"}, nil return []string{"1.1.1.1"}, nil
}, },
MockDecodeCNAME: func() (string, error) {
return "dns.google.", nil
},
} }
return response, nil return response, nil
}, },
@ -177,7 +180,7 @@ func TestNewResolver(t *testing.T) {
if ev.Engine != "mocked" { if ev.Engine != "mocked" {
t.Fatal("unexpected engine") t.Fatal("unexpected engine")
} }
if len(ev.Answers) != 1 { if len(ev.Answers) != 2 {
t.Fatal("expected single answer in DNSLookup event") t.Fatal("expected single answer in DNSLookup event")
} }
if ev.QueryType == "A" && ev.Answers[0].IPv4 != "1.1.1.1" { if ev.QueryType == "A" && ev.Answers[0].IPv4 != "1.1.1.1" {
@ -186,6 +189,9 @@ func TestNewResolver(t *testing.T) {
if ev.QueryType == "AAAA" && ev.Answers[0].IPv6 != "fe80::a00:20ff:feb9:4c54" { if ev.QueryType == "AAAA" && ev.Answers[0].IPv6 != "fe80::a00:20ff:feb9:4c54" {
t.Fatal("unexpected AAAA query result") t.Fatal("unexpected AAAA query result")
} }
if ev.Answers[1].AnswerType != "CNAME " && ev.Answers[1].Hostname != "dns.google." {
t.Fatal("unexpected second answer (expected CNAME)", ev.Answers[1])
}
} }
}) })
}) })
@ -205,6 +211,9 @@ func TestNewResolver(t *testing.T) {
} }
return []string{"1.1.1.1"}, nil return []string{"1.1.1.1"}, nil
}, },
MockDecodeCNAME: func() (string, error) {
return "dns.google.", nil
},
} }
return response, nil return response, nil
}, },
@ -350,8 +359,12 @@ func TestDelayedDNSResponseWithTimeout(t *testing.T) {
addrs := []string{"1.1.1.1"} addrs := []string{"1.1.1.1"}
finished := trace.TimeNow() finished := trace.TimeNow()
// 1. fill the trace // 1. fill the trace
err := trace.OnDelayedDNSResponse(started, txp, query, &mocks.DNSResponse{}, dnsResponse := &mocks.DNSResponse{
addrs, nil, finished) MockDecodeCNAME: func() (string, error) {
return "", netxlite.ErrOODNSNoAnswer
},
}
err := trace.OnDelayedDNSResponse(started, txp, query, dnsResponse, addrs, nil, finished)
// 2. read the trace // 2. read the trace
got := trace.DelayedDNSResponseWithTimeout(context.Background(), time.Second) got := trace.DelayedDNSResponseWithTimeout(context.Background(), time.Second)
if err != nil { if err != nil {
@ -388,8 +401,12 @@ func TestDelayedDNSResponseWithTimeout(t *testing.T) {
addrs := []string{"1.1.1.1"} addrs := []string{"1.1.1.1"}
finished := trace.TimeNow() finished := trace.TimeNow()
// 1. attempt to write into the trace // 1. attempt to write into the trace
err := trace.OnDelayedDNSResponse(started, txp, query, &mocks.DNSResponse{}, dnsResponse := &mocks.DNSResponse{
addrs, nil, finished) MockDecodeCNAME: func() (string, error) {
return "", netxlite.ErrOODNSNoAnswer
},
}
err := trace.OnDelayedDNSResponse(started, txp, query, dnsResponse, addrs, nil, finished)
if !errors.Is(err, ErrDelayedDNSResponseBufferFull) { if !errors.Is(err, ErrDelayedDNSResponseBufferFull) {
t.Fatal("unexpected error", err) t.Fatal("unexpected error", err)
} }
@ -427,10 +444,15 @@ func TestDelayedDNSResponseWithTimeout(t *testing.T) {
addrs := []string{"1.1.1.1"} addrs := []string{"1.1.1.1"}
finished := trace.TimeNow() finished := trace.TimeNow()
events := 4 events := 4
dnsResponse := &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", netxlite.ErrOODNSNoAnswer
},
}
for i := 0; i < events; i++ { for i := 0; i < events; i++ {
// fill the trace // fill the trace
trace.delayedDNSResponse <- NewArchivalDNSLookupResultFromRoundTrip(trace.Index, started.Sub(trace.ZeroTime), trace.delayedDNSResponse <- NewArchivalDNSLookupResultFromRoundTrip(trace.Index, started.Sub(trace.ZeroTime),
txp, query, &mocks.DNSResponse{}, addrs, nil, finished.Sub(trace.ZeroTime)) txp, query, dnsResponse, addrs, nil, finished.Sub(trace.ZeroTime))
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() // we ensure that the context cancels before draining all the events cancel() // we ensure that the context cancels before draining all the events
@ -465,8 +487,13 @@ func TestDelayedDNSResponseWithTimeout(t *testing.T) {
} }
addrs := []string{"1.1.1.1"} addrs := []string{"1.1.1.1"}
finished := trace.TimeNow() finished := trace.TimeNow()
dnsResponse := &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", netxlite.ErrOODNSNoAnswer
},
}
trace.delayedDNSResponse <- NewArchivalDNSLookupResultFromRoundTrip(trace.Index, started.Sub(trace.ZeroTime), trace.delayedDNSResponse <- NewArchivalDNSLookupResultFromRoundTrip(trace.Index, started.Sub(trace.ZeroTime),
txp, query, &mocks.DNSResponse{}, addrs, nil, finished.Sub(trace.ZeroTime)) txp, query, dnsResponse, addrs, nil, finished.Sub(trace.ZeroTime))
got := trace.DelayedDNSResponseWithTimeout(context.Background(), time.Second) got := trace.DelayedDNSResponseWithTimeout(context.Background(), time.Second)
if len(got) != 1 { if len(got) != 1 {
t.Fatal("unexpected output from trace") t.Fatal("unexpected output from trace")
@ -475,52 +502,156 @@ func TestDelayedDNSResponseWithTimeout(t *testing.T) {
}) })
} }
func TestAnswersFromAddrs(t *testing.T) { func TestNewArchivalDNSAnswers(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
args []string addrs []string
resp model.DNSResponse
expected []model.ArchivalDNSAnswer
}{{ }{{
name: "with valid input", name: "with valid input",
args: []string{"1.1.1.1", "fe80::a00:20ff:feb9:4c54"}, addrs: []string{
"8.8.4.4",
"2001:4860:4860::8844",
},
resp: nil,
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "A",
Hostname: "",
IPv4: "8.8.4.4",
IPv6: "",
TTL: nil,
}, {
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "AAAA",
Hostname: "",
IPv4: "",
IPv6: "2001:4860:4860::8844",
TTL: nil,
}},
}, { }, {
name: "with invalid IPv4 address", name: "with invalid IPv4 address",
args: []string{"1.1.1.1.1", "fe80::a00:20ff:feb9:4c54"}, addrs: []string{
"1.1.1.1.1", // invalid because it has five dots
"2001:4860:4860::8844",
},
resp: nil,
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "AAAA",
Hostname: "",
IPv4: "",
IPv6: "2001:4860:4860::8844",
TTL: nil,
}},
}, { }, {
name: "with invalid IPv6 address", name: "with invalid IPv6 address",
args: []string{"1.1.1.1", "fe80::a00:20ff:feb9:::4c54"}, addrs: []string{
"8.8.4.4",
"fe80::a00:20ff:feb9:::4c54", // invalid because it has :::
},
resp: nil,
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "A",
Hostname: "",
IPv4: "8.8.4.4",
IPv6: "",
TTL: nil,
}},
}, { }, {
name: "with empty input", name: "with empty input",
args: []string{}, addrs: []string{},
resp: nil,
expected: nil,
}, { }, {
name: "with nil input", name: "with nil input",
args: nil, addrs: nil,
resp: nil,
expected: nil,
}, {
name: "with valid IPv4 address and CNAME",
addrs: []string{
"8.8.8.8",
},
resp: &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "dns.google.", nil
},
},
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "A",
Hostname: "",
IPv4: "8.8.8.8",
IPv6: "",
TTL: nil,
}, {
ASN: 0,
ASOrgName: "",
AnswerType: "CNAME",
Hostname: "dns.google.",
IPv4: "",
IPv6: "",
TTL: nil,
}},
}, {
name: "with valid IPv6 address and CNAME",
addrs: []string{
"2001:4860:4860::8844",
},
resp: &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "dns.google.", nil
},
},
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "AAAA",
Hostname: "",
IPv4: "",
IPv6: "2001:4860:4860::8844",
TTL: nil,
}, {
ASN: 0,
ASOrgName: "",
AnswerType: "CNAME",
Hostname: "dns.google.",
IPv4: "",
IPv6: "",
TTL: nil,
}},
}, {
name: "with DecodeCNAME error",
addrs: []string{},
resp: &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", errors.New("mocked errorr")
},
},
expected: nil,
}, {
name: "with DecodeCNAME success and no CNAME",
addrs: []string{},
resp: &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", nil
},
},
expected: nil,
}} }}
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := archivalAnswersFromAddrs(tt.args) got := newArchivalDNSAnswers(tt.addrs, tt.resp)
var idx int if diff := cmp.Diff(tt.expected, got); diff != "" {
for _, inp := range tt.args { t.Fatal(diff)
ip6, err := netxlite.IsIPv6(inp)
if err != nil {
continue
}
if idx >= len(got) {
t.Fatal("unexpected array length")
}
answer := got[idx]
if ip6 {
if answer.AnswerType != "AAAA" || answer.IPv6 != inp {
t.Fatal("unexpected output", answer)
}
} else {
if answer.AnswerType != "A" || answer.IPv4 != inp {
t.Fatal("unexpected output", answer)
}
}
idx++
}
if idx != len(got) {
t.Fatal("unexpected array length", len(got))
} }
}) })
} }