feat(dnsfwd): add a UDP and TCP DNS forwarder for sandbox gateways
- commit
- 4f2d9a2c70f8610dbeb1eeb6990262c22a7135e8
- author
- brice · · 2026-10-12 15:35 UTC
- parent
- 2e4b251
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +706 -0
A internal/dnsfwd/dnsfwd.go +323 -0
| @@ -0,0 +1,323 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package dnsfwd is a small DNS forwarder for job sandboxes (stdlib only). It | |
| 4 | + | // listens on the gateway address of each sandbox (UDP and TCP, port 53 in | |
| 5 | + | // production) and relays queries unchanged to the host's resolvers; the network | |
| 6 | + | // policy lets guests reach nothing else on the host. It does not parse queries | |
| 7 | + | // beyond what framing needs, so it supports every record type and EDNS. | |
| 8 | + | // | |
| 9 | + | // Abuse limits: at most MaxInflight concurrent upstream exchanges across all | |
| 10 | + | // listeners, a deadline on every upstream exchange, idle and total timeouts on | |
| 11 | + | // TCP connections, and a cap on TCP queries per connection. | |
| 12 | + | package dnsfwd | |
| 13 | + | ||
| 14 | + | import ( | |
| 15 | + | "context" | |
| 16 | + | "encoding/binary" | |
| 17 | + | "errors" | |
| 18 | + | "fmt" | |
| 19 | + | "io" | |
| 20 | + | "net" | |
| 21 | + | "net/netip" | |
| 22 | + | "strconv" | |
| 23 | + | "sync" | |
| 24 | + | "time" | |
| 25 | + | ||
| 26 | + | "github.com/bnema/zerowrap" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | const ( | |
| 30 | + | // MaxInflight bounds concurrent upstream exchanges. | |
| 31 | + | MaxInflight = 256 | |
| 32 | + | // UpstreamTimeout is the deadline of one upstream attempt. | |
| 33 | + | UpstreamTimeout = 3 * time.Second | |
| 34 | + | // TCPIdleTimeout closes TCP client connections that stay silent. | |
| 35 | + | TCPIdleTimeout = 10 * time.Second | |
| 36 | + | // TCPMaxQueries caps queries per TCP connection. | |
| 37 | + | TCPMaxQueries = 64 | |
| 38 | + | ||
| 39 | + | maxUDP = 4096 // largest datagram relayed (EDNS0 sizes up to 4096) | |
| 40 | + | hdrLen = 12 | |
| 41 | + | ) | |
| 42 | + | ||
| 43 | + | // Forwarder relays DNS queries. The zero value is not usable; use New. | |
| 44 | + | type Forwarder struct { | |
| 45 | + | upstreams func() []string | |
| 46 | + | port int | |
| 47 | + | sem chan struct{} | |
| 48 | + | ||
| 49 | + | mu sync.Mutex | |
| 50 | + | listeners map[netip.Addr]*listener | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | type listener struct { | |
| 54 | + | udp net.PacketConn | |
| 55 | + | tcp net.Listener | |
| 56 | + | cancel context.CancelFunc | |
| 57 | + | done sync.WaitGroup | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // New returns a Forwarder. upstreams returns the "host:port" resolvers to try | |
| 61 | + | // in order (see Resolvers.Addrs); port is the port to listen on (53, or another | |
| 62 | + | // port in tests). | |
| 63 | + | func New(upstreams func() []string, port int) *Forwarder { | |
| 64 | + | return &Forwarder{ | |
| 65 | + | upstreams: upstreams, | |
| 66 | + | port: port, | |
| 67 | + | sem: make(chan struct{}, MaxInflight), | |
| 68 | + | listeners: map[netip.Addr]*listener{}, | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Listen starts serving on addr (UDP and TCP). It returns once both sockets are | |
| 73 | + | // bound. Listening twice on the same address is an error. | |
| 74 | + | func (f *Forwarder) Listen(ctx context.Context, addr netip.Addr) error { | |
| 75 | + | f.mu.Lock() | |
| 76 | + | defer f.mu.Unlock() | |
| 77 | + | if _, ok := f.listeners[addr]; ok { | |
| 78 | + | return fmt.Errorf("dnsfwd: already listening on %s", addr) | |
| 79 | + | } | |
| 80 | + | hp := net.JoinHostPort(addr.String(), strconv.Itoa(f.port)) | |
| 81 | + | var lc net.ListenConfig | |
| 82 | + | pc, err := lc.ListenPacket(ctx, "udp", hp) | |
| 83 | + | if err != nil { | |
| 84 | + | return fmt.Errorf("dnsfwd: listen udp %s: %w", hp, err) | |
| 85 | + | } | |
| 86 | + | // With port 0 (tests) UDP and TCP must share the port the kernel picked. | |
| 87 | + | if f.port == 0 { | |
| 88 | + | hp = pc.LocalAddr().String() | |
| 89 | + | } | |
| 90 | + | tl, err := lc.Listen(ctx, "tcp", hp) | |
| 91 | + | if err != nil { | |
| 92 | + | _ = pc.Close() | |
| 93 | + | return fmt.Errorf("dnsfwd: listen tcp %s: %w", hp, err) | |
| 94 | + | } | |
| 95 | + | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| 96 | + | l := &listener{udp: pc, tcp: tl, cancel: cancel} | |
| 97 | + | f.listeners[addr] = l | |
| 98 | + | log := zerowrap.FromCtx(ctx) | |
| 99 | + | l.done.Add(2) | |
| 100 | + | go func() { defer l.done.Done(); f.serveUDP(lctx, pc, log) }() | |
| 101 | + | go func() { defer l.done.Done(); f.serveTCP(lctx, tl, log) }() | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Addr returns the bound UDP address for addr, useful when port is 0. | |
| 106 | + | func (f *Forwarder) Addr(addr netip.Addr) (net.Addr, bool) { | |
| 107 | + | f.mu.Lock() | |
| 108 | + | defer f.mu.Unlock() | |
| 109 | + | l, ok := f.listeners[addr] | |
| 110 | + | if !ok { | |
| 111 | + | return nil, false | |
| 112 | + | } | |
| 113 | + | return l.udp.LocalAddr(), true | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Close stops listening on addr and waits for its goroutines. Unknown | |
| 117 | + | // addresses are ignored. | |
| 118 | + | func (f *Forwarder) Close(addr netip.Addr) { | |
| 119 | + | f.mu.Lock() | |
| 120 | + | l, ok := f.listeners[addr] | |
| 121 | + | delete(f.listeners, addr) | |
| 122 | + | f.mu.Unlock() | |
| 123 | + | if !ok { | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | l.cancel() | |
| 127 | + | _ = l.udp.Close() | |
| 128 | + | _ = l.tcp.Close() | |
| 129 | + | l.done.Wait() | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // CloseAll stops every listener. | |
| 133 | + | func (f *Forwarder) CloseAll() { | |
| 134 | + | f.mu.Lock() | |
| 135 | + | addrs := make([]netip.Addr, 0, len(f.listeners)) | |
| 136 | + | for a := range f.listeners { | |
| 137 | + | addrs = append(addrs, a) | |
| 138 | + | } | |
| 139 | + | f.mu.Unlock() | |
| 140 | + | for _, a := range addrs { | |
| 141 | + | f.Close(a) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (f *Forwarder) acquire(ctx context.Context) bool { | |
| 146 | + | select { | |
| 147 | + | case f.sem <- struct{}{}: | |
| 148 | + | return true | |
| 149 | + | case <-ctx.Done(): | |
| 150 | + | return false | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (f *Forwarder) release() { <-f.sem } | |
| 155 | + | ||
| 156 | + | func (f *Forwarder) serveUDP(ctx context.Context, pc net.PacketConn, log zerowrap.Logger) { | |
| 157 | + | buf := make([]byte, maxUDP) | |
| 158 | + | for { | |
| 159 | + | n, from, err := pc.ReadFrom(buf) | |
| 160 | + | if err != nil { | |
| 161 | + | if ctx.Err() == nil && !errors.Is(err, net.ErrClosed) { | |
| 162 | + | log.Warn().Err(err).Msg("dns forwarder: udp read") | |
| 163 | + | } | |
| 164 | + | return | |
| 165 | + | } | |
| 166 | + | if n < hdrLen { | |
| 167 | + | continue | |
| 168 | + | } | |
| 169 | + | q := append([]byte(nil), buf[:n]...) | |
| 170 | + | if !f.acquire(ctx) { | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | go func() { | |
| 174 | + | defer f.release() | |
| 175 | + | resp := f.exchange(ctx, q, false) | |
| 176 | + | if len(resp) > 0 { | |
| 177 | + | _, _ = pc.WriteTo(resp, from) | |
| 178 | + | } | |
| 179 | + | }() | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (f *Forwarder) serveTCP(ctx context.Context, l net.Listener, log zerowrap.Logger) { | |
| 184 | + | var wg sync.WaitGroup | |
| 185 | + | defer wg.Wait() | |
| 186 | + | for { | |
| 187 | + | c, err := l.Accept() | |
| 188 | + | if err != nil { | |
| 189 | + | if ctx.Err() == nil && !errors.Is(err, net.ErrClosed) { | |
| 190 | + | log.Warn().Err(err).Msg("dns forwarder: tcp accept") | |
| 191 | + | } | |
| 192 | + | return | |
| 193 | + | } | |
| 194 | + | wg.Add(1) | |
| 195 | + | go func() { | |
| 196 | + | defer wg.Done() | |
| 197 | + | defer func() { _ = c.Close() }() | |
| 198 | + | stop := context.AfterFunc(ctx, func() { _ = c.Close() }) | |
| 199 | + | defer stop() | |
| 200 | + | f.serveConn(ctx, c) | |
| 201 | + | }() | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (f *Forwarder) serveConn(ctx context.Context, c net.Conn) { | |
| 206 | + | for range TCPMaxQueries { | |
| 207 | + | _ = c.SetReadDeadline(time.Now().Add(TCPIdleTimeout)) | |
| 208 | + | var l [2]byte | |
| 209 | + | if _, err := io.ReadFull(c, l[:]); err != nil { | |
| 210 | + | return | |
| 211 | + | } | |
| 212 | + | n := int(binary.BigEndian.Uint16(l[:])) | |
| 213 | + | if n < hdrLen { | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | q := make([]byte, n) | |
| 217 | + | if _, err := io.ReadFull(c, q); err != nil { | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | if !f.acquire(ctx) { | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | resp := f.exchange(ctx, q, true) | |
| 224 | + | f.release() | |
| 225 | + | if len(resp) == 0 || len(resp) > 0xffff { | |
| 226 | + | return | |
| 227 | + | } | |
| 228 | + | out := make([]byte, 2+len(resp)) | |
| 229 | + | binary.BigEndian.PutUint16(out, uint16(len(resp))) | |
| 230 | + | copy(out[2:], resp) | |
| 231 | + | _ = c.SetWriteDeadline(time.Now().Add(TCPIdleTimeout)) | |
| 232 | + | if _, err := c.Write(out); err != nil { | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // exchange relays q to the upstreams in order and returns the first answer, or | |
| 239 | + | // a SERVFAIL when none answers. The transport towards the upstream matches the | |
| 240 | + | // client's: a client that fell back to TCP gets a complete answer. | |
| 241 | + | func (f *Forwarder) exchange(ctx context.Context, q []byte, overTCP bool) []byte { | |
| 242 | + | for _, up := range f.upstreams() { | |
| 243 | + | var resp []byte | |
| 244 | + | var err error | |
| 245 | + | if overTCP { | |
| 246 | + | resp, err = exchangeTCP(ctx, up, q) | |
| 247 | + | } else { | |
| 248 | + | resp, err = exchangeUDP(ctx, up, q) | |
| 249 | + | } | |
| 250 | + | if err == nil && len(resp) >= hdrLen && resp[0] == q[0] && resp[1] == q[1] { | |
| 251 | + | return resp | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return servfail(q) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func exchangeUDP(ctx context.Context, up string, q []byte) ([]byte, error) { | |
| 258 | + | ctx, cancel := context.WithTimeout(ctx, UpstreamTimeout) | |
| 259 | + | defer cancel() | |
| 260 | + | var d net.Dialer | |
| 261 | + | c, err := d.DialContext(ctx, "udp", up) | |
| 262 | + | if err != nil { | |
| 263 | + | return nil, err | |
| 264 | + | } | |
| 265 | + | defer func() { _ = c.Close() }() | |
| 266 | + | dl, _ := ctx.Deadline() | |
| 267 | + | _ = c.SetDeadline(dl) | |
| 268 | + | if _, err := c.Write(q); err != nil { | |
| 269 | + | return nil, err | |
| 270 | + | } | |
| 271 | + | buf := make([]byte, maxUDP) | |
| 272 | + | for { | |
| 273 | + | n, err := c.Read(buf) | |
| 274 | + | if err != nil { | |
| 275 | + | return nil, err | |
| 276 | + | } | |
| 277 | + | // Ignore datagrams that do not answer this query (ID mismatch). | |
| 278 | + | if n >= hdrLen && buf[0] == q[0] && buf[1] == q[1] { | |
| 279 | + | return append([]byte(nil), buf[:n]...), nil | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func exchangeTCP(ctx context.Context, up string, q []byte) ([]byte, error) { | |
| 285 | + | ctx, cancel := context.WithTimeout(ctx, UpstreamTimeout) | |
| 286 | + | defer cancel() | |
| 287 | + | var d net.Dialer | |
| 288 | + | c, err := d.DialContext(ctx, "tcp", up) | |
| 289 | + | if err != nil { | |
| 290 | + | return nil, err | |
| 291 | + | } | |
| 292 | + | defer func() { _ = c.Close() }() | |
| 293 | + | dl, _ := ctx.Deadline() | |
| 294 | + | _ = c.SetDeadline(dl) | |
| 295 | + | out := make([]byte, 2+len(q)) | |
| 296 | + | binary.BigEndian.PutUint16(out, uint16(len(q))) | |
| 297 | + | copy(out[2:], q) | |
| 298 | + | if _, err := c.Write(out); err != nil { | |
| 299 | + | return nil, err | |
| 300 | + | } | |
| 301 | + | var l [2]byte | |
| 302 | + | if _, err := io.ReadFull(c, l[:]); err != nil { | |
| 303 | + | return nil, err | |
| 304 | + | } | |
| 305 | + | resp := make([]byte, binary.BigEndian.Uint16(l[:])) | |
| 306 | + | if _, err := io.ReadFull(c, resp); err != nil { | |
| 307 | + | return nil, err | |
| 308 | + | } | |
| 309 | + | return resp, nil | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // servfail builds a SERVFAIL reply for q: same ID and opcode, QR and RA set, | |
| 313 | + | // RD copied, no records. | |
| 314 | + | func servfail(q []byte) []byte { | |
| 315 | + | if len(q) < hdrLen { | |
| 316 | + | return nil | |
| 317 | + | } | |
| 318 | + | r := make([]byte, hdrLen) | |
| 319 | + | r[0], r[1] = q[0], q[1] | |
| 320 | + | r[2] = 0x80 | (q[2] & 0x79) // QR, opcode, RD kept; AA/TC cleared | |
| 321 | + | r[3] = 0x80 | 0x02 // RA, RCODE=SERVFAIL | |
| 322 | + | return r | |
| 323 | + | } | |
| @@ -0,0 +1,323 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package dnsfwd is a small DNS forwarder for job sandboxes (stdlib only). It | |||
| 4 | + | // listens on the gateway address of each sandbox (UDP and TCP, port 53 in | |||
| 5 | + | // production) and relays queries unchanged to the host's resolvers; the network | |||
| 6 | + | // policy lets guests reach nothing else on the host. It does not parse queries | |||
| 7 | + | // beyond what framing needs, so it supports every record type and EDNS. | |||
| 8 | + | // | |||
| 9 | + | // Abuse limits: at most MaxInflight concurrent upstream exchanges across all | |||
| 10 | + | // listeners, a deadline on every upstream exchange, idle and total timeouts on | |||
| 11 | + | // TCP connections, and a cap on TCP queries per connection. | |||
| 12 | + | package dnsfwd | |||
| 13 | + | ||||
| 14 | + | import ( | |||
| 15 | + | "context" | |||
| 16 | + | "encoding/binary" | |||
| 17 | + | "errors" | |||
| 18 | + | "fmt" | |||
| 19 | + | "io" | |||
| 20 | + | "net" | |||
| 21 | + | "net/netip" | |||
| 22 | + | "strconv" | |||
| 23 | + | "sync" | |||
| 24 | + | "time" | |||
| 25 | + | ||||
| 26 | + | "github.com/bnema/zerowrap" | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | const ( | |||
| 30 | + | // MaxInflight bounds concurrent upstream exchanges. | |||
| 31 | + | MaxInflight = 256 | |||
| 32 | + | // UpstreamTimeout is the deadline of one upstream attempt. | |||
| 33 | + | UpstreamTimeout = 3 * time.Second | |||
| 34 | + | // TCPIdleTimeout closes TCP client connections that stay silent. | |||
| 35 | + | TCPIdleTimeout = 10 * time.Second | |||
| 36 | + | // TCPMaxQueries caps queries per TCP connection. | |||
| 37 | + | TCPMaxQueries = 64 | |||
| 38 | + | ||||
| 39 | + | maxUDP = 4096 // largest datagram relayed (EDNS0 sizes up to 4096) | |||
| 40 | + | hdrLen = 12 | |||
| 41 | + | ) | |||
| 42 | + | ||||
| 43 | + | // Forwarder relays DNS queries. The zero value is not usable; use New. | |||
| 44 | + | type Forwarder struct { | |||
| 45 | + | upstreams func() []string | |||
| 46 | + | port int | |||
| 47 | + | sem chan struct{} | |||
| 48 | + | ||||
| 49 | + | mu sync.Mutex | |||
| 50 | + | listeners map[netip.Addr]*listener | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | type listener struct { | |||
| 54 | + | udp net.PacketConn | |||
| 55 | + | tcp net.Listener | |||
| 56 | + | cancel context.CancelFunc | |||
| 57 | + | done sync.WaitGroup | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // New returns a Forwarder. upstreams returns the "host:port" resolvers to try | |||
| 61 | + | // in order (see Resolvers.Addrs); port is the port to listen on (53, or another | |||
| 62 | + | // port in tests). | |||
| 63 | + | func New(upstreams func() []string, port int) *Forwarder { | |||
| 64 | + | return &Forwarder{ | |||
| 65 | + | upstreams: upstreams, | |||
| 66 | + | port: port, | |||
| 67 | + | sem: make(chan struct{}, MaxInflight), | |||
| 68 | + | listeners: map[netip.Addr]*listener{}, | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Listen starts serving on addr (UDP and TCP). It returns once both sockets are | |||
| 73 | + | // bound. Listening twice on the same address is an error. | |||
| 74 | + | func (f *Forwarder) Listen(ctx context.Context, addr netip.Addr) error { | |||
| 75 | + | f.mu.Lock() | |||
| 76 | + | defer f.mu.Unlock() | |||
| 77 | + | if _, ok := f.listeners[addr]; ok { | |||
| 78 | + | return fmt.Errorf("dnsfwd: already listening on %s", addr) | |||
| 79 | + | } | |||
| 80 | + | hp := net.JoinHostPort(addr.String(), strconv.Itoa(f.port)) | |||
| 81 | + | var lc net.ListenConfig | |||
| 82 | + | pc, err := lc.ListenPacket(ctx, "udp", hp) | |||
| 83 | + | if err != nil { | |||
| 84 | + | return fmt.Errorf("dnsfwd: listen udp %s: %w", hp, err) | |||
| 85 | + | } | |||
| 86 | + | // With port 0 (tests) UDP and TCP must share the port the kernel picked. | |||
| 87 | + | if f.port == 0 { | |||
| 88 | + | hp = pc.LocalAddr().String() | |||
| 89 | + | } | |||
| 90 | + | tl, err := lc.Listen(ctx, "tcp", hp) | |||
| 91 | + | if err != nil { | |||
| 92 | + | _ = pc.Close() | |||
| 93 | + | return fmt.Errorf("dnsfwd: listen tcp %s: %w", hp, err) | |||
| 94 | + | } | |||
| 95 | + | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |||
| 96 | + | l := &listener{udp: pc, tcp: tl, cancel: cancel} | |||
| 97 | + | f.listeners[addr] = l | |||
| 98 | + | log := zerowrap.FromCtx(ctx) | |||
| 99 | + | l.done.Add(2) | |||
| 100 | + | go func() { defer l.done.Done(); f.serveUDP(lctx, pc, log) }() | |||
| 101 | + | go func() { defer l.done.Done(); f.serveTCP(lctx, tl, log) }() | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Addr returns the bound UDP address for addr, useful when port is 0. | |||
| 106 | + | func (f *Forwarder) Addr(addr netip.Addr) (net.Addr, bool) { | |||
| 107 | + | f.mu.Lock() | |||
| 108 | + | defer f.mu.Unlock() | |||
| 109 | + | l, ok := f.listeners[addr] | |||
| 110 | + | if !ok { | |||
| 111 | + | return nil, false | |||
| 112 | + | } | |||
| 113 | + | return l.udp.LocalAddr(), true | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Close stops listening on addr and waits for its goroutines. Unknown | |||
| 117 | + | // addresses are ignored. | |||
| 118 | + | func (f *Forwarder) Close(addr netip.Addr) { | |||
| 119 | + | f.mu.Lock() | |||
| 120 | + | l, ok := f.listeners[addr] | |||
| 121 | + | delete(f.listeners, addr) | |||
| 122 | + | f.mu.Unlock() | |||
| 123 | + | if !ok { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | l.cancel() | |||
| 127 | + | _ = l.udp.Close() | |||
| 128 | + | _ = l.tcp.Close() | |||
| 129 | + | l.done.Wait() | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // CloseAll stops every listener. | |||
| 133 | + | func (f *Forwarder) CloseAll() { | |||
| 134 | + | f.mu.Lock() | |||
| 135 | + | addrs := make([]netip.Addr, 0, len(f.listeners)) | |||
| 136 | + | for a := range f.listeners { | |||
| 137 | + | addrs = append(addrs, a) | |||
| 138 | + | } | |||
| 139 | + | f.mu.Unlock() | |||
| 140 | + | for _, a := range addrs { | |||
| 141 | + | f.Close(a) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (f *Forwarder) acquire(ctx context.Context) bool { | |||
| 146 | + | select { | |||
| 147 | + | case f.sem <- struct{}{}: | |||
| 148 | + | return true | |||
| 149 | + | case <-ctx.Done(): | |||
| 150 | + | return false | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func (f *Forwarder) release() { <-f.sem } | |||
| 155 | + | ||||
| 156 | + | func (f *Forwarder) serveUDP(ctx context.Context, pc net.PacketConn, log zerowrap.Logger) { | |||
| 157 | + | buf := make([]byte, maxUDP) | |||
| 158 | + | for { | |||
| 159 | + | n, from, err := pc.ReadFrom(buf) | |||
| 160 | + | if err != nil { | |||
| 161 | + | if ctx.Err() == nil && !errors.Is(err, net.ErrClosed) { | |||
| 162 | + | log.Warn().Err(err).Msg("dns forwarder: udp read") | |||
| 163 | + | } | |||
| 164 | + | return | |||
| 165 | + | } | |||
| 166 | + | if n < hdrLen { | |||
| 167 | + | continue | |||
| 168 | + | } | |||
| 169 | + | q := append([]byte(nil), buf[:n]...) | |||
| 170 | + | if !f.acquire(ctx) { | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | go func() { | |||
| 174 | + | defer f.release() | |||
| 175 | + | resp := f.exchange(ctx, q, false) | |||
| 176 | + | if len(resp) > 0 { | |||
| 177 | + | _, _ = pc.WriteTo(resp, from) | |||
| 178 | + | } | |||
| 179 | + | }() | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (f *Forwarder) serveTCP(ctx context.Context, l net.Listener, log zerowrap.Logger) { | |||
| 184 | + | var wg sync.WaitGroup | |||
| 185 | + | defer wg.Wait() | |||
| 186 | + | for { | |||
| 187 | + | c, err := l.Accept() | |||
| 188 | + | if err != nil { | |||
| 189 | + | if ctx.Err() == nil && !errors.Is(err, net.ErrClosed) { | |||
| 190 | + | log.Warn().Err(err).Msg("dns forwarder: tcp accept") | |||
| 191 | + | } | |||
| 192 | + | return | |||
| 193 | + | } | |||
| 194 | + | wg.Add(1) | |||
| 195 | + | go func() { | |||
| 196 | + | defer wg.Done() | |||
| 197 | + | defer func() { _ = c.Close() }() | |||
| 198 | + | stop := context.AfterFunc(ctx, func() { _ = c.Close() }) | |||
| 199 | + | defer stop() | |||
| 200 | + | f.serveConn(ctx, c) | |||
| 201 | + | }() | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (f *Forwarder) serveConn(ctx context.Context, c net.Conn) { | |||
| 206 | + | for range TCPMaxQueries { | |||
| 207 | + | _ = c.SetReadDeadline(time.Now().Add(TCPIdleTimeout)) | |||
| 208 | + | var l [2]byte | |||
| 209 | + | if _, err := io.ReadFull(c, l[:]); err != nil { | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | n := int(binary.BigEndian.Uint16(l[:])) | |||
| 213 | + | if n < hdrLen { | |||
| 214 | + | return | |||
| 215 | + | } | |||
| 216 | + | q := make([]byte, n) | |||
| 217 | + | if _, err := io.ReadFull(c, q); err != nil { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | if !f.acquire(ctx) { | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | resp := f.exchange(ctx, q, true) | |||
| 224 | + | f.release() | |||
| 225 | + | if len(resp) == 0 || len(resp) > 0xffff { | |||
| 226 | + | return | |||
| 227 | + | } | |||
| 228 | + | out := make([]byte, 2+len(resp)) | |||
| 229 | + | binary.BigEndian.PutUint16(out, uint16(len(resp))) | |||
| 230 | + | copy(out[2:], resp) | |||
| 231 | + | _ = c.SetWriteDeadline(time.Now().Add(TCPIdleTimeout)) | |||
| 232 | + | if _, err := c.Write(out); err != nil { | |||
| 233 | + | return | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // exchange relays q to the upstreams in order and returns the first answer, or | |||
| 239 | + | // a SERVFAIL when none answers. The transport towards the upstream matches the | |||
| 240 | + | // client's: a client that fell back to TCP gets a complete answer. | |||
| 241 | + | func (f *Forwarder) exchange(ctx context.Context, q []byte, overTCP bool) []byte { | |||
| 242 | + | for _, up := range f.upstreams() { | |||
| 243 | + | var resp []byte | |||
| 244 | + | var err error | |||
| 245 | + | if overTCP { | |||
| 246 | + | resp, err = exchangeTCP(ctx, up, q) | |||
| 247 | + | } else { | |||
| 248 | + | resp, err = exchangeUDP(ctx, up, q) | |||
| 249 | + | } | |||
| 250 | + | if err == nil && len(resp) >= hdrLen && resp[0] == q[0] && resp[1] == q[1] { | |||
| 251 | + | return resp | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | return servfail(q) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func exchangeUDP(ctx context.Context, up string, q []byte) ([]byte, error) { | |||
| 258 | + | ctx, cancel := context.WithTimeout(ctx, UpstreamTimeout) | |||
| 259 | + | defer cancel() | |||
| 260 | + | var d net.Dialer | |||
| 261 | + | c, err := d.DialContext(ctx, "udp", up) | |||
| 262 | + | if err != nil { | |||
| 263 | + | return nil, err | |||
| 264 | + | } | |||
| 265 | + | defer func() { _ = c.Close() }() | |||
| 266 | + | dl, _ := ctx.Deadline() | |||
| 267 | + | _ = c.SetDeadline(dl) | |||
| 268 | + | if _, err := c.Write(q); err != nil { | |||
| 269 | + | return nil, err | |||
| 270 | + | } | |||
| 271 | + | buf := make([]byte, maxUDP) | |||
| 272 | + | for { | |||
| 273 | + | n, err := c.Read(buf) | |||
| 274 | + | if err != nil { | |||
| 275 | + | return nil, err | |||
| 276 | + | } | |||
| 277 | + | // Ignore datagrams that do not answer this query (ID mismatch). | |||
| 278 | + | if n >= hdrLen && buf[0] == q[0] && buf[1] == q[1] { | |||
| 279 | + | return append([]byte(nil), buf[:n]...), nil | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func exchangeTCP(ctx context.Context, up string, q []byte) ([]byte, error) { | |||
| 285 | + | ctx, cancel := context.WithTimeout(ctx, UpstreamTimeout) | |||
| 286 | + | defer cancel() | |||
| 287 | + | var d net.Dialer | |||
| 288 | + | c, err := d.DialContext(ctx, "tcp", up) | |||
| 289 | + | if err != nil { | |||
| 290 | + | return nil, err | |||
| 291 | + | } | |||
| 292 | + | defer func() { _ = c.Close() }() | |||
| 293 | + | dl, _ := ctx.Deadline() | |||
| 294 | + | _ = c.SetDeadline(dl) | |||
| 295 | + | out := make([]byte, 2+len(q)) | |||
| 296 | + | binary.BigEndian.PutUint16(out, uint16(len(q))) | |||
| 297 | + | copy(out[2:], q) | |||
| 298 | + | if _, err := c.Write(out); err != nil { | |||
| 299 | + | return nil, err | |||
| 300 | + | } | |||
| 301 | + | var l [2]byte | |||
| 302 | + | if _, err := io.ReadFull(c, l[:]); err != nil { | |||
| 303 | + | return nil, err | |||
| 304 | + | } | |||
| 305 | + | resp := make([]byte, binary.BigEndian.Uint16(l[:])) | |||
| 306 | + | if _, err := io.ReadFull(c, resp); err != nil { | |||
| 307 | + | return nil, err | |||
| 308 | + | } | |||
| 309 | + | return resp, nil | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // servfail builds a SERVFAIL reply for q: same ID and opcode, QR and RA set, | |||
| 313 | + | // RD copied, no records. | |||
| 314 | + | func servfail(q []byte) []byte { | |||
| 315 | + | if len(q) < hdrLen { | |||
| 316 | + | return nil | |||
| 317 | + | } | |||
| 318 | + | r := make([]byte, hdrLen) | |||
| 319 | + | r[0], r[1] = q[0], q[1] | |||
| 320 | + | r[2] = 0x80 | (q[2] & 0x79) // QR, opcode, RD kept; AA/TC cleared | |||
| 321 | + | r[3] = 0x80 | 0x02 // RA, RCODE=SERVFAIL | |||
| 322 | + | return r | |||
| 323 | + | } | |||
A internal/dnsfwd/dnsfwd_test.go +297 -0
| @@ -0,0 +1,297 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package dnsfwd | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/binary" | |
| 8 | + | "io" | |
| 9 | + | "net" | |
| 10 | + | "net/netip" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "reflect" | |
| 14 | + | "strings" | |
| 15 | + | "testing" | |
| 16 | + | "time" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // query builds a minimal query for name with the given ID. | |
| 20 | + | func query(id uint16, name string) []byte { | |
| 21 | + | b := make([]byte, hdrLen) | |
| 22 | + | binary.BigEndian.PutUint16(b, id) | |
| 23 | + | b[2] = 0x01 // RD | |
| 24 | + | b[5] = 1 // QDCOUNT | |
| 25 | + | for _, l := range strings.Split(name, ".") { | |
| 26 | + | b = append(b, byte(len(l))) | |
| 27 | + | b = append(b, l...) | |
| 28 | + | } | |
| 29 | + | return append(b, 0, 0, 1, 0, 1) // root, A, IN | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // answer turns a query into a response carrying a recognisable payload. | |
| 33 | + | func answer(q []byte) []byte { | |
| 34 | + | r := append([]byte(nil), q...) | |
| 35 | + | r[2] |= 0x80 | |
| 36 | + | return append(r, "ANSWER"...) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // fakeUpstream serves UDP and TCP on one port of 127.0.0.1 and returns it. | |
| 40 | + | func fakeUpstream(t *testing.T, handle func(q []byte) []byte) string { | |
| 41 | + | t.Helper() | |
| 42 | + | pc, err := net.ListenPacket("udp", "127.0.0.1:0") | |
| 43 | + | if err != nil { | |
| 44 | + | t.Skip("no loopback:", err) | |
| 45 | + | } | |
| 46 | + | addr := pc.LocalAddr().String() | |
| 47 | + | tl, err := net.Listen("tcp", addr) | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | t.Cleanup(func() { _ = pc.Close(); _ = tl.Close() }) | |
| 52 | + | go func() { | |
| 53 | + | buf := make([]byte, 4096) | |
| 54 | + | for { | |
| 55 | + | n, from, err := pc.ReadFrom(buf) | |
| 56 | + | if err != nil { | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | if r := handle(append([]byte(nil), buf[:n]...)); r != nil { | |
| 60 | + | _, _ = pc.WriteTo(r, from) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | }() | |
| 64 | + | go func() { | |
| 65 | + | for { | |
| 66 | + | c, err := tl.Accept() | |
| 67 | + | if err != nil { | |
| 68 | + | return | |
| 69 | + | } | |
| 70 | + | go func() { | |
| 71 | + | defer func() { _ = c.Close() }() | |
| 72 | + | var l [2]byte | |
| 73 | + | if _, err := io.ReadFull(c, l[:]); err != nil { | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | q := make([]byte, binary.BigEndian.Uint16(l[:])) | |
| 77 | + | if _, err := io.ReadFull(c, q); err != nil { | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | r := handle(q) | |
| 81 | + | out := make([]byte, 2+len(r)) | |
| 82 | + | binary.BigEndian.PutUint16(out, uint16(len(r))) | |
| 83 | + | copy(out[2:], r) | |
| 84 | + | _, _ = c.Write(out) | |
| 85 | + | }() | |
| 86 | + | } | |
| 87 | + | }() | |
| 88 | + | return addr | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func start(t *testing.T, ups ...string) (*Forwarder, string) { | |
| 92 | + | t.Helper() | |
| 93 | + | f := New(func() []string { return ups }, 0) | |
| 94 | + | ip := netip.MustParseAddr("127.0.0.1") | |
| 95 | + | if err := f.Listen(context.Background(), ip); err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | t.Cleanup(f.CloseAll) | |
| 99 | + | a, _ := f.Addr(ip) | |
| 100 | + | return f, a.String() | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestUDPAndTCPForwarding(t *testing.T) { | |
| 104 | + | up := fakeUpstream(t, answer) | |
| 105 | + | _, addr := start(t, up) | |
| 106 | + | ||
| 107 | + | c, err := net.Dial("udp", addr) | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | defer func() { _ = c.Close() }() | |
| 112 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |
| 113 | + | q := query(0x1234, "example.test") | |
| 114 | + | _, _ = c.Write(q) | |
| 115 | + | buf := make([]byte, 512) | |
| 116 | + | n, err := c.Read(buf) | |
| 117 | + | if err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if string(buf[n-6:n]) != "ANSWER" || binary.BigEndian.Uint16(buf) != 0x1234 { | |
| 121 | + | t.Fatalf("udp answer: %x", buf[:n]) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | tc, err := net.Dial("tcp", addr) | |
| 125 | + | if err != nil { | |
| 126 | + | t.Fatal(err) | |
| 127 | + | } | |
| 128 | + | defer func() { _ = tc.Close() }() | |
| 129 | + | _ = tc.SetDeadline(time.Now().Add(5 * time.Second)) | |
| 130 | + | // Two queries on one connection. | |
| 131 | + | for _, id := range []uint16{7, 8} { | |
| 132 | + | q := query(id, "tcp.test") | |
| 133 | + | framed := append([]byte{byte(len(q) >> 8), byte(len(q))}, q...) | |
| 134 | + | _, _ = tc.Write(framed) | |
| 135 | + | var l [2]byte | |
| 136 | + | if _, err := io.ReadFull(tc, l[:]); err != nil { | |
| 137 | + | t.Fatal(err) | |
| 138 | + | } | |
| 139 | + | r := make([]byte, binary.BigEndian.Uint16(l[:])) | |
| 140 | + | if _, err := io.ReadFull(tc, r); err != nil { | |
| 141 | + | t.Fatal(err) | |
| 142 | + | } | |
| 143 | + | if binary.BigEndian.Uint16(r) != id || string(r[len(r)-6:]) != "ANSWER" { | |
| 144 | + | t.Fatalf("tcp answer for %d: %x", id, r) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestFailoverToSecondUpstream(t *testing.T) { | |
| 150 | + | dead := "127.0.0.1:1" // nothing listens: UDP read fails with ICMP refused | |
| 151 | + | live := fakeUpstream(t, answer) | |
| 152 | + | _, addr := start(t, dead, live) | |
| 153 | + | c, _ := net.Dial("udp", addr) | |
| 154 | + | defer func() { _ = c.Close() }() | |
| 155 | + | _ = c.SetDeadline(time.Now().Add(8 * time.Second)) | |
| 156 | + | _, _ = c.Write(query(1, "a.test")) | |
| 157 | + | buf := make([]byte, 512) | |
| 158 | + | n, err := c.Read(buf) | |
| 159 | + | if err != nil || string(buf[n-6:n]) != "ANSWER" { | |
| 160 | + | t.Fatalf("n=%d err=%v", n, err) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestServfailWhenNoUpstreamAnswers(t *testing.T) { | |
| 165 | + | _, addr := start(t) // no upstream at all | |
| 166 | + | c, _ := net.Dial("udp", addr) | |
| 167 | + | defer func() { _ = c.Close() }() | |
| 168 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |
| 169 | + | _, _ = c.Write(query(0xbeef, "a.test")) | |
| 170 | + | buf := make([]byte, 512) | |
| 171 | + | n, err := c.Read(buf) | |
| 172 | + | if err != nil { | |
| 173 | + | t.Fatal(err) | |
| 174 | + | } | |
| 175 | + | if n != hdrLen || binary.BigEndian.Uint16(buf) != 0xbeef || buf[3]&0x0f != 2 || buf[2]&0x80 == 0 { | |
| 176 | + | t.Fatalf("not a SERVFAIL: %x", buf[:n]) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestMismatchedUpstreamIDIgnored(t *testing.T) { | |
| 181 | + | // Upstream answers with a wrong ID: treated as no answer, client gets SERVFAIL. | |
| 182 | + | up := fakeUpstream(t, func(q []byte) []byte { | |
| 183 | + | r := answer(q) | |
| 184 | + | r[0] ^= 0xff | |
| 185 | + | return r | |
| 186 | + | }) | |
| 187 | + | f, addr := start(t, up) | |
| 188 | + | _ = f | |
| 189 | + | c, _ := net.Dial("udp", addr) | |
| 190 | + | defer func() { _ = c.Close() }() | |
| 191 | + | _ = c.SetDeadline(time.Now().Add(2*UpstreamTimeout + 2*time.Second)) | |
| 192 | + | _, _ = c.Write(query(5, "a.test")) | |
| 193 | + | buf := make([]byte, 512) | |
| 194 | + | n, err := c.Read(buf) | |
| 195 | + | if err != nil { | |
| 196 | + | t.Fatal(err) | |
| 197 | + | } | |
| 198 | + | if buf[3]&0x0f != 2 { | |
| 199 | + | t.Fatalf("want SERVFAIL, got %x", buf[:n]) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestShortPacketsIgnored(t *testing.T) { | |
| 204 | + | up := fakeUpstream(t, answer) | |
| 205 | + | _, addr := start(t, up) | |
| 206 | + | c, _ := net.Dial("udp", addr) | |
| 207 | + | defer func() { _ = c.Close() }() | |
| 208 | + | _, _ = c.Write([]byte{1, 2, 3}) // dropped | |
| 209 | + | _, _ = c.Write(query(9, "a.test")) | |
| 210 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |
| 211 | + | buf := make([]byte, 512) | |
| 212 | + | n, err := c.Read(buf) | |
| 213 | + | if err != nil || binary.BigEndian.Uint16(buf) != 9 { | |
| 214 | + | t.Fatalf("n=%d err=%v", n, err) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func TestListenTwiceAndClose(t *testing.T) { | |
| 219 | + | f := New(func() []string { return nil }, 0) | |
| 220 | + | ip := netip.MustParseAddr("127.0.0.1") | |
| 221 | + | if err := f.Listen(context.Background(), ip); err != nil { | |
| 222 | + | t.Skip(err) | |
| 223 | + | } | |
| 224 | + | if err := f.Listen(context.Background(), ip); err == nil { | |
| 225 | + | t.Fatal("second Listen on the same address must fail") | |
| 226 | + | } | |
| 227 | + | a, _ := f.Addr(ip) | |
| 228 | + | f.Close(ip) | |
| 229 | + | f.Close(ip) // idempotent | |
| 230 | + | if _, ok := f.Addr(ip); ok { | |
| 231 | + | t.Fatal("still listening after Close") | |
| 232 | + | } | |
| 233 | + | if pc, err := net.ListenPacket("udp", a.String()); err != nil { | |
| 234 | + | t.Fatalf("port not released: %v", err) | |
| 235 | + | } else { | |
| 236 | + | _ = pc.Close() | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestServfailShape(t *testing.T) { | |
| 241 | + | if servfail([]byte{1}) != nil { | |
| 242 | + | t.Fatal("short query must give nil") | |
| 243 | + | } | |
| 244 | + | q := query(0xabcd, "x.test") | |
| 245 | + | q[2] = 0x79 // opcode bits + RD + AA | |
| 246 | + | r := servfail(q) | |
| 247 | + | if r[0] != 0xab || r[1] != 0xcd || r[2]&0x80 == 0 || r[3] != 0x82 { | |
| 248 | + | t.Fatalf("bad servfail %x", r) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestParseResolvConf(t *testing.T) { | |
| 253 | + | in := `# comment | |
| 254 | + | nameserver 127.0.0.53 | |
| 255 | + | nameserver 127.0.0.53 | |
| 256 | + | nameserver 192.0.2.1 # trailing | |
| 257 | + | nameserver 2001:db8::1 | |
| 258 | + | nameserver fe80::1%eth0 | |
| 259 | + | nameserver 0.0.0.0 | |
| 260 | + | nameserver not-an-ip | |
| 261 | + | search example.test | |
| 262 | + | options edns0 | |
| 263 | + | nameserver | |
| 264 | + | ` | |
| 265 | + | got := ParseResolvConf(strings.NewReader(in)) | |
| 266 | + | want := []string{"127.0.0.53:53", "192.0.2.1:53", "[2001:db8::1]:53", "[fe80::1%eth0]:53"} | |
| 267 | + | if !reflect.DeepEqual(got, want) { | |
| 268 | + | t.Fatalf("got %v want %v", got, want) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func TestResolversReload(t *testing.T) { | |
| 273 | + | p := filepath.Join(t.TempDir(), "resolv.conf") | |
| 274 | + | if err := os.WriteFile(p, []byte("nameserver 192.0.2.1\n"), 0o644); err != nil { | |
| 275 | + | t.Fatal(err) | |
| 276 | + | } | |
| 277 | + | r := NewResolvers(p) | |
| 278 | + | if got := r.Addrs(); !reflect.DeepEqual(got, []string{"192.0.2.1:53"}) { | |
| 279 | + | t.Fatal(got) | |
| 280 | + | } | |
| 281 | + | if err := os.WriteFile(p, []byte("nameserver 192.0.2.99\nnameserver 192.0.2.1\n"), 0o644); err != nil { | |
| 282 | + | t.Fatal(err) | |
| 283 | + | } | |
| 284 | + | later := time.Now().Add(2 * time.Second) | |
| 285 | + | _ = os.Chtimes(p, later, later) | |
| 286 | + | if got := r.Addrs(); !reflect.DeepEqual(got, []string{"192.0.2.99:53", "192.0.2.1:53"}) { | |
| 287 | + | t.Fatal(got) | |
| 288 | + | } | |
| 289 | + | // Unreadable file keeps the last known list. | |
| 290 | + | _ = os.Remove(p) | |
| 291 | + | if got := r.Addrs(); len(got) != 2 { | |
| 292 | + | t.Fatalf("lost the last known list: %v", got) | |
| 293 | + | } | |
| 294 | + | if got := NewResolvers(filepath.Join(t.TempDir(), "missing")).Addrs(); len(got) != 0 { | |
| 295 | + | t.Fatal(got) | |
| 296 | + | } | |
| 297 | + | } | |
| @@ -0,0 +1,297 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package dnsfwd | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/binary" | |||
| 8 | + | "io" | |||
| 9 | + | "net" | |||
| 10 | + | "net/netip" | |||
| 11 | + | "os" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "reflect" | |||
| 14 | + | "strings" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // query builds a minimal query for name with the given ID. | |||
| 20 | + | func query(id uint16, name string) []byte { | |||
| 21 | + | b := make([]byte, hdrLen) | |||
| 22 | + | binary.BigEndian.PutUint16(b, id) | |||
| 23 | + | b[2] = 0x01 // RD | |||
| 24 | + | b[5] = 1 // QDCOUNT | |||
| 25 | + | for _, l := range strings.Split(name, ".") { | |||
| 26 | + | b = append(b, byte(len(l))) | |||
| 27 | + | b = append(b, l...) | |||
| 28 | + | } | |||
| 29 | + | return append(b, 0, 0, 1, 0, 1) // root, A, IN | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // answer turns a query into a response carrying a recognisable payload. | |||
| 33 | + | func answer(q []byte) []byte { | |||
| 34 | + | r := append([]byte(nil), q...) | |||
| 35 | + | r[2] |= 0x80 | |||
| 36 | + | return append(r, "ANSWER"...) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // fakeUpstream serves UDP and TCP on one port of 127.0.0.1 and returns it. | |||
| 40 | + | func fakeUpstream(t *testing.T, handle func(q []byte) []byte) string { | |||
| 41 | + | t.Helper() | |||
| 42 | + | pc, err := net.ListenPacket("udp", "127.0.0.1:0") | |||
| 43 | + | if err != nil { | |||
| 44 | + | t.Skip("no loopback:", err) | |||
| 45 | + | } | |||
| 46 | + | addr := pc.LocalAddr().String() | |||
| 47 | + | tl, err := net.Listen("tcp", addr) | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | t.Cleanup(func() { _ = pc.Close(); _ = tl.Close() }) | |||
| 52 | + | go func() { | |||
| 53 | + | buf := make([]byte, 4096) | |||
| 54 | + | for { | |||
| 55 | + | n, from, err := pc.ReadFrom(buf) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 59 | + | if r := handle(append([]byte(nil), buf[:n]...)); r != nil { | |||
| 60 | + | _, _ = pc.WriteTo(r, from) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | }() | |||
| 64 | + | go func() { | |||
| 65 | + | for { | |||
| 66 | + | c, err := tl.Accept() | |||
| 67 | + | if err != nil { | |||
| 68 | + | return | |||
| 69 | + | } | |||
| 70 | + | go func() { | |||
| 71 | + | defer func() { _ = c.Close() }() | |||
| 72 | + | var l [2]byte | |||
| 73 | + | if _, err := io.ReadFull(c, l[:]); err != nil { | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | q := make([]byte, binary.BigEndian.Uint16(l[:])) | |||
| 77 | + | if _, err := io.ReadFull(c, q); err != nil { | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | r := handle(q) | |||
| 81 | + | out := make([]byte, 2+len(r)) | |||
| 82 | + | binary.BigEndian.PutUint16(out, uint16(len(r))) | |||
| 83 | + | copy(out[2:], r) | |||
| 84 | + | _, _ = c.Write(out) | |||
| 85 | + | }() | |||
| 86 | + | } | |||
| 87 | + | }() | |||
| 88 | + | return addr | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func start(t *testing.T, ups ...string) (*Forwarder, string) { | |||
| 92 | + | t.Helper() | |||
| 93 | + | f := New(func() []string { return ups }, 0) | |||
| 94 | + | ip := netip.MustParseAddr("127.0.0.1") | |||
| 95 | + | if err := f.Listen(context.Background(), ip); err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | t.Cleanup(f.CloseAll) | |||
| 99 | + | a, _ := f.Addr(ip) | |||
| 100 | + | return f, a.String() | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestUDPAndTCPForwarding(t *testing.T) { | |||
| 104 | + | up := fakeUpstream(t, answer) | |||
| 105 | + | _, addr := start(t, up) | |||
| 106 | + | ||||
| 107 | + | c, err := net.Dial("udp", addr) | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatal(err) | |||
| 110 | + | } | |||
| 111 | + | defer func() { _ = c.Close() }() | |||
| 112 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |||
| 113 | + | q := query(0x1234, "example.test") | |||
| 114 | + | _, _ = c.Write(q) | |||
| 115 | + | buf := make([]byte, 512) | |||
| 116 | + | n, err := c.Read(buf) | |||
| 117 | + | if err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | if string(buf[n-6:n]) != "ANSWER" || binary.BigEndian.Uint16(buf) != 0x1234 { | |||
| 121 | + | t.Fatalf("udp answer: %x", buf[:n]) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | tc, err := net.Dial("tcp", addr) | |||
| 125 | + | if err != nil { | |||
| 126 | + | t.Fatal(err) | |||
| 127 | + | } | |||
| 128 | + | defer func() { _ = tc.Close() }() | |||
| 129 | + | _ = tc.SetDeadline(time.Now().Add(5 * time.Second)) | |||
| 130 | + | // Two queries on one connection. | |||
| 131 | + | for _, id := range []uint16{7, 8} { | |||
| 132 | + | q := query(id, "tcp.test") | |||
| 133 | + | framed := append([]byte{byte(len(q) >> 8), byte(len(q))}, q...) | |||
| 134 | + | _, _ = tc.Write(framed) | |||
| 135 | + | var l [2]byte | |||
| 136 | + | if _, err := io.ReadFull(tc, l[:]); err != nil { | |||
| 137 | + | t.Fatal(err) | |||
| 138 | + | } | |||
| 139 | + | r := make([]byte, binary.BigEndian.Uint16(l[:])) | |||
| 140 | + | if _, err := io.ReadFull(tc, r); err != nil { | |||
| 141 | + | t.Fatal(err) | |||
| 142 | + | } | |||
| 143 | + | if binary.BigEndian.Uint16(r) != id || string(r[len(r)-6:]) != "ANSWER" { | |||
| 144 | + | t.Fatalf("tcp answer for %d: %x", id, r) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestFailoverToSecondUpstream(t *testing.T) { | |||
| 150 | + | dead := "127.0.0.1:1" // nothing listens: UDP read fails with ICMP refused | |||
| 151 | + | live := fakeUpstream(t, answer) | |||
| 152 | + | _, addr := start(t, dead, live) | |||
| 153 | + | c, _ := net.Dial("udp", addr) | |||
| 154 | + | defer func() { _ = c.Close() }() | |||
| 155 | + | _ = c.SetDeadline(time.Now().Add(8 * time.Second)) | |||
| 156 | + | _, _ = c.Write(query(1, "a.test")) | |||
| 157 | + | buf := make([]byte, 512) | |||
| 158 | + | n, err := c.Read(buf) | |||
| 159 | + | if err != nil || string(buf[n-6:n]) != "ANSWER" { | |||
| 160 | + | t.Fatalf("n=%d err=%v", n, err) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestServfailWhenNoUpstreamAnswers(t *testing.T) { | |||
| 165 | + | _, addr := start(t) // no upstream at all | |||
| 166 | + | c, _ := net.Dial("udp", addr) | |||
| 167 | + | defer func() { _ = c.Close() }() | |||
| 168 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |||
| 169 | + | _, _ = c.Write(query(0xbeef, "a.test")) | |||
| 170 | + | buf := make([]byte, 512) | |||
| 171 | + | n, err := c.Read(buf) | |||
| 172 | + | if err != nil { | |||
| 173 | + | t.Fatal(err) | |||
| 174 | + | } | |||
| 175 | + | if n != hdrLen || binary.BigEndian.Uint16(buf) != 0xbeef || buf[3]&0x0f != 2 || buf[2]&0x80 == 0 { | |||
| 176 | + | t.Fatalf("not a SERVFAIL: %x", buf[:n]) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestMismatchedUpstreamIDIgnored(t *testing.T) { | |||
| 181 | + | // Upstream answers with a wrong ID: treated as no answer, client gets SERVFAIL. | |||
| 182 | + | up := fakeUpstream(t, func(q []byte) []byte { | |||
| 183 | + | r := answer(q) | |||
| 184 | + | r[0] ^= 0xff | |||
| 185 | + | return r | |||
| 186 | + | }) | |||
| 187 | + | f, addr := start(t, up) | |||
| 188 | + | _ = f | |||
| 189 | + | c, _ := net.Dial("udp", addr) | |||
| 190 | + | defer func() { _ = c.Close() }() | |||
| 191 | + | _ = c.SetDeadline(time.Now().Add(2*UpstreamTimeout + 2*time.Second)) | |||
| 192 | + | _, _ = c.Write(query(5, "a.test")) | |||
| 193 | + | buf := make([]byte, 512) | |||
| 194 | + | n, err := c.Read(buf) | |||
| 195 | + | if err != nil { | |||
| 196 | + | t.Fatal(err) | |||
| 197 | + | } | |||
| 198 | + | if buf[3]&0x0f != 2 { | |||
| 199 | + | t.Fatalf("want SERVFAIL, got %x", buf[:n]) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func TestShortPacketsIgnored(t *testing.T) { | |||
| 204 | + | up := fakeUpstream(t, answer) | |||
| 205 | + | _, addr := start(t, up) | |||
| 206 | + | c, _ := net.Dial("udp", addr) | |||
| 207 | + | defer func() { _ = c.Close() }() | |||
| 208 | + | _, _ = c.Write([]byte{1, 2, 3}) // dropped | |||
| 209 | + | _, _ = c.Write(query(9, "a.test")) | |||
| 210 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |||
| 211 | + | buf := make([]byte, 512) | |||
| 212 | + | n, err := c.Read(buf) | |||
| 213 | + | if err != nil || binary.BigEndian.Uint16(buf) != 9 { | |||
| 214 | + | t.Fatalf("n=%d err=%v", n, err) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func TestListenTwiceAndClose(t *testing.T) { | |||
| 219 | + | f := New(func() []string { return nil }, 0) | |||
| 220 | + | ip := netip.MustParseAddr("127.0.0.1") | |||
| 221 | + | if err := f.Listen(context.Background(), ip); err != nil { | |||
| 222 | + | t.Skip(err) | |||
| 223 | + | } | |||
| 224 | + | if err := f.Listen(context.Background(), ip); err == nil { | |||
| 225 | + | t.Fatal("second Listen on the same address must fail") | |||
| 226 | + | } | |||
| 227 | + | a, _ := f.Addr(ip) | |||
| 228 | + | f.Close(ip) | |||
| 229 | + | f.Close(ip) // idempotent | |||
| 230 | + | if _, ok := f.Addr(ip); ok { | |||
| 231 | + | t.Fatal("still listening after Close") | |||
| 232 | + | } | |||
| 233 | + | if pc, err := net.ListenPacket("udp", a.String()); err != nil { | |||
| 234 | + | t.Fatalf("port not released: %v", err) | |||
| 235 | + | } else { | |||
| 236 | + | _ = pc.Close() | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestServfailShape(t *testing.T) { | |||
| 241 | + | if servfail([]byte{1}) != nil { | |||
| 242 | + | t.Fatal("short query must give nil") | |||
| 243 | + | } | |||
| 244 | + | q := query(0xabcd, "x.test") | |||
| 245 | + | q[2] = 0x79 // opcode bits + RD + AA | |||
| 246 | + | r := servfail(q) | |||
| 247 | + | if r[0] != 0xab || r[1] != 0xcd || r[2]&0x80 == 0 || r[3] != 0x82 { | |||
| 248 | + | t.Fatalf("bad servfail %x", r) | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestParseResolvConf(t *testing.T) { | |||
| 253 | + | in := `# comment | |||
| 254 | + | nameserver 127.0.0.53 | |||
| 255 | + | nameserver 127.0.0.53 | |||
| 256 | + | nameserver 192.0.2.1 # trailing | |||
| 257 | + | nameserver 2001:db8::1 | |||
| 258 | + | nameserver fe80::1%eth0 | |||
| 259 | + | nameserver 0.0.0.0 | |||
| 260 | + | nameserver not-an-ip | |||
| 261 | + | search example.test | |||
| 262 | + | options edns0 | |||
| 263 | + | nameserver | |||
| 264 | + | ` | |||
| 265 | + | got := ParseResolvConf(strings.NewReader(in)) | |||
| 266 | + | want := []string{"127.0.0.53:53", "192.0.2.1:53", "[2001:db8::1]:53", "[fe80::1%eth0]:53"} | |||
| 267 | + | if !reflect.DeepEqual(got, want) { | |||
| 268 | + | t.Fatalf("got %v want %v", got, want) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func TestResolversReload(t *testing.T) { | |||
| 273 | + | p := filepath.Join(t.TempDir(), "resolv.conf") | |||
| 274 | + | if err := os.WriteFile(p, []byte("nameserver 192.0.2.1\n"), 0o644); err != nil { | |||
| 275 | + | t.Fatal(err) | |||
| 276 | + | } | |||
| 277 | + | r := NewResolvers(p) | |||
| 278 | + | if got := r.Addrs(); !reflect.DeepEqual(got, []string{"192.0.2.1:53"}) { | |||
| 279 | + | t.Fatal(got) | |||
| 280 | + | } | |||
| 281 | + | if err := os.WriteFile(p, []byte("nameserver 192.0.2.99\nnameserver 192.0.2.1\n"), 0o644); err != nil { | |||
| 282 | + | t.Fatal(err) | |||
| 283 | + | } | |||
| 284 | + | later := time.Now().Add(2 * time.Second) | |||
| 285 | + | _ = os.Chtimes(p, later, later) | |||
| 286 | + | if got := r.Addrs(); !reflect.DeepEqual(got, []string{"192.0.2.99:53", "192.0.2.1:53"}) { | |||
| 287 | + | t.Fatal(got) | |||
| 288 | + | } | |||
| 289 | + | // Unreadable file keeps the last known list. | |||
| 290 | + | _ = os.Remove(p) | |||
| 291 | + | if got := r.Addrs(); len(got) != 2 { | |||
| 292 | + | t.Fatalf("lost the last known list: %v", got) | |||
| 293 | + | } | |||
| 294 | + | if got := NewResolvers(filepath.Join(t.TempDir(), "missing")).Addrs(); len(got) != 0 { | |||
| 295 | + | t.Fatal(got) | |||
| 296 | + | } | |||
| 297 | + | } | |||
A internal/dnsfwd/resolvers.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package dnsfwd | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "net" | |
| 8 | + | "net/netip" | |
| 9 | + | "os" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Resolvers reads the upstream nameservers from a resolv.conf-format file and | |
| 16 | + | // re-reads it when it changes. Entries such as the systemd-resolved stub | |
| 17 | + | // (127.0.0.53) are kept on purpose: the forwarder runs on the host, where the | |
| 18 | + | // stub is reachable, and that stub is the only resolver that knows the host's | |
| 19 | + | // split-DNS configuration. | |
| 20 | + | type Resolvers struct { | |
| 21 | + | path string | |
| 22 | + | ||
| 23 | + | mu sync.Mutex | |
| 24 | + | mtime time.Time | |
| 25 | + | size int64 | |
| 26 | + | addrs []string | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewResolvers returns a Resolvers reading path (default /etc/resolv.conf). | |
| 30 | + | func NewResolvers(path string) *Resolvers { | |
| 31 | + | if path == "" { | |
| 32 | + | path = "/etc/resolv.conf" | |
| 33 | + | } | |
| 34 | + | return &Resolvers{path: path} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Addrs returns the upstream "host:53" addresses, in file order. When the file | |
| 38 | + | // cannot be read the last known list is returned. | |
| 39 | + | func (r *Resolvers) Addrs() []string { | |
| 40 | + | r.mu.Lock() | |
| 41 | + | defer r.mu.Unlock() | |
| 42 | + | st, err := os.Stat(r.path) | |
| 43 | + | if err != nil { | |
| 44 | + | return r.addrs | |
| 45 | + | } | |
| 46 | + | if st.ModTime().Equal(r.mtime) && st.Size() == r.size && r.addrs != nil { | |
| 47 | + | return r.addrs | |
| 48 | + | } | |
| 49 | + | f, err := os.Open(r.path) | |
| 50 | + | if err != nil { | |
| 51 | + | return r.addrs | |
| 52 | + | } | |
| 53 | + | defer func() { _ = f.Close() }() | |
| 54 | + | r.addrs = ParseResolvConf(f) | |
| 55 | + | r.mtime, r.size = st.ModTime(), st.Size() | |
| 56 | + | return r.addrs | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // ParseResolvConf extracts "nameserver" entries as "ip:53" strings. Comments, | |
| 60 | + | // unknown directives and invalid addresses are ignored; duplicates removed. | |
| 61 | + | // IPv6 addresses with a zone (fe80::1%eth0) are kept with their zone. | |
| 62 | + | func ParseResolvConf(f interface{ Read([]byte) (int, error) }) []string { | |
| 63 | + | var out []string | |
| 64 | + | seen := map[string]bool{} | |
| 65 | + | sc := bufio.NewScanner(f) | |
| 66 | + | for sc.Scan() { | |
| 67 | + | line := strings.TrimSpace(sc.Text()) | |
| 68 | + | if i := strings.IndexAny(line, "#;"); i >= 0 { | |
| 69 | + | line = strings.TrimSpace(line[:i]) | |
| 70 | + | } | |
| 71 | + | fields := strings.Fields(line) | |
| 72 | + | if len(fields) < 2 || fields[0] != "nameserver" { | |
| 73 | + | continue | |
| 74 | + | } | |
| 75 | + | a, err := netip.ParseAddr(fields[1]) | |
| 76 | + | if err != nil || a.IsUnspecified() { | |
| 77 | + | continue | |
| 78 | + | } | |
| 79 | + | s := net.JoinHostPort(a.String(), "53") | |
| 80 | + | if !seen[s] { | |
| 81 | + | seen[s] = true | |
| 82 | + | out = append(out, s) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | return out | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package dnsfwd | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bufio" | |||
| 7 | + | "net" | |||
| 8 | + | "net/netip" | |||
| 9 | + | "os" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "time" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Resolvers reads the upstream nameservers from a resolv.conf-format file and | |||
| 16 | + | // re-reads it when it changes. Entries such as the systemd-resolved stub | |||
| 17 | + | // (127.0.0.53) are kept on purpose: the forwarder runs on the host, where the | |||
| 18 | + | // stub is reachable, and that stub is the only resolver that knows the host's | |||
| 19 | + | // split-DNS configuration. | |||
| 20 | + | type Resolvers struct { | |||
| 21 | + | path string | |||
| 22 | + | ||||
| 23 | + | mu sync.Mutex | |||
| 24 | + | mtime time.Time | |||
| 25 | + | size int64 | |||
| 26 | + | addrs []string | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // NewResolvers returns a Resolvers reading path (default /etc/resolv.conf). | |||
| 30 | + | func NewResolvers(path string) *Resolvers { | |||
| 31 | + | if path == "" { | |||
| 32 | + | path = "/etc/resolv.conf" | |||
| 33 | + | } | |||
| 34 | + | return &Resolvers{path: path} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Addrs returns the upstream "host:53" addresses, in file order. When the file | |||
| 38 | + | // cannot be read the last known list is returned. | |||
| 39 | + | func (r *Resolvers) Addrs() []string { | |||
| 40 | + | r.mu.Lock() | |||
| 41 | + | defer r.mu.Unlock() | |||
| 42 | + | st, err := os.Stat(r.path) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return r.addrs | |||
| 45 | + | } | |||
| 46 | + | if st.ModTime().Equal(r.mtime) && st.Size() == r.size && r.addrs != nil { | |||
| 47 | + | return r.addrs | |||
| 48 | + | } | |||
| 49 | + | f, err := os.Open(r.path) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return r.addrs | |||
| 52 | + | } | |||
| 53 | + | defer func() { _ = f.Close() }() | |||
| 54 | + | r.addrs = ParseResolvConf(f) | |||
| 55 | + | r.mtime, r.size = st.ModTime(), st.Size() | |||
| 56 | + | return r.addrs | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // ParseResolvConf extracts "nameserver" entries as "ip:53" strings. Comments, | |||
| 60 | + | // unknown directives and invalid addresses are ignored; duplicates removed. | |||
| 61 | + | // IPv6 addresses with a zone (fe80::1%eth0) are kept with their zone. | |||
| 62 | + | func ParseResolvConf(f interface{ Read([]byte) (int, error) }) []string { | |||
| 63 | + | var out []string | |||
| 64 | + | seen := map[string]bool{} | |||
| 65 | + | sc := bufio.NewScanner(f) | |||
| 66 | + | for sc.Scan() { | |||
| 67 | + | line := strings.TrimSpace(sc.Text()) | |||
| 68 | + | if i := strings.IndexAny(line, "#;"); i >= 0 { | |||
| 69 | + | line = strings.TrimSpace(line[:i]) | |||
| 70 | + | } | |||
| 71 | + | fields := strings.Fields(line) | |||
| 72 | + | if len(fields) < 2 || fields[0] != "nameserver" { | |||
| 73 | + | continue | |||
| 74 | + | } | |||
| 75 | + | a, err := netip.ParseAddr(fields[1]) | |||
| 76 | + | if err != nil || a.IsUnspecified() { | |||
| 77 | + | continue | |||
| 78 | + | } | |||
| 79 | + | s := net.JoinHostPort(a.String(), "53") | |||
| 80 | + | if !seen[s] { | |||
| 81 | + | seen[s] = true | |||
| 82 | + | out = append(out, s) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | return out | |||
| 86 | + | } | |||