From 1433ba026d2d170d9c1ecf8e796052bcefa3ae83 Mon Sep 17 00:00:00 2001 From: Tom Luca Roth Date: Fri, 6 Mar 2026 10:54:57 +0100 Subject: [PATCH 1/4] In Register and RegisterProxy ensure that passed domain has trailing dot. This in turn then ensures that when we check if the hostname has the domain as a suffix, we also check that the hostname has a trailing dot --- server.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server.go b/server.go index bd74262..1261fed 100644 --- a/server.go +++ b/server.go @@ -86,6 +86,9 @@ func Register(instance, service, domain string, port int, text []string, ifaces return nil, fmt.Errorf("missing port") } + // Ensure domain has trailing dot + entry.Domain = fmt.Sprintf("%s.", trimDot(entry.Domain)) + var err error if entry.HostName == "" { entry.HostName, err = os.Hostname() @@ -147,6 +150,9 @@ func RegisterProxy(instance, service, domain string, port int, host string, ips return nil, fmt.Errorf("missing port") } + // Ensure domain has trailing dot + entry.Domain = fmt.Sprintf("%s.", trimDot(entry.Domain)) + if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) { entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain)) } From c100db465e515ba5b78deef2518c16141836a248 Mon Sep 17 00:00:00 2001 From: Tom Luca Roth Date: Fri, 6 Mar 2026 12:32:36 +0100 Subject: [PATCH 2/4] Changed logic when checking hostname in Register and RegisterProxy to avoid "hostname.local.local" case and added tests --- main/main.go | 22 +++++++ server.go | 24 +++++--- service_test.go | 150 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 main/main.go diff --git a/main/main.go b/main/main.go new file mode 100644 index 0000000..18de0d8 --- /dev/null +++ b/main/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/enbility/zeroconf/v3" +) + + +func main() { + server, err := zeroconf.Register("GoZeroconf", "_workstation._tcp", "local", 42424, []string{"txtv=0", "lo=1", "la=2"}, nil) + if err != nil { + fmt.Println(err.Error()) + } + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + _ = <-sigChan + server.Shutdown() +} \ No newline at end of file diff --git a/server.go b/server.go index 1261fed..9315ac3 100644 --- a/server.go +++ b/server.go @@ -85,10 +85,7 @@ func Register(instance, service, domain string, port int, text []string, ifaces if entry.Port == 0 { return nil, fmt.Errorf("missing port") } - - // Ensure domain has trailing dot - entry.Domain = fmt.Sprintf("%s.", trimDot(entry.Domain)) - + var err error if entry.HostName == "" { entry.HostName, err = os.Hostname() @@ -97,7 +94,15 @@ func Register(instance, service, domain string, port int, text []string, ifaces } } - if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) { + // On MacOS os.Hostname() returns the hostname with the domain at the end but without trailing "." + // e.g. "MacBook-Air.local" in this case we simply add a dot to get a fully qualified mdns domain + if strings.HasSuffix(entry.HostName, trimDot(entry.Domain)) { + entry.HostName += "." + } + + // Ensure domain has trailing dot + entry.Domain = fmt.Sprintf("%s.", trimDot(entry.Domain)) + if !strings.HasSuffix(entry.HostName, entry.Domain) { entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain)) } @@ -150,10 +155,15 @@ func RegisterProxy(instance, service, domain string, port int, host string, ips return nil, fmt.Errorf("missing port") } + // On MacOS os.Hostname() returns the hostname with the domain at the end but without trailing "." + // e.g. "MacBook-Air.local" in this case we simply add a dot to get a fully qualified mdns domain + if strings.HasSuffix(entry.HostName, trimDot(entry.Domain)) { + entry.HostName += "." + } + // Ensure domain has trailing dot entry.Domain = fmt.Sprintf("%s.", trimDot(entry.Domain)) - - if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) { + if !strings.HasSuffix(entry.HostName, entry.Domain) { entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain)) } diff --git a/service_test.go b/service_test.go index d2f39b4..93b2fb2 100644 --- a/service_test.go +++ b/service_test.go @@ -2,7 +2,10 @@ package zeroconf import ( "context" + "fmt" "log" + "os" + "strings" "testing" "time" ) @@ -202,3 +205,150 @@ func TestSubtype(t *testing.T) { } }) } + +func TestFullyQualifiedDomain(t *testing.T) { + discoverEntry := func(t *testing.T, instance, service, domain string) *ServiceEntry { + t.Helper() + + time.Sleep(time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + entries := make(chan *ServiceEntry, 100) + expired := make(chan *ServiceEntry, 100) + if err := Browse(ctx, service, fmt.Sprintf("%s.", trimDot(domain)), entries, expired); err != nil { + t.Fatalf("Expected browse success, but got %v", err) + } + <-ctx.Done() + + if len(entries) == 0 { + t.Fatal("Expected at least one service entry, but got none") + } + + for len(entries) > 0 { + result := <-entries + if result.Instance == instance { + return result + } + } + + t.Fatalf("Expected service entry for instance %q, but did not find it", instance) + return nil + } + + expectedHostName := func(hostName, domain string) string { + t.Helper() + + if strings.HasSuffix(hostName, trimDot(domain)) { + hostName += "." + } + + domain = fmt.Sprintf("%s.", trimDot(domain)) + if !strings.HasSuffix(hostName, domain) { + hostName = fmt.Sprintf("%s.%s.", trimDot(hostName), trimDot(domain)) + } + + return hostName + } + + t.Run("Register", func(t *testing.T) { + testCases := []struct { + name string + domain string + }{ + {name: "domain without trailing dot", domain: "local"}, + {name: "domain with trailing dot", domain: "local."}, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + hostName, err := os.Hostname() + if err != nil { + t.Fatalf("could not determine host: %v", err) + } + + instance := fmt.Sprintf("test-register-fqdn-%d", i) + service := fmt.Sprintf("_fqdn-register-%d._tcp", i) + + server, err := Register(instance, service, tc.domain, mdnsPort, []string{"txtv=0"}, nil) + if err != nil { + t.Fatalf("error while registering mdns service: %s", err) + } + t.Cleanup(server.Shutdown) + + result := discoverEntry(t, instance, service, tc.domain) + if result.Domain != "local." { + t.Fatalf("Expected domain is local., but got %s", result.Domain) + } + if result.HostName != expectedHostName(hostName, tc.domain) { + t.Fatalf("Expected hostname is %s, but got %s", expectedHostName(hostName, tc.domain), result.HostName) + } + }) + } + }) + + t.Run("RegisterProxy", func(t *testing.T) { + testCases := []struct { + name string + hostName string + domain string + expectedHost string + }{ + { + name: "short hostname without trailing dot in domain", + hostName: "Laptop-1", + domain: "local", + expectedHost: "Laptop-1.local.", + }, + { + name: "short hostname with trailing dot in domain", + hostName: "Laptop-1", + domain: "local.", + expectedHost: "Laptop-1.local.", + }, + { + name: "hostname including domain without trailing dot in domain", + hostName: "MacBook-Air.local", + domain: "local", + expectedHost: "MacBook-Air.local.", + }, + { + name: "hostname including domain with trailing dot in domain", + hostName: "MacBook-Air.local", + domain: "local.", + expectedHost: "MacBook-Air.local.", + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + instance := fmt.Sprintf("test-registerproxy-fqdn-%d", i) + service := fmt.Sprintf("_fqdn-registerproxy-%d._tcp", i) + + server, err := RegisterProxy( + instance, + service, + tc.domain, + mdnsPort, + tc.hostName, + []string{"192.168.1.100"}, + []string{"txtv=0"}, + nil, + ) + if err != nil { + t.Fatalf("error while registering proxy mdns service: %s", err) + } + t.Cleanup(server.Shutdown) + + result := discoverEntry(t, instance, service, tc.domain) + if result.Domain != "local." { + t.Fatalf("Expected domain is local., but got %s", result.Domain) + } + if result.HostName != tc.expectedHost { + t.Fatalf("Expected hostname is %s, but got %s", tc.expectedHost, result.HostName) + } + }) + } + }) +} From 10039a30f329ab57a533384499cabf8542c2dfec Mon Sep 17 00:00:00 2001 From: Tom Luca Roth Date: Fri, 6 Mar 2026 12:41:50 +0100 Subject: [PATCH 3/4] Added package var for hostnameFunc so it can be changed out/mocked in tests. Also added the corresponding tests --- server.go | 10 ++++---- service_test.go | 62 ++++++++++++++++++++++++++++--------------------- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/server.go b/server.go index 9315ac3..a681116 100644 --- a/server.go +++ b/server.go @@ -21,6 +21,8 @@ const ( var defaultTTL uint32 = 3200 +var hostnameFunc = os.Hostname + type serverOpts struct { ttl uint32 connFactory api.ConnectionFactory @@ -85,17 +87,17 @@ func Register(instance, service, domain string, port int, text []string, ifaces if entry.Port == 0 { return nil, fmt.Errorf("missing port") } - + var err error if entry.HostName == "" { - entry.HostName, err = os.Hostname() + entry.HostName, err = hostnameFunc() if err != nil { return nil, fmt.Errorf("could not determine host") } } // On MacOS os.Hostname() returns the hostname with the domain at the end but without trailing "." - // e.g. "MacBook-Air.local" in this case we simply add a dot to get a fully qualified mdns domain + // e.g. "MacBook-Air.local" in this case we simply add a dot to get a fully qualified mdns domain if strings.HasSuffix(entry.HostName, trimDot(entry.Domain)) { entry.HostName += "." } @@ -156,7 +158,7 @@ func RegisterProxy(instance, service, domain string, port int, host string, ips } // On MacOS os.Hostname() returns the hostname with the domain at the end but without trailing "." - // e.g. "MacBook-Air.local" in this case we simply add a dot to get a fully qualified mdns domain + // e.g. "MacBook-Air.local" in this case we simply add a dot to get a fully qualified mdns domain if strings.HasSuffix(entry.HostName, trimDot(entry.Domain)) { entry.HostName += "." } diff --git a/service_test.go b/service_test.go index 93b2fb2..a31aba6 100644 --- a/service_test.go +++ b/service_test.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "log" - "os" - "strings" "testing" "time" ) @@ -237,36 +235,48 @@ func TestFullyQualifiedDomain(t *testing.T) { return nil } - expectedHostName := func(hostName, domain string) string { - t.Helper() - - if strings.HasSuffix(hostName, trimDot(domain)) { - hostName += "." - } - - domain = fmt.Sprintf("%s.", trimDot(domain)) - if !strings.HasSuffix(hostName, domain) { - hostName = fmt.Sprintf("%s.%s.", trimDot(hostName), trimDot(domain)) - } - - return hostName - } - t.Run("Register", func(t *testing.T) { testCases := []struct { - name string - domain string + name string + hostName string + domain string + expectedHost string }{ - {name: "domain without trailing dot", domain: "local"}, - {name: "domain with trailing dot", domain: "local."}, + { + name: "short hostname without trailing dot in domain", + hostName: "Laptop-1", + domain: "local", + expectedHost: "Laptop-1.local.", + }, + { + name: "short hostname with trailing dot in domain", + hostName: "Laptop-1", + domain: "local.", + expectedHost: "Laptop-1.local.", + }, + { + name: "hostname including domain without trailing dot in domain", + hostName: "MacBook-Air.local", + domain: "local", + expectedHost: "MacBook-Air.local.", + }, + { + name: "hostname including domain with trailing dot in domain", + hostName: "MacBook-Air.local", + domain: "local.", + expectedHost: "MacBook-Air.local.", + }, } for i, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - hostName, err := os.Hostname() - if err != nil { - t.Fatalf("could not determine host: %v", err) + origHostnameFunc := hostnameFunc + hostnameFunc = func() (string, error) { + return tc.hostName, nil } + t.Cleanup(func() { + hostnameFunc = origHostnameFunc + }) instance := fmt.Sprintf("test-register-fqdn-%d", i) service := fmt.Sprintf("_fqdn-register-%d._tcp", i) @@ -281,8 +291,8 @@ func TestFullyQualifiedDomain(t *testing.T) { if result.Domain != "local." { t.Fatalf("Expected domain is local., but got %s", result.Domain) } - if result.HostName != expectedHostName(hostName, tc.domain) { - t.Fatalf("Expected hostname is %s, but got %s", expectedHostName(hostName, tc.domain), result.HostName) + if result.HostName != tc.expectedHost { + t.Fatalf("Expected hostname is %s, but got %s", tc.expectedHost, result.HostName) } }) } From 580bfc73d05c2de8ba4e791f8e64c8ef2d762345 Mon Sep 17 00:00:00 2001 From: Simon Thelen Date: Mon, 9 Mar 2026 11:20:25 +0100 Subject: [PATCH 4/4] chore: cleanup local test tool --- main/main.go | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 main/main.go diff --git a/main/main.go b/main/main.go deleted file mode 100644 index 18de0d8..0000000 --- a/main/main.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/signal" - "syscall" - - "github.com/enbility/zeroconf/v3" -) - - -func main() { - server, err := zeroconf.Register("GoZeroconf", "_workstation._tcp", "local", 42424, []string{"txtv=0", "lo=1", "la=2"}, nil) - if err != nil { - fmt.Println(err.Error()) - } - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - _ = <-sigChan - server.Shutdown() -} \ No newline at end of file