test / test (push) Canceled after 0s
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>
135 lines
3.5 KiB
Go
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...)
|
|
}
|