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:
parent
e099cee78c
commit
17bb067010
14 changed files with 575 additions and 29 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
179
internal/netprint/listen.go
Normal 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
|
||||
}
|
||||
13
internal/netprint/listen_ipv6_unix.go
Normal file
13
internal/netprint/listen_ipv6_unix.go
Normal 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)
|
||||
}
|
||||
13
internal/netprint/listen_ipv6_windows.go
Normal file
13
internal/netprint/listen_ipv6_windows.go
Normal 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)
|
||||
}
|
||||
120
internal/netprint/listen_test.go
Normal file
120
internal/netprint/listen_test.go
Normal 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, ""
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue