detached 4f2d9a2

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close