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>
This commit is contained in:
Cursor Agent 2026-10-05 00:34:34 +00:00
commit 17bb067010
No known key found for this signature in database
14 changed files with 575 additions and 29 deletions

View file

@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin
1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login.
2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files.
3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later `DnsServiceRegister` publishes only `<name>._ipp._tcp.local`. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) and answers `_ipp._tcp`, `_universal._sub._ipp._tcp`, and `_print._sub._ipp._tcp` itself. Every one of those PTRs points at `<name>._ipp._tcp.local`, with SRV, TXT, and address records. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` both list the printer. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG.
3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later `DnsServiceRegister` publishes only `<name>._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `<name>._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. The listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, so a connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG.
4. Print a PDF. Open **Receipts** from the tray, or browse to the printer’s admin URL (`http://<lan-address>:<port>/`). Each job has a receipt PDF and a verify link on `https://explorer.verae.ai/time`.
The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, and whether discovery is advertising. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, open recent receipts, and open settings (printer name, forward-to printer, signing key). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer.
@ -31,7 +31,7 @@ Forward-to is optional: a local queue name, or an `ipp://` URI. A failed forward
## Firewall
The Windows installers add inbound rules named **Verae NetPrint IPP** (TCP 631), **Verae NetPrint IPP Fallback** (TCP 8632), and **Verae NetPrint mDNS** (UDP 5353) on the private and domain profiles. A checkbox, off by default, also allows Public networks. A silent install can pass `/PUBLIC=1`. The tray has the same switch. The rules are removed when the last NetPrint product is uninstalled. Port 8631 is not opened; that is the driver helper on loopback.
The Windows installers add inbound rules named **Verae NetPrint IPP** (TCP 631), **Verae NetPrint IPP Fallback** (TCP 8632), and **Verae NetPrint mDNS** (UDP 5353) on the private and domain profiles. Each rule sets `remoteip=any`, which includes IPv4, global IPv6, and link-local `fe80::/10`. A checkbox, off by default, also allows Public networks. A silent install can pass `/PUBLIC=1`. The tray has the same switch. The rules are removed when the last NetPrint product is uninstalled. Port 8631 is not opened; that is the driver helper on loopback.
An optional checkbox (silent `/QUEUE=1`) adds a local Windows printer named **Verae Timestamp** aimed at `http://127.0.0.1:<bound-port>/ipp/print`. It is removed when the last NetPrint product is uninstalled. The driver printer **Verae TimeStamp PDF** is a different queue.

View file

@ -34,7 +34,8 @@ func serviceToDNS(s Service) []dnsSDInstance {
// that long name, which `dns-sd -B _ipp._tcp,_universal` does not accept, and
// those extra services also stopped plain `_ipp._tcp` browse from listing the
// printer. Only the primary instance is registered here. Subtype PTRs are
// sent by the shared UDP responder and point at this same name.
// sent by the shared UDP responder, point at this same name, and do not
// include SRV, TXT, or address records.
return []dnsSDInstance{{
InstanceName: base + "._ipp._tcp.local",
Host: host,
@ -77,11 +78,16 @@ func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClie
// advertiseForOS is the discovery strategy for goos.
// Windows registers only the primary name with DnsServiceRegister (Dnscache
// already owns UDP 5353 and answers plain _ipp._tcp browse). Subtype PTRs
// cannot be expressed there, so a shared UDP 5353 socket always runs as well
// and answers _ipp._tcp plus _universal._sub and _print._sub, all pointing at
// the primary instance. The socket sets SO_REUSEADDR and joins the mDNS groups
// so it shares the port with the Windows stack instead of replacing it.
// already owns UDP 5353 and answers plain _ipp._tcp browse, including SRV,
// TXT, and the host addresses). Subtype PTRs cannot be expressed there, so a
// shared UDP 5353 socket runs as well, but only after registration is decided.
// While DnsServiceRegister holds the instance, that socket answers
// _universal._sub and _print._sub PTRs and never the base _ipp._tcp PTR, SRV,
// TXT, or A/AAAA. Publishing those unique records from both stacks makes mDNS
// rename one advertisement to "Name(1)". If DnsServiceRegister fails, the UDP
// socket advertises the full set so plain browse still works. The socket sets
// SO_REUSEADDR and joins the mDNS groups so it shares the port with the
// Windows stack instead of replacing it.
func advertiseForOS(goos string, ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error {
if goos != "windows" {
return advertiseUDP(ctx, current, sink)
@ -105,7 +111,7 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap
sink(mdnsReport{
Advertising: true,
Mode: "dns-sd",
Detail: st.dnsDetail + "; shared UDP 5353 PTR _ipp._tcp, _universal._sub, _print._sub",
Detail: st.dnsDetail + "; shared UDP 5353 subtype PTR _universal._sub and _print._sub",
LastSent: time.Now(),
})
case st.dnsOK:
@ -162,7 +168,24 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap
}
ready()
}()
udpErr := advertiseUDP(ctx, current, func(r mdnsReport) {
select {
case <-dnsReady:
case <-ctx.Done():
<-dnsDone
return nil
}
mu.Lock()
dnsOK := st.dnsOK
mu.Unlock()
// Snapshot dnsOK. Registration does not succeed later in this call, and
// flipping SubtypesOnly on after a full announcement would already have
// probed a second instance.
udpCurrent := func() Service {
s := current()
s.SubtypesOnly = dnsOK
return s
}
udpErr := advertiseUDP(ctx, udpCurrent, func(r mdnsReport) {
mu.Lock()
if r.Advertising {
st.udpOK = true
@ -181,10 +204,6 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap
st.udpErr = udpErr.Error()
mu.Unlock()
publish()
select {
case <-dnsReady:
case <-ctx.Done():
}
mu.Lock()
dnsOK := st.dnsOK
dnsErr := st.dnsErr

View file

@ -1,5 +1,11 @@
package netprint
// firewallRemoteIP is passed to netsh as remoteip=. The value any covers
// IPv4, global IPv6, and link-local fe80::/10. The Mac resolver tries the
// fe80 address of the SRV target before IPv4; a rule that omits that prefix
// never completes the TCP handshake.
const firewallRemoteIP = "any"
// firewallProfile is the netsh profile list. Public stays off unless the
// installer or the tray asked for it. Private and Domain stay allowed.
func firewallProfile(public bool) string {

179
internal/netprint/listen.go Normal file
View file

@ -0,0 +1,179 @@
package netprint
import (
"context"
"fmt"
"log"
"net"
"strings"
"sync"
"syscall"
)
// ippBindHosts splits cfg.Bind into an IPv4 host and an IPv6 host.
// An empty bind listens on 0.0.0.0 and on [::] as two sockets. A single
// dual-stack socket (IPV6_V6ONLY off) accepts global IPv6 and IPv4, and on
// Windows a connect to the host's fe80 address never completes. The IPv6
// socket is bound with IPV6_V6ONLY so it can take [::] beside the IPv4 socket
// and still accept link-local destinations.
func ippBindHosts(bind string) (v4, v6 string) {
bind = strings.Trim(strings.TrimSpace(bind), "[]")
switch bind {
case "", "0.0.0.0", "::":
return "0.0.0.0", "::"
}
ip := net.ParseIP(bind)
if ip == nil {
return bind, ""
}
if ip.To4() != nil {
return ip.String(), ""
}
return "", ip.String()
}
func listenPort(v4host, v6host string, port int) (net.Listener, error) {
var lns []net.Listener
if v4host != "" {
ln, err := net.Listen("tcp4", net.JoinHostPort(v4host, fmt.Sprint(port)))
if err != nil {
return nil, err
}
lns = append(lns, ln)
}
if v6host != "" {
ln, err := listenTCP6(net.JoinHostPort(v6host, fmt.Sprint(port)))
if err != nil {
if len(lns) == 0 {
return nil, err
}
// IPv4 is up. Keep the port that was requested instead of moving
// the printer, and say why link-local clients will time out.
log.Printf("netprint: IPv6 [%s]:%d (IPV6_V6ONLY): %v", v6host, port, err)
} else {
lns = append(lns, ln)
}
}
if len(lns) == 0 {
return nil, fmt.Errorf("no listen address")
}
if len(lns) == 1 {
return lns[0], nil
}
return newMultiListener(lns), nil
}
func listenTCP6(address string) (net.Listener, error) {
var lc net.ListenConfig
lc.Control = func(network, address string, c syscall.RawConn) error {
var sockErr error
if err := c.Control(func(fd uintptr) {
sockErr = setIPv6Only(fd)
}); err != nil {
return err
}
return sockErr
}
return lc.Listen(context.Background(), "tcp6", address)
}
func listenerAddrs(ln net.Listener) string {
m, ok := ln.(*multiListener)
if !ok {
return ln.Addr().String()
}
parts := make([]string, 0, len(m.lns))
for _, l := range m.lns {
parts = append(parts, l.Addr().String())
}
return strings.Join(parts, " and ")
}
type acceptOne struct {
c net.Conn
err error
}
// multiListener is several listeners served as one. http.Server.Shutdown
// closes it, which closes every socket.
type multiListener struct {
lns []net.Listener
ch chan acceptOne
done chan struct{}
start sync.Once
stop sync.Once
}
func newMultiListener(lns []net.Listener) *multiListener {
return &multiListener{
lns: lns,
ch: make(chan acceptOne),
done: make(chan struct{}),
}
}
func (m *multiListener) Addr() net.Addr { return m.lns[0].Addr() }
func (m *multiListener) Accept() (net.Conn, error) {
m.start.Do(func() {
for _, ln := range m.lns {
go m.loop(ln)
}
})
select {
case <-m.done:
return nil, net.ErrClosed
case r := <-m.ch:
return r.c, r.err
}
}
func (m *multiListener) loop(ln net.Listener) {
for {
c, err := ln.Accept()
select {
case <-m.done:
if c != nil {
_ = c.Close()
}
return
case m.ch <- acceptOne{c: c, err: err}:
}
if err != nil {
return
}
}
}
func (m *multiListener) Close() error {
var err error
m.stop.Do(func() {
close(m.done)
for _, ln := range m.lns {
if e := ln.Close(); err == nil {
err = e
}
}
})
return err
}
func socketIPv6Only(ln net.Listener) (int, error) {
tcp, ok := ln.(*net.TCPListener)
if !ok {
return 0, fmt.Errorf("%T", ln)
}
raw, err := tcp.SyscallConn()
if err != nil {
return 0, err
}
var v int
var sockErr error
err = raw.Control(func(fd uintptr) {
v, sockErr = getIPv6Only(fd)
})
if err != nil {
return 0, err
}
return v, sockErr
}

View file

@ -0,0 +1,13 @@
//go:build !windows
package netprint
import "syscall"
func setIPv6Only(fd uintptr) error {
return syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY, 1)
}
func getIPv6Only(fd uintptr) (int, error) {
return syscall.GetsockoptInt(int(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY)
}

View file

@ -0,0 +1,13 @@
//go:build windows
package netprint
import "syscall"
func setIPv6Only(fd uintptr) error {
return syscall.SetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY, 1)
}
func getIPv6Only(fd uintptr) (int, error) {
return syscall.GetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY)
}

View file

@ -0,0 +1,120 @@
package netprint
import (
"net"
"os"
"strconv"
"strings"
"testing"
"time"
)
func TestListenIPPAcceptsIPv4AndLinkLocalIPv6(t *testing.T) {
port := freePort(t)
ln, got, err := listenIPP(Config{Port: port})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
if got != port {
t.Fatalf("port %d", got)
}
ml, ok := ln.(*multiListener)
if !ok || len(ml.lns) != 2 {
t.Fatalf("listener %T (%s)", ln, listenerAddrs(ln))
}
v6only, err := socketIPv6Only(ml.lns[1])
if err != nil {
t.Fatal(err)
}
if v6only != 1 {
t.Fatalf("IPV6_V6ONLY %d", v6only)
}
dial := func(network, address string) {
t.Helper()
conn, err := net.DialTimeout(network, address, 2*time.Second)
if err != nil {
t.Fatalf("dial %s %s: %v", network, address, err)
}
_ = conn.Close()
}
dial("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
dial("tcp6", net.JoinHostPort("::1", strconv.Itoa(port)))
ip, zone := firstLinkLocal()
if ip == nil {
t.Log("no link-local address; [::] with IPV6_V6ONLY still accepts ::1")
return
}
dial("tcp6", net.JoinHostPort(ip.String()+"%"+zone, strconv.Itoa(port)))
}
func TestListenIPPSpecificIPv4StaysIPv4(t *testing.T) {
port := freePort(t)
ln, _, err := listenIPP(Config{Bind: "127.0.0.1", Port: port})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
if _, ok := ln.(*net.TCPListener); !ok {
t.Fatalf("specific bind opened %T", ln)
}
conn, err := net.DialTimeout("tcp6", net.JoinHostPort("::1", strconv.Itoa(port)), 200*time.Millisecond)
if err == nil {
_ = conn.Close()
t.Fatal("127.0.0.1 bind accepted IPv6")
}
}
func TestFirewallRuleAllowsLinkLocal(t *testing.T) {
if firewallRemoteIP != "any" {
t.Fatalf("remoteip %q", firewallRemoteIP)
}
src, err := os.ReadFile("firewall.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), "fe80::/10") {
t.Fatal("firewall.go does not document link-local")
}
win, err := os.ReadFile("service_windows.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(win), "remoteip="+`firewallRemoteIP`) && !strings.Contains(string(win), `remoteip="+firewallRemoteIP`) {
t.Fatal("service firewall rule does not set remoteip")
}
for _, path := range []string{"../../platform/windows/installer-netprint.nsi", "../../platform/windows/installer-windows.nsi"} {
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(b)
if strings.Count(text, "remoteip=any") < 3 || !strings.Contains(text, "fe80::/10") {
t.Fatalf("%s missing link-local firewall coverage", path)
}
}
}
func firstLinkLocal() (net.IP, string) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, ""
}
for _, ifi := range ifaces {
if ifi.Flags&net.FlagUp == 0 {
continue
}
addrs, err := ifi.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
ip := addrIP(a)
if ip == nil || ip.To4() != nil || !ip.IsLinkLocalUnicast() {
continue
}
return ip, ifi.Name
}
}
return nil, ""
}

View file

@ -30,6 +30,11 @@ type Service struct {
Port int
IPs []net.IP
TXT []string
// SubtypesOnly answers _universal._sub and _print._sub PTRs and nothing
// else. Windows uses that mode while DnsServiceRegister owns the primary
// _ipp._tcp name: a second SRV or TXT for the same instance is a conflict,
// and mDNS renames one of them to "Name(1)".
SubtypesOnly bool
}
// AirPrintTXT returns the Bonjour Printing / AirPrint text records.
@ -145,6 +150,9 @@ func (s Service) matches(q dnsQuestion) bool {
univ := "_universal._sub._ipp._tcp.local"
print := "_print._sub._ipp._tcp.local"
enum := "_services._dns-sd._udp.local"
if s.SubtypesOnly {
return (n == univ || n == print) && (q.typ == dnsTypePTR || q.typ == dnsTypeANY)
}
switch {
case n == ptr || n == univ || n == print || n == enum:
return q.typ == dnsTypePTR || q.typ == dnsTypeANY
@ -175,10 +183,12 @@ func (s Service) Announce(goodbye bool) []byte {
ttl = 0
}
qs := []dnsQuestion{
{name: []string{"_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN},
{name: []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN},
{name: []string{"_print", "_sub", "_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN},
}
if !s.SubtypesOnly {
qs = append([]dnsQuestion{{name: []string{"_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}}, qs...)
}
return s.encodeResponse(0, qs, ttl)
}
@ -209,18 +219,31 @@ func (s Service) encodeResponse(id uint16, qs []dnsQuestion, ttl uint32) []byte
n := lowerJoin(q.name)
switch {
case n == "_services._dns-sd._udp.local":
if s.SubtypesOnly {
break
}
k := "_ipp._tcp.local"
if !seenPTR[k] {
an = append(an, dnsRR{name: q.name, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: []string{"_ipp", "_tcp", "local"}})
seenPTR[k] = true
}
case n == "_ipp._tcp.local" || n == "_universal._sub._ipp._tcp.local" || n == "_print._sub._ipp._tcp.local":
if s.SubtypesOnly && n == "_ipp._tcp.local" {
break
}
if !seenPTR[n] {
an = append(an, dnsRR{name: q.name, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: s.instanceLabels()})
seenPTR[n] = true
}
needSRV, needTXT, needAddr = true, true, true
// Subtype PTRs are not a second claim on the instance. SRV, TXT,
// and address records stay with whoever owns the base name.
if !s.SubtypesOnly {
needSRV, needTXT, needAddr = true, true, true
}
case n == lowerJoin(s.instanceLabels()):
if s.SubtypesOnly {
break
}
if q.typ == dnsTypeANY || q.typ == dnsTypeSRV {
needSRV = true
}
@ -231,7 +254,9 @@ func (s Service) encodeResponse(id uint16, qs []dnsQuestion, ttl uint32) []byte
needSRV, needTXT, needAddr = true, true, true
}
case n == lowerJoin(s.hostLabels()):
needAddr = true
if !s.SubtypesOnly {
needAddr = true
}
}
}
if needSRV {
@ -538,7 +563,7 @@ func lanIPs() []net.IP {
}
for _, a := range addrs {
ip := addrIP(a)
if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
if !keepLAN(ip) {
continue
}
if v4 := ip.To4(); v4 != nil {
@ -553,6 +578,17 @@ func lanIPs() []net.IP {
return out
}
// keepLAN reports whether ip may be published in our own A/AAAA records.
// Link-local addresses (fe80::/10 and 169.254.0.0/16) are omitted. Windows
// still publishes fe80 for the computer name that the SRV target uses; the
// IPP listener accepts that address instead of leaving the connect unanswered.
func keepLAN(ip net.IP) bool {
if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
return ip.To4() != nil || ip.To16() != nil
}
func addrIP(a net.Addr) net.IP {
switch v := a.(type) {
case *net.IPNet:

View file

@ -125,6 +125,9 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) {
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)
@ -143,11 +146,43 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) {
t.Fatalf("deregistered %d", len(api.deregistered))
}
ann := pkt.packet(0)
if err := assertPrimaryPTRs(ann, "Verae Timestamp"); err != nil {
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) {

View file

@ -254,6 +254,129 @@ func assertPrimaryPTRs(msg []byte, instance string) error {
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)

View file

@ -167,7 +167,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl)
log.Printf("netprint: mDNS disabled")
s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"})
}
log.Printf("netprint: IPP on %s, control on %s", ippLn.Addr(), ctlLn.Addr())
log.Printf("netprint: IPP on %s, control on %s", listenerAddrs(ippLn), ctlLn.Addr())
return s, nil
}
@ -231,11 +231,11 @@ func ippListenPorts(cfg Config) []int {
}
func listenIPP(cfg Config) (net.Listener, int, error) {
bind := cfg.Bind
v4host, v6host := ippBindHosts(cfg.Bind)
try := ippListenPorts(cfg)
var last error
for _, port := range try {
ln, err := net.Listen("tcp", net.JoinHostPort(bind, fmt.Sprint(port)))
ln, err := listenPort(v4host, v6host, port)
if err == nil {
return ln, port, nil
}

View file

@ -130,7 +130,7 @@ func ensureFirewall(public bool) {
for _, r := range rules {
out, err := exec.Command("netsh", "advfirewall", "firewall", "add", "rule",
"name="+r.name, "dir=in", "action=allow", "protocol="+r.proto,
"localport="+r.port, "profile="+profile).CombinedOutput()
"localport="+r.port, "remoteip="+firewallRemoteIP, "profile="+profile).CombinedOutput()
if err != nil {
log.Printf("netprint: firewall %s profile %s: %v (%s)", r.name, profile, err, strings.TrimSpace(string(out)))
} else {

View file

@ -112,19 +112,20 @@ FunctionEnd
; Inbound TCP 631 (IPP), TCP 8632 (fallback; 8631 is the driver helper), UDP 5353 (mDNS).
; $FwProfile is private,domain unless the installer opted into Public.
; remoteip=any includes IPv4, global IPv6, and link-local fe80::/10.
; A macro so the uninstaller can run the same commands.
!macro NETPRINT_FIREWALL_ADD
nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"'
Pop $0
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=$FwProfile'
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 remoteip=any profile=$FwProfile'
Pop $0
nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"'
Pop $0
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=$FwProfile'
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 remoteip=any profile=$FwProfile'
Pop $0
nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"'
Pop $0
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=$FwProfile'
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 remoteip=any profile=$FwProfile'
Pop $0
!macroend

View file

@ -813,6 +813,7 @@ Section "Verae Print Stamp + printer" SecMain
DetailPrint "Installing the Verae network printer (service + tray)"
; TCP 631, TCP 8632 (not the helper's 8631), UDP 5353.
; Private and domain unless the Public checkbox was turned on.
; remoteip=any includes IPv4, global IPv6, and link-local fe80::/10.
StrCpy $FwProfile "private,domain"
StrCpy $R8 "false"
${If} $NetPublic == "1"
@ -821,15 +822,15 @@ Section "Verae Print Stamp + printer" SecMain
${EndIf}
nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"'
Pop $0
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=$FwProfile'
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 remoteip=any profile=$FwProfile'
Pop $0
nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"'
Pop $0
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=$FwProfile'
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 remoteip=any profile=$FwProfile'
Pop $0
nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"'
Pop $0
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=$FwProfile'
nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 remoteip=any profile=$FwProfile'
Pop $0
CreateDirectory "$LogDir"
StrCpy $R9 '"$INSTDIR\bin\verae-print.exe" netprint install --owner driver --allow-public=$R8'