Files
selfpost/internal/web/handlers/fakedns_test.go
T
mixandClaude Opus 5.5 b905f3e560
test / test (push) Canceled after 0s
panel-notifications: Account, Server > Settings, Server > Health, help; e2e
The pages of the mockups accepted in 87fa0a4, built from the kit's partials
(written by a Sonnet agent into the working tree, reviewed here):

- Account: "Send me notifications" in the Profile form, saved with the
  profile; an unticked box is off.
- Server > Settings: the instance switch (box 03), and box 04 Notification
  mail with the DKIM and SPF records of the server's own name, drawn with the
  record partial of a domain's DNS page and checked in DNS. Sending rate
  limits are box 02 in the left column. A record that is not in place is
  tagged as a warning, not a failure: notifications are sent without it.
- Server > Health: card 06 Notification mail with the state of each record
  and a link to Settings, beside Configuration (07); part of the polled
  fragment; Re-check DNS forces it too. Overview does not run this check.
- help.go: the Notifications topic and the clauses in Settings and Your
  account, word for word with help.html.

If the server's key cannot be read the pages still render: the DKIM record
is left out and SPF is shown unchecked.

e2e: the administrator gets a mailbox behind the sink and the server's DKIM
record from Settings is published; the level-2 refusal of the next step must
then arrive as a notification from postmaster@mail.e2e.test, signed as that
name with the key Settings shows, and must not be a row of the Outbound log.
The rotation step now waits for Postfix to be running: it follows a step that
restarts the container, and logrotate's `postfix reload` failed in between.

On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (147 s); the notification reached the sink 28 s after the
refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:21:48 +03:00

135 lines
3.5 KiB
Go

package handlers
import (
"encoding/binary"
"net"
"strings"
"sync"
"testing"
)
// fakeZone is a DNS server on the loopback that answers from a table, so that a
// page can be rendered against records that are published, wrong or absent
// without leaving the machine. Names are lower case without the trailing dot;
// a name the zone does not hold is NXDOMAIN, which is how a record that is not
// published looks to the checker.
type fakeZone struct {
mu sync.Mutex
txt map[string][]string
addr map[string]string // name -> IPv4 address
}
// newFakeZone starts the server for the length of the test and returns the
// resolver address to give dnscheck.New.
func newFakeZone(t *testing.T) (*fakeZone, string) {
t.Helper()
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("fake DNS: listen: %v", err)
}
z := &fakeZone{txt: map[string][]string{}, addr: map[string]string{}}
t.Cleanup(func() { _ = conn.Close() })
go func() {
buf := make([]byte, 1500)
for {
n, from, err := conn.ReadFrom(buf)
if err != nil {
return
}
if resp := z.answer(buf[:n]); resp != nil {
_, _ = conn.WriteTo(resp, from)
}
}
}()
return z, conn.LocalAddr().String()
}
// publishTXT sets the TXT record of a name; publishA its IPv4 address.
func (z *fakeZone) publishTXT(name, value string) {
z.mu.Lock()
defer z.mu.Unlock()
z.txt[strings.ToLower(name)] = append(z.txt[strings.ToLower(name)], value)
}
func (z *fakeZone) publishA(name, ip string) {
z.mu.Lock()
defer z.mu.Unlock()
z.addr[strings.ToLower(name)] = ip
}
// answer builds the reply to one query: the question is copied back, then the
// records of the asked type if the zone has any. A name with nothing of the
// asked type but known under another type is an empty answer (NOERROR), an
// unknown name is NXDOMAIN.
func (z *fakeZone) answer(q []byte) []byte {
if len(q) < 12 || binary.BigEndian.Uint16(q[4:6]) != 1 {
return nil
}
i, labels := 12, []string{}
for {
if i >= len(q) {
return nil
}
l := int(q[i])
if l == 0 {
i++
break
}
if i+1+l > len(q) {
return nil
}
labels = append(labels, string(q[i+1:i+1+l]))
i += 1 + l
}
if i+4 > len(q) {
return nil
}
name, qtype := strings.ToLower(strings.Join(labels, ".")), binary.BigEndian.Uint16(q[i:i+2])
question := q[12 : i+4]
z.mu.Lock()
txt, ip := z.txt[name], z.addr[name]
z.mu.Unlock()
var answers [][]byte
switch {
case qtype == 16:
for _, v := range txt {
var rdata []byte
for len(v) > 255 {
rdata = append(rdata, 255)
rdata = append(rdata, v[:255]...)
v = v[255:]
}
rdata = append(rdata, byte(len(v)))
rdata = append(rdata, v...)
answers = append(answers, record(16, rdata))
}
case qtype == 1 && ip != "":
answers = append(answers, record(1, net.ParseIP(ip).To4()))
}
rcode := uint16(0)
if len(txt) == 0 && ip == "" {
rcode = 3
}
resp := make([]byte, 12, 512)
copy(resp, q[:2])
binary.BigEndian.PutUint16(resp[2:], 0x8180|rcode)
binary.BigEndian.PutUint16(resp[4:], 1)
binary.BigEndian.PutUint16(resp[6:], uint16(len(answers)))
resp = append(resp, question...)
for _, a := range answers {
resp = append(resp, a...)
}
return resp
}
// record is one answer record named by the question (a pointer to offset 12),
// class IN, with a TTL of a minute.
func record(rtype uint16, rdata []byte) []byte {
r := []byte{0xC0, 0x0C, 0, 0, 0, 1, 0, 0, 0, 60, 0, 0}
binary.BigEndian.PutUint16(r[2:], rtype)
binary.BigEndian.PutUint16(r[10:], uint16(len(rdata)))
return append(r, rdata...)
}