aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2019-12-27 21:12:07 +0100
committerMartin Polden <mpolden@mpolden.no>2019-12-27 21:12:07 +0100
commit2bf84ab1138095f812600051134f12e10d120bd1 (patch)
tree45ef64241c22661b232a0165dacf276e2695deda
parentd6783170e9cbb38aa6b39d9ad66187315193dae0 (diff)
Implement DNS over HTTPS client
-rw-r--r--dns/http/client.go74
-rw-r--r--dns/http/client_test.go84
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)
+ }
+}