PrintToVeraeTimeStamp/internal/netprint/mdns_packet_test.go
Cursor Agent 17bb067010
Keep one IPP advertisement and accept link-local IPv6.
DnsServiceRegister stays the only owner of the _ipp._tcp instance. The shared UDP responder answers subtype PTRs only, so mDNS no longer renames a second copy to Name(1). The IPP port listens on IPv4 and on [::] with IPV6_V6ONLY, and the firewall rules allow fe80::/10, so a Mac connect to the host's link-local address completes.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 00:34:34 +00:00

507 lines
12 KiB
Go

package netprint
import (
"encoding/binary"
"fmt"
"net"
"os"
"strings"
"sync"
"testing"
"time"
)
type fakePkt struct {
mu sync.Mutex
writes [][]byte
once sync.Once
closed chan struct{}
closeMu sync.Once
}
func (f *fakePkt) ensure() {
f.once.Do(func() { f.closed = make(chan struct{}) })
}
func (f *fakePkt) ReadFromUDP([]byte) (int, *net.UDPAddr, error) {
f.ensure()
<-f.closed
return 0, nil, net.ErrClosed
}
func (f *fakePkt) WriteToUDP(b []byte, _ *net.UDPAddr) (int, error) {
f.ensure()
f.mu.Lock()
f.writes = append(f.writes, append([]byte(nil), b...))
f.mu.Unlock()
return len(b), nil
}
func (f *fakePkt) SetReadDeadline(time.Time) error { return nil }
func (f *fakePkt) Close() error {
f.ensure()
f.closeMu.Do(func() { close(f.closed) })
return nil
}
func (f *fakePkt) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.writes)
}
func (f *fakePkt) packet(i int) []byte {
f.mu.Lock()
defer f.mu.Unlock()
return f.writes[i]
}
type rrView struct {
name string
typ uint16
ttl uint32
ptr string
port int
target string
txt []string
ip net.IP
}
func parsePacket(msg []byte) (ans, extra []rrView, compressed bool, err error) {
if len(msg) < 12 {
return nil, nil, false, fmt.Errorf("short")
}
qd := int(binary.BigEndian.Uint16(msg[4:6]))
an := int(binary.BigEndian.Uint16(msg[6:8]))
ns := int(binary.BigEndian.Uint16(msg[8:10]))
ar := int(binary.BigEndian.Uint16(msg[10:12]))
off := 12
for i := 0; i < qd; i++ {
_, off, err = skipQuestion(msg, off)
if err != nil {
return nil, nil, false, err
}
}
read := func(n int) ([]rrView, error) {
var out []rrView
for i := 0; i < n; i++ {
var rr rrView
var jumped bool
rr, off, jumped, err = parseRR(msg, off)
if err != nil {
return nil, err
}
if jumped {
compressed = true
}
out = append(out, rr)
}
return out, nil
}
if ans, err = read(an); err != nil {
return nil, nil, false, err
}
if _, err = read(ns); err != nil {
return nil, nil, false, err
}
if extra, err = read(ar); err != nil {
return nil, nil, false, err
}
return ans, extra, compressed, nil
}
func skipQuestion(msg []byte, off int) (int, int, error) {
_, next, err := parseName(msg, off)
if err != nil {
return 0, 0, err
}
if next+4 > len(msg) {
return 0, 0, fmt.Errorf("truncated question")
}
return 0, next + 4, nil
}
func parseRR(msg []byte, off int) (rrView, int, bool, error) {
labels, next, err := parseName(msg, off)
jumped := nameUsesPointer(msg, off)
if err != nil {
return rrView{}, 0, false, err
}
off = next
if off+10 > len(msg) {
return rrView{}, 0, false, fmt.Errorf("truncated rr")
}
rr := rrView{
name: strings.ToLower(strings.Join(labels, ".")),
typ: binary.BigEndian.Uint16(msg[off : off+2]),
ttl: binary.BigEndian.Uint32(msg[off+4 : off+8]),
}
rdlen := int(binary.BigEndian.Uint16(msg[off+8 : off+10]))
off += 10
if off+rdlen > len(msg) {
return rrView{}, 0, false, fmt.Errorf("truncated rdata")
}
rdata := msg[off : off+rdlen]
off += rdlen
switch rr.typ {
case dnsTypePTR:
pl, _, err := parseName(msg, off-rdlen)
if err != nil {
return rrView{}, 0, false, err
}
if nameUsesPointer(msg, off-rdlen) {
jumped = true
}
rr.ptr = strings.ToLower(strings.Join(pl, "."))
case dnsTypeSRV:
if len(rdata) < 6 {
return rrView{}, 0, false, fmt.Errorf("short srv")
}
rr.port = int(binary.BigEndian.Uint16(rdata[4:6]))
tl, _, err := parseName(msg, off-rdlen+6)
if err != nil {
return rrView{}, 0, false, err
}
if nameUsesPointer(msg, off-rdlen+6) {
jumped = true
}
rr.target = strings.ToLower(strings.Join(tl, "."))
case dnsTypeTXT:
for i := 0; i < len(rdata); {
n := int(rdata[i])
i++
if i+n > len(rdata) {
return rrView{}, 0, false, fmt.Errorf("bad txt")
}
rr.txt = append(rr.txt, string(rdata[i:i+n]))
i += n
}
case dnsTypeA:
if len(rdata) == 4 {
rr.ip = net.IP(rdata)
}
case dnsTypeAAAA:
if len(rdata) == 16 {
rr.ip = net.IP(rdata)
}
}
return rr, off, jumped, nil
}
func nameUsesPointer(msg []byte, off int) bool {
for hops := 0; hops < 16 && off < len(msg); hops++ {
l := int(msg[off])
if l == 0 {
return false
}
if l&0xC0 == 0xC0 {
return true
}
if l&0xC0 != 0 || off+1+l > len(msg) {
return false
}
off += 1 + l
}
return false
}
func assertPrimaryPTRs(msg []byte, instance string) error {
ans, extra, compressed, err := parsePacket(msg)
if err != nil {
return err
}
want := strings.ToLower(instance) + "._ipp._tcp.local"
need := map[string]bool{
"_ipp._tcp.local": false,
"_universal._sub._ipp._tcp.local": false,
"_print._sub._ipp._tcp.local": false,
}
var sawSRV, sawTXT, sawA bool
for _, rr := range append(ans, extra...) {
if rr.typ == dnsTypePTR {
if _, ok := need[rr.name]; ok {
if rr.ptr != want {
return fmt.Errorf("PTR %s target %q, want %q", rr.name, rr.ptr, want)
}
need[rr.name] = true
}
}
if rr.typ == dnsTypeSRV && rr.port == 631 && rr.target == "host.local" && rr.name == want {
sawSRV = true
}
if rr.typ == dnsTypeTXT && rr.name == want {
joined := strings.Join(rr.txt, "\n")
if strings.Contains(joined, "rp=ipp/print") {
sawTXT = true
}
}
if rr.typ == dnsTypeA && rr.ip.Equal(net.ParseIP("192.168.1.184")) && rr.name == "host.local" {
sawA = true
}
}
for name, ok := range need {
if !ok {
return fmt.Errorf("missing PTR %s", name)
}
}
if !sawSRV || !sawTXT || !sawA {
return fmt.Errorf("srv %v txt %v a %v", sawSRV, sawTXT, sawA)
}
if !compressed {
return fmt.Errorf("announcement did not compress a repeated name")
}
return nil
}
func assertSubtypePTRsOnly(msg []byte, instance string) error {
ans, extra, _, err := parsePacket(msg)
if err != nil {
return err
}
want := strings.ToLower(instance) + "._ipp._tcp.local"
need := map[string]bool{
"_universal._sub._ipp._tcp.local": false,
"_print._sub._ipp._tcp.local": false,
}
for _, rr := range append(ans, extra...) {
switch rr.typ {
case dnsTypePTR:
if rr.name == "_ipp._tcp.local" {
return fmt.Errorf("base _ipp._tcp PTR was advertised")
}
if _, ok := need[rr.name]; ok {
if rr.ptr != want {
return fmt.Errorf("PTR %s target %q, want %q", rr.name, rr.ptr, want)
}
need[rr.name] = true
}
case dnsTypeSRV, dnsTypeTXT, dnsTypeA, dnsTypeAAAA:
return fmt.Errorf("unique record type %d name %s", rr.typ, rr.name)
}
}
for name, ok := range need {
if !ok {
return fmt.Errorf("missing PTR %s", name)
}
}
return nil
}
func TestSubtypeOnlyResponderSkipsPrimaryRecords(t *testing.T) {
svc := sampleService()
svc.SubtypesOnly = true
if err := assertSubtypePTRsOnly(svc.Announce(false), "Verae Timestamp"); err != nil {
t.Fatal(err)
}
bye := svc.Announce(true)
if err := assertSubtypePTRsOnly(bye, "Verae Timestamp"); err != nil {
t.Fatal(err)
}
ans, _, _, err := parsePacket(bye)
if err != nil {
t.Fatal(err)
}
var ptrs int
for _, rr := range ans {
if rr.typ == dnsTypePTR {
ptrs++
if rr.ttl != 0 {
t.Fatalf("goodbye ttl %d", rr.ttl)
}
}
}
if ptrs != 2 {
t.Fatalf("goodbye ptrs %d", ptrs)
}
if svc.Reply(Query(1, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) != nil {
t.Fatal("answered base _ipp._tcp PTR")
}
if svc.Reply(Query(1, svc.instanceLabels(), dnsTypeSRV)) != nil {
t.Fatal("answered SRV")
}
if svc.Reply(Query(1, svc.instanceLabels(), dnsTypeTXT)) != nil {
t.Fatal("answered TXT")
}
if svc.Reply(Query(1, svc.hostLabels(), dnsTypeA)) != nil {
t.Fatal("answered A")
}
if svc.Reply(Query(1, svc.hostLabels(), dnsTypeAAAA)) != nil {
t.Fatal("answered AAAA")
}
resp := svc.Reply(Query(1, []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, dnsTypePTR))
ans, extra, _, err := parsePacket(resp)
if err != nil {
t.Fatal(err)
}
var sawUniv bool
for _, rr := range append(ans, extra...) {
switch rr.typ {
case dnsTypePTR:
if rr.name == "_universal._sub._ipp._tcp.local" && rr.ptr == "verae timestamp._ipp._tcp.local" {
sawUniv = true
}
if rr.name == "_ipp._tcp.local" {
t.Fatal("subtype reply included the base PTR")
}
case dnsTypeSRV, dnsTypeTXT, dnsTypeA, dnsTypeAAAA:
t.Fatalf("subtype reply included type %d", rr.typ)
}
}
if !sawUniv {
t.Fatal("subtype reply missing the _universal PTR")
}
full := sampleService()
if full.Reply(Query(1, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) == nil {
t.Fatal("full responder dropped the base PTR")
}
if err := assertPrimaryPTRs(full.Announce(false), "Verae Timestamp"); err != nil {
t.Fatal(err)
}
}
func TestLANIPsSkipLinkLocal(t *testing.T) {
if keepLAN(net.ParseIP("fe80::d5bb:1")) || keepLAN(net.ParseIP("169.254.1.1")) {
t.Fatal("link-local address was kept")
}
if keepLAN(net.ParseIP("127.0.0.1")) || keepLAN(net.ParseIP("::1")) || keepLAN(nil) {
t.Fatal("loopback or nil was kept")
}
if !keepLAN(net.ParseIP("192.168.1.184")) || !keepLAN(net.ParseIP("2001:db8::184")) {
t.Fatal("global address was dropped")
}
for _, ip := range lanIPs() {
if ip.IsLinkLocalUnicast() {
t.Fatalf("lanIPs returned %s", ip)
}
}
}
func TestMDNSPacketLayout(t *testing.T) {
svc := sampleService()
pkt := svc.Announce(false)
if err := assertPrimaryPTRs(pkt, "Verae Timestamp"); err != nil {
t.Fatal(err)
}
ans, _, _, err := parsePacket(pkt)
if err != nil {
t.Fatal(err)
}
for _, rr := range ans {
if rr.typ == dnsTypePTR && rr.ttl != mdnsTTL {
t.Fatalf("PTR ttl %d", rr.ttl)
}
}
bye := svc.Announce(true)
ans, _, _, err = parsePacket(bye)
if err != nil {
t.Fatal(err)
}
var ptrs int
for _, rr := range ans {
if rr.typ == dnsTypePTR {
ptrs++
if rr.ttl != 0 {
t.Fatalf("goodbye ttl %d", rr.ttl)
}
if rr.ptr != "verae timestamp._ipp._tcp.local" {
t.Fatalf("goodbye target %s", rr.ptr)
}
}
}
if ptrs != 3 {
t.Fatalf("goodbye ptrs %d", ptrs)
}
q := QueryUnicast(9, []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, dnsTypePTR)
if !queryUnicast(q) {
t.Fatal("QU bit not set")
}
if queryUnicast(Query(9, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) {
t.Fatal("plain query reported QU")
}
resp := svc.Reply(q)
if resp == nil {
t.Fatal("no QU reply")
}
ans, extra, _, err := parsePacket(resp)
if err != nil {
t.Fatal(err)
}
found := false
for _, rr := range append(ans, extra...) {
if rr.typ == dnsTypePTR && rr.name == "_universal._sub._ipp._tcp.local" {
found = rr.ptr == "verae timestamp._ipp._tcp.local"
}
}
if !found {
t.Fatal("QU subtype PTR does not point at the primary instance")
}
plain := svc.Reply(Query(3, []string{"_ipp", "_tcp", "local"}, dnsTypePTR))
if plain == nil {
t.Fatal("plain browse reply missing")
}
if err := func() error {
a, e, _, err := parsePacket(plain)
if err != nil {
return err
}
for _, rr := range append(a, e...) {
if rr.typ == dnsTypePTR && rr.name == "_ipp._tcp.local" && rr.ptr == "verae timestamp._ipp._tcp.local" {
return nil
}
}
return fmt.Errorf("plain PTR target")
}(); err != nil {
t.Fatal(err)
}
}
func TestIPv6MulticastRequest(t *testing.T) {
b := ipv6GroupReq(7)
if got := net.IP(b[:16]).String(); got != "ff02::fb" {
t.Fatalf("group %s", got)
}
if binary.LittleEndian.Uint32(b[16:]) != 7 {
t.Fatalf("ifindex %d", binary.LittleEndian.Uint32(b[16:]))
}
}
func TestWindowsListenJoinsBothGroups(t *testing.T) {
src, err := readSource("advertise_windows.go")
if err != nil {
t.Fatal(err)
}
for _, needle := range []string{"SO_REUSEADDR", "224.0.0.251", "ff02::fb", "SO_EXCLUSIVEADDRUSE", "ipv6GroupReq"} {
if !strings.Contains(src, needle) {
t.Fatalf("advertise_windows.go missing %s", needle)
}
}
}
func TestTraySingleInstanceAndLog(t *testing.T) {
if trayLoopExit(0) != "WM_QUIT" || trayLoopExit(-1) != "GetMessage error" {
t.Fatal(trayLoopExit(0))
}
src, err := readSource("tray_windows.go")
if err != nil {
t.Fatal(err)
}
for _, needle := range []string{"CreateMutexW", `Local\VeraeNetPrintTray`, "setupServiceLog", "tray message loop ended", "WM_CLOSE", "tray already running"} {
if !strings.Contains(src, needle) {
t.Fatalf("tray_windows.go missing %s", needle)
}
}
launch, err := readSource("launch_tray_windows.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(launch, "explorer.exe") {
t.Fatal("tray launch must go through Explorer so Task Scheduler does not own the process")
}
}
func readSource(name string) (string, error) {
raw, err := os.ReadFile(name)
return string(raw), err
}