CHANGELOG for v1.20240513.0.
[dnstt.git] / dnstt-client / http.go
blob560735a9af3748e989f3df7f56d004ec06df31cb
1 package main
3 import (
4 "bytes"
5 "fmt"
6 "io"
7 "io/ioutil"
8 "log"
9 "net/http"
10 "strconv"
11 "sync"
12 "time"
14 "www.bamsoftware.com/git/dnstt.git/turbotunnel"
17 // A default Retry-After delay to use when there is no explicit Retry-After
18 // header in an HTTP response.
19 const defaultRetryAfter = 10 * time.Second
21 // HTTPPacketConn is an HTTP-based transport for DNS messages, used for DNS over
22 // HTTPS (DoH). Its WriteTo and ReadFrom methods exchange DNS messages over HTTP
23 // requests and responses.
25 // HTTPPacketConn deals only with already formatted DNS messages. It does not
26 // handle encoding information into the messages. That is rather the
27 // responsibility of DNSPacketConn.
29 // https://tools.ietf.org/html/rfc8484
30 type HTTPPacketConn struct {
31 // client is the http.Client used to make requests. We use this instead
32 // of http.DefaultClient in order to support setting a timeout and a
33 // uTLS fingerprint.
34 client *http.Client
36 // urlString is the URL to which HTTP requests will be sent, for example
37 // "https://doh.example/dns-query".
38 urlString string
40 // notBefore, if not zero, is a time before which we may not send any
41 // queries; queries are buffered or dropped until that time. notBefore
42 // is set when we get a 429 Too Many Requests HTTP response or other
43 // unexpected status code that causes us to need to slow down. It is set
44 // according to the Retry-After header if available, otherwise it is set
45 // to defaultRetryAfter in the future. notBeforeLock controls access to
46 // notBefore.
47 notBefore time.Time
48 notBeforeLock sync.RWMutex
50 // QueuePacketConn is the direct receiver of ReadFrom and WriteTo calls.
51 // sendLoop, via send, removes messages from the outgoing queue that
52 // were placed there by WriteTo, and inserts messages into the incoming
53 // queue to be returned from ReadFrom.
54 *turbotunnel.QueuePacketConn
57 // NewHTTPPacketConn creates a new HTTPPacketConn configured to use the HTTP
58 // server at urlString as a DNS over HTTP resolver. client is the http.Client
59 // that will be used to make requests. urlString should include any necessary
60 // path components; e.g., "/dns-query". numSenders is the number of concurrent
61 // sender-receiver goroutines to run.
62 func NewHTTPPacketConn(rt http.RoundTripper, urlString string, numSenders int) (*HTTPPacketConn, error) {
63 c := &HTTPPacketConn{
64 client: &http.Client{
65 Transport: rt,
66 Timeout: 1 * time.Minute,
68 urlString: urlString,
69 QueuePacketConn: turbotunnel.NewQueuePacketConn(turbotunnel.DummyAddr{}, 0),
71 for i := 0; i < numSenders; i++ {
72 go c.sendLoop()
74 return c, nil
77 // send sends a message in an HTTP request, and queues the body HTTP response to
78 // be returned from a future call to ReadFrom.
79 func (c *HTTPPacketConn) send(p []byte) error {
80 req, err := http.NewRequest("POST", c.urlString, bytes.NewReader(p))
81 if err != nil {
82 return err
84 req.Header.Set("Accept", "application/dns-message")
85 req.Header.Set("Content-Type", "application/dns-message")
86 req.Header.Set("User-Agent", "") // Disable default "Go-http-client/1.1".
87 resp, err := c.client.Do(req)
88 if err != nil {
89 return err
91 defer resp.Body.Close()
93 switch resp.StatusCode {
94 case http.StatusOK:
95 if ct := resp.Header.Get("Content-Type"); ct != "application/dns-message" {
96 return fmt.Errorf("unknown HTTP response Content-Type %+q", ct)
98 body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 64000))
99 if err == nil {
100 c.QueuePacketConn.QueueIncoming(body, turbotunnel.DummyAddr{})
102 // Ignore err != nil; don't report an error if we at least
103 // managed to send.
104 default:
105 // We primarily are thinking of 429 Too Many Requests here, but
106 // any other unexpected response codes will also cause us to
107 // rate-limit ourselves and emit a log message.
108 // https://developers.google.com/speed/public-dns/docs/doh/#errors
109 now := time.Now()
110 var retryAfter time.Time
111 if value := resp.Header.Get("Retry-After"); value != "" {
112 var err error
113 retryAfter, err = parseRetryAfter(value, now)
114 if err != nil {
115 log.Printf("cannot parse Retry-After value %+q", value)
118 if retryAfter.IsZero() {
119 // Supply a default.
120 retryAfter = now.Add(defaultRetryAfter)
122 if retryAfter.Before(now) {
123 log.Printf("got %+q, but Retry-After is %v in the past",
124 resp.Status, now.Sub(retryAfter))
125 } else {
126 c.notBeforeLock.Lock()
127 if retryAfter.Before(c.notBefore) {
128 log.Printf("got %+q, but Retry-After is %v earlier than already received Retry-After",
129 resp.Status, c.notBefore.Sub(retryAfter))
130 } else {
131 log.Printf("got %+q; ceasing sending for %v",
132 resp.Status, retryAfter.Sub(now))
133 c.notBefore = retryAfter
135 c.notBeforeLock.Unlock()
139 return nil
142 // sendLoop loops over the contents of the outgoing queue and passes them to
143 // send. It drops packets while c.notBefore is in the future.
144 func (c *HTTPPacketConn) sendLoop() {
145 for p := range c.QueuePacketConn.OutgoingQueue(turbotunnel.DummyAddr{}) {
146 // Stop sending while we are rate-limiting ourselves (as a
147 // result of a Retry-After response header, for example).
148 c.notBeforeLock.RLock()
149 notBefore := c.notBefore
150 c.notBeforeLock.RUnlock()
151 if wait := notBefore.Sub(time.Now()); wait > 0 {
152 // Drop it.
153 continue
156 err := c.send(p)
157 if err != nil {
158 log.Printf("sendLoop: %v", err)
163 // parseRetryAfter parses the value of a Retry-After header as an absolute
164 // time.Time.
165 func parseRetryAfter(value string, now time.Time) (time.Time, error) {
166 // May be a date string or an integer number of seconds.
167 // https://tools.ietf.org/html/rfc7231#section-7.1.3
168 if t, err := http.ParseTime(value); err == nil {
169 return t, nil
171 i, err := strconv.ParseUint(value, 10, 32)
172 if err != nil {
173 return time.Time{}, err
175 return now.Add(time.Duration(i) * time.Second), nil