diff options
author | Martin Polden <mpolden@mpolden.no> | 2019-12-27 21:12:07 +0100 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2019-12-27 21:12:07 +0100 |
commit | 2bf84ab1138095f812600051134f12e10d120bd1 (patch) | |
tree | 45ef64241c22661b232a0165dacf276e2695deda | |
parent | d6783170e9cbb38aa6b39d9ad66187315193dae0 (diff) |
Implement DNS over HTTPS client
-rw-r--r-- | dns/http/client.go | 74 | ||||
-rw-r--r-- | dns/http/client_test.go | 84 |
2 files changed, 158 insertions, 0 deletions
diff --git a/dns/http/client.go b/dns/http/client.go new file mode 100644 index 0000000..d26223a --- /dev/null +++ b/dns/http/client.go @@ -0,0 +1,74 @@ +package http + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" + + "github.com/miekg/dns" +) + +// RFC8484 (https://tools.ietf.org/html/rfc8484) claims that application/dns-message should be used, as does +// https://developers.cloudflare.com/1.1.1.1/dns-over-https/wireformat/ +// +// However, Cloudflares service only accept this media type from one of the older RFC drafts +// (https://tools.ietf.org/html/draft-ietf-doh-dns-over-https-05). +const mimeType = "application/dns-udpwireformat" + +// Client is a DNS-over-HTTPS client. +type Client struct { + httpClient *http.Client +} + +// NewClient creates a new DNS-over-HTTPS client. +func NewClient(timeout time.Duration) *Client { + return &Client{httpClient: &http.Client{Timeout: timeout}} +} + +// Exchange sends the DNS message msg to the DNS-over-HTTPS endpoint addr and returns the response. +func (c *Client) Exchange(msg *dns.Msg, addr string) (*dns.Msg, time.Duration, error) { + u, err := url.Parse(addr) + if err != nil { + return nil, 0, fmt.Errorf("invalid url: %w", err) + } + + p, err := msg.Pack() + if err != nil { + return nil, 0, err + } + + r, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(p)) + if err != nil { + return nil, 0, err + } + r.Header.Set("Content-Type", mimeType) + r.Header.Set("Accept", mimeType) + + t := time.Now() + resp, err := c.httpClient.Do(r) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("server returned HTTP %d error: %q", resp.StatusCode, resp.Status) + } + if contentType := resp.Header.Get("Content-Type"); contentType != mimeType { + return nil, 0, fmt.Errorf("server returned unexpected ContentType %q, want %q", contentType, mimeType) + } + + p, err = ioutil.ReadAll(resp.Body) + if err != nil { + return nil, 0, err + } + rtt := time.Since(t) + reply := dns.Msg{} + if err := reply.Unpack(p); err != nil { + return nil, 0, err + } + return &reply, rtt, nil +} diff --git a/dns/http/client_test.go b/dns/http/client_test.go new file mode 100644 index 0000000..30c8a00 --- /dev/null +++ b/dns/http/client_test.go @@ -0,0 +1,84 @@ +package http + +import ( + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/miekg/dns" +) + +// Reponse and request data from https://developers.cloudflare.com/1.1.1.1/dns-over-https/wireformat/. +const response = ` +00 00 81 80 00 01 00 01 00 00 00 00 03 77 77 77 +07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 +01 03 77 77 77 07 65 78 61 6d 70 6c 65 03 63 6f +6d 00 00 01 00 01 00 00 00 80 00 04 C0 00 02 01 +` + +const request = ` +00 00 01 00 00 01 00 00 00 00 00 00 03 77 77 77 +07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 +01 +` + +func hexDecode(s string) []byte { + replacer := strings.NewReplacer(" ", "", "\n", "") + b, err := hex.DecodeString(replacer.Replace(s)) + if err != nil { + panic(err) + } + return b +} + +func handler(w http.ResponseWriter, r *http.Request) { + contentType := r.Header.Get("Content-Type") + accept := r.Header.Get("Accept") + const mimeType = "application/dns-udpwireformat" + + if contentType != mimeType { + w.WriteHeader(http.StatusUnsupportedMediaType) + io.WriteString(w, "invalid value for header \"Content-Type\"") + return + } + if accept != mimeType { + w.WriteHeader(http.StatusUnsupportedMediaType) + io.WriteString(w, "invalid value for header \"Accept\"") + return + } + w.Header().Set("Content-Type", mimeType) + w.Write(hexDecode(response)) +} + +func TestExchange(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + msg := dns.Msg{} + if err := msg.Unpack(hexDecode(request)); err != nil { + t.Fatal(err) + } + + client := NewClient(10 * time.Second) + reply, _, err := client.Exchange(&msg, srv.URL) + if err != nil { + t.Fatal(err) + } + + want := `;; opcode: QUERY, status: NOERROR, id: 0 +;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0 + +;; QUESTION SECTION: +;www.example.com. IN A + +;; ANSWER SECTION: +www.example.com. 128 IN A 192.0.2.1 +` + if got := reply.String(); got != want { + t.Errorf("got %s, want %s", got, want) + } +} |