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>
507 lines
12 KiB
Go
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
|
|
}
|