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>
459 lines
13 KiB
Go
459 lines
13 KiB
Go
package netprint
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
|
)
|
|
|
|
// fakeDNS is a DnsServiceRegister stand-in. CI runs on Linux, so the Windows
|
|
// strategy is invoked through advertiseForOS("windows", ..., fakeDNS).
|
|
type fakeDNS struct {
|
|
mu sync.Mutex
|
|
registered []dnsSDInstance
|
|
deregistered []dnsSDInstance
|
|
err error
|
|
failSubtypes bool
|
|
}
|
|
|
|
func (f *fakeDNS) Register(inst dnsSDInstance) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.failSubtypes && len(f.registered) > 0 {
|
|
return f.err
|
|
}
|
|
if f.err != nil && !f.failSubtypes {
|
|
return f.err
|
|
}
|
|
f.registered = append(f.registered, inst)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDNS) Deregister(inst dnsSDInstance) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.deregistered = append(f.deregistered, inst)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDNS) names() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]string, len(f.registered))
|
|
for i, inst := range f.registered {
|
|
out[i] = inst.InstanceName
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (f *fakeDNS) count() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.registered)
|
|
}
|
|
|
|
func sampleService() Service {
|
|
return Service{
|
|
Instance: "Verae Timestamp",
|
|
Host: "host",
|
|
Port: 631,
|
|
IPs: []net.IP{net.ParseIP("192.168.1.184")},
|
|
TXT: []string{"ty=Verae Timestamp", "rp=ipp/print"},
|
|
}
|
|
}
|
|
|
|
func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) {
|
|
pkt := &fakePkt{}
|
|
prev := listenMDNSFn
|
|
listenMDNSFn = func() (mdnsConn, error) {
|
|
return pkt, nil
|
|
}
|
|
t.Cleanup(func() { listenMDNSFn = prev })
|
|
|
|
api := &fakeDNS{}
|
|
var mu sync.Mutex
|
|
var got mdnsReport
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, func(r mdnsReport) {
|
|
mu.Lock()
|
|
got = r
|
|
mu.Unlock()
|
|
})
|
|
}()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
mu.Lock()
|
|
detail := got.Detail
|
|
mu.Unlock()
|
|
if api.count() >= 1 && pkt.count() >= 1 && strings.Contains(detail, "_universal._sub") {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if api.count() != 1 || pkt.count() < 1 {
|
|
t.Fatalf("registered %d announcements %d", api.count(), pkt.count())
|
|
}
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("advertiseForOS did not return after cancel")
|
|
}
|
|
mu.Lock()
|
|
report := got
|
|
mu.Unlock()
|
|
if report.Mode != "dns-sd" || !report.Advertising || report.Err != "" {
|
|
t.Fatalf("status report %+v", report)
|
|
}
|
|
if !strings.Contains(report.Detail, "DnsServiceRegister") || !strings.Contains(report.Detail, "631") || !strings.Contains(report.Detail, "_universal._sub") {
|
|
t.Fatalf("detail %q", report.Detail)
|
|
}
|
|
if strings.Contains(report.Detail, "PTR _ipp._tcp") {
|
|
t.Fatalf("UDP responder still claims the base PTR: %q", report.Detail)
|
|
}
|
|
gotNames := api.names()
|
|
if len(gotNames) != 1 || gotNames[0] != "Verae Timestamp._ipp._tcp.local" {
|
|
t.Fatalf("registered %v", gotNames)
|
|
}
|
|
for _, name := range gotNames {
|
|
if strings.Contains(name, "_sub") || strings.Contains(name, ",") {
|
|
t.Fatalf("subtype service was registered: %s", name)
|
|
}
|
|
}
|
|
api.mu.Lock()
|
|
defer api.mu.Unlock()
|
|
if api.registered[0].Port != 631 || api.registered[0].Host != "host.local" {
|
|
t.Fatalf("instance %+v", api.registered)
|
|
}
|
|
if len(api.deregistered) != 1 {
|
|
t.Fatalf("deregistered %d", len(api.deregistered))
|
|
}
|
|
ann := pkt.packet(0)
|
|
if err := assertSubtypePTRsOnly(ann, "Verae Timestamp"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestWindowsDNSFailureAdvertisesFullService(t *testing.T) {
|
|
pkt := &fakePkt{}
|
|
prev := listenMDNSFn
|
|
listenMDNSFn = func() (mdnsConn, error) { return pkt, nil }
|
|
t.Cleanup(func() { listenMDNSFn = prev })
|
|
api := &fakeDNS{err: errors.New("dnsapi unavailable")}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, nil)
|
|
}()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) && pkt.count() < 1 {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if pkt.count() < 1 {
|
|
t.Fatal("no announcement after DnsServiceRegister failed")
|
|
}
|
|
if err := assertPrimaryPTRs(pkt.packet(0), "Verae Timestamp"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("advertiseForOS did not return")
|
|
}
|
|
}
|
|
|
|
func TestWindowsDNSFailureFallsBackToSharedUDP(t *testing.T) {
|
|
prev := listenMDNSFn
|
|
listenMDNSFn = func() (mdnsConn, error) {
|
|
return nil, errors.New("Only one usage of each socket address (protocol/network address/port) is normally permitted")
|
|
}
|
|
t.Cleanup(func() { listenMDNSFn = prev })
|
|
api := &fakeDNS{err: errors.New("dnsapi unavailable")}
|
|
err := advertiseForOS("windows", context.Background(), func() Service { return sampleService() }, api, nil)
|
|
if err == nil {
|
|
t.Fatal("expected a combined error")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "DnsServiceRegister") || !strings.Contains(msg, "5353") {
|
|
t.Fatalf("error %q", msg)
|
|
}
|
|
if api.count() != 0 {
|
|
t.Fatal("primary registration should not be recorded after failure")
|
|
}
|
|
}
|
|
|
|
func TestWindowsSubtypeFailureStillAdvertises(t *testing.T) {
|
|
// Subtype names are not registered. The shared socket carries them.
|
|
// A second DnsServiceRegister must not be attempted.
|
|
prev := listenMDNSFn
|
|
listenMDNSFn = func() (mdnsConn, error) {
|
|
return &fakePkt{}, nil
|
|
}
|
|
t.Cleanup(func() { listenMDNSFn = prev })
|
|
api := &fakeDNS{err: errors.New("subtype rejected"), failSubtypes: true}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, nil)
|
|
}()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) && api.count() < 1 {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if api.count() != 1 {
|
|
t.Fatalf("primary count %d names %v", api.count(), api.names())
|
|
}
|
|
cancel()
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestWindowsAdvertiseEntrypoint(t *testing.T) {
|
|
b, err := os.ReadFile("advertise_windows.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := string(b)
|
|
if !strings.Contains(src, `advertiseForOS("windows"`) || !strings.Contains(src, "windowsDNSService{}") {
|
|
t.Fatal("Windows advertisePlatform must call DnsServiceRegister via advertiseForOS")
|
|
}
|
|
}
|
|
|
|
func TestStatusShowsWindowsDNSDSuccess(t *testing.T) {
|
|
api := &fakeDNS{}
|
|
restore := swapAdvertiser(t, func(ctx context.Context, current func() Service, sink mdnsSink) error {
|
|
return advertiseForOS("windows", ctx, current, api, sink)
|
|
})
|
|
defer restore()
|
|
prevListen := listenMDNSFn
|
|
listenMDNSFn = func() (mdnsConn, error) {
|
|
return &fakePkt{}, nil
|
|
}
|
|
defer func() { listenMDNSFn = prevListen }()
|
|
|
|
cfg, path := testConfig(t)
|
|
on := true
|
|
cfg.Advertise = &on
|
|
s, err := Start(cfg, path, stampMock(t), &fakeCtrl{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
_ = s.Shutdown(ctx)
|
|
})
|
|
st := waitMDNS(t, s, "dns-sd")
|
|
if !st.MDNSAdvertising || st.MDNSError != "" {
|
|
t.Fatalf("%+v", st)
|
|
}
|
|
if api.count() < 1 || !strings.Contains(api.names()[0], "._ipp._tcp.local") {
|
|
t.Fatalf("Windows path was not invoked: %v", api.names())
|
|
}
|
|
body := getURL(t, s.ControlURL()+"api/v1/status")
|
|
for _, needle := range []string{`"mdnsAdvertising": true`, `"mdnsMode": "dns-sd"`} {
|
|
if !strings.Contains(body, needle) {
|
|
t.Fatalf("status missing %s\n%s", needle, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStatusShowsMDNSFailure(t *testing.T) {
|
|
restore := swapAdvertiser(t, func(ctx context.Context, current func() Service, sink mdnsSink) error {
|
|
return advertiseForOS("windows", ctx, current, &fakeDNS{err: errors.New("dnsapi unavailable")}, sink)
|
|
})
|
|
defer restore()
|
|
prevListen := listenMDNSFn
|
|
listenMDNSFn = func() (mdnsConn, error) {
|
|
return nil, errors.New("Only one usage of each socket address")
|
|
}
|
|
defer func() { listenMDNSFn = prevListen }()
|
|
|
|
cfg, path := testConfig(t)
|
|
on := true
|
|
cfg.Advertise = &on
|
|
s, err := Start(cfg, path, stampMock(t), &fakeCtrl{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
_ = s.Shutdown(ctx)
|
|
})
|
|
st := waitMDNS(t, s, "error")
|
|
if st.MDNSAdvertising {
|
|
t.Fatal("advertising should be false")
|
|
}
|
|
if !strings.Contains(st.MDNSError, "DnsServiceRegister") || !strings.Contains(st.MDNSError, "5353") {
|
|
t.Fatalf("mdnsError %q", st.MDNSError)
|
|
}
|
|
body := getURL(t, s.ControlURL()+"api/v1/status")
|
|
if !strings.Contains(body, `"mdnsMode": "error"`) || !strings.Contains(body, "DnsServiceRegister") {
|
|
t.Fatalf("status API hid the mDNS error\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestServiceLogWritesStartupAndJobs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("VERAE_LOG_DIR", dir)
|
|
prevW := log.Writer()
|
|
prevFlags := log.Flags()
|
|
t.Cleanup(func() {
|
|
log.SetOutput(prevW)
|
|
log.SetFlags(prevFlags)
|
|
})
|
|
setupServiceLog()
|
|
log.Printf("netprint: starting")
|
|
st, err := OpenStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ProcessJob(st, Config{}, nil, 7, "application/pdf", "Hello", "ada", []byte("%PDF-1.4"), func(string, string, string) (job.Result, error) {
|
|
return job.Result{}, fmt.Errorf("boom")
|
|
}, nil)
|
|
b, err := os.ReadFile(filepath.Join(dir, "netprint.log"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := string(b)
|
|
for _, needle := range []string{"netprint: log file", "netprint: starting", "job 7 started", "job 7 failed"} {
|
|
if !strings.Contains(text, needle) {
|
|
t.Fatalf("log missing %q\n%s", needle, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrayDiscoveryAndPublicWarning(t *testing.T) {
|
|
st := Status{
|
|
Running: true, Name: "Verae Timestamp", Port: 631,
|
|
MDNSMode: "error", MDNSError: "DnsServiceRegister: dnsapi unavailable; shared UDP 5353: bind failed",
|
|
NetworkPublic: true, SupportsPublic: true,
|
|
}
|
|
var sawDiscovery, sawWarn, sawPublic bool
|
|
for _, e := range BuildMenu(st) {
|
|
if strings.HasPrefix(e.Label, "Discovery — not advertising:") {
|
|
sawDiscovery = true
|
|
if !strings.Contains(e.Label, "DnsServiceRegister") {
|
|
t.Fatal(e.Label)
|
|
}
|
|
}
|
|
if strings.Contains(e.Label, "This network is Public") {
|
|
sawWarn = true
|
|
}
|
|
if e.Kind == "public" {
|
|
sawPublic = true
|
|
if !e.Enabled || e.Checked {
|
|
t.Fatalf("public entry %+v", e)
|
|
}
|
|
}
|
|
}
|
|
if !sawDiscovery || !sawWarn || !sawPublic {
|
|
t.Fatalf("discovery %v warn %v public %v", sawDiscovery, sawWarn, sawPublic)
|
|
}
|
|
if firewallProfile(false) != "private,domain" || firewallProfile(true) != "private,domain,public" {
|
|
t.Fatalf("profiles %q %q", firewallProfile(false), firewallProfile(true))
|
|
}
|
|
}
|
|
|
|
func TestAllowPublicRoundTrip(t *testing.T) {
|
|
cfg, path := testConfig(t)
|
|
s, err := Start(cfg, path, stampMock(t), &fakeCtrl{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
_ = s.Shutdown(ctx)
|
|
})
|
|
res, err := httpPost(t, s.ControlURL()+"api/v1/firewall", `{"allowPublic":true}`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(res, `"allowPublic": true`) {
|
|
t.Fatal(res)
|
|
}
|
|
body := getURL(t, s.ControlURL()+"api/v1/status")
|
|
if !strings.Contains(body, `"allowPublic": true`) {
|
|
t.Fatal(body)
|
|
}
|
|
}
|
|
|
|
func swapAdvertiser(t *testing.T, fn func(context.Context, func() Service, mdnsSink) error) func() {
|
|
t.Helper()
|
|
prev := startAdvertiser
|
|
startAdvertiser = fn
|
|
return func() { startAdvertiser = prev }
|
|
}
|
|
|
|
func waitMDNS(t *testing.T, s *Server, mode string) Status {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
var st Status
|
|
for time.Now().Before(deadline) {
|
|
st = s.status()
|
|
if st.MDNSMode == mode {
|
|
return st
|
|
}
|
|
time.Sleep(15 * time.Millisecond)
|
|
}
|
|
t.Fatalf("mdns mode %q, want %q (%+v)", st.MDNSMode, mode, st)
|
|
return st
|
|
}
|
|
|
|
func getURL(t *testing.T, raw string) string {
|
|
t.Helper()
|
|
res, err := http.Get(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
b, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET %s: %d %s", raw, res.StatusCode, b)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func httpPost(t *testing.T, raw, body string) (string, error) {
|
|
t.Helper()
|
|
res, err := http.Post(raw, "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer res.Body.Close()
|
|
b, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if res.StatusCode >= 300 {
|
|
return "", fmt.Errorf("POST %s: %d %s", raw, res.StatusCode, b)
|
|
}
|
|
return string(b), nil
|
|
}
|