bugfixes nov2025 pt4 (#7)

* Add mDNS resolution.
* Update the website and documentation
This commit is contained in:
2025-11-25 11:14:33 +00:00
committed by GitHub
parent 3a7cc6f502
commit 1167847fd4
14 changed files with 1550 additions and 1607 deletions
+220
View File
@@ -0,0 +1,220 @@
package mdns
import (
"fmt"
"net"
"sync"
"time"
"github.com/grandcat/zeroconf"
"github.com/nvm/kportal/internal/logger"
)
const (
// shutdownTimeout is the maximum time to wait for mDNS server shutdown
shutdownTimeout = 2 * time.Second
// mdnsDomain is the standard mDNS domain (RFC 6762)
// This is always ".local" for multicast DNS - it's not configurable
// and is different from your network's DNS search domain
mdnsDomain = "local"
)
// Publisher manages mDNS hostname registrations for port forwards.
// It allows forwards with aliases to be accessible via <alias>.local hostnames.
type Publisher struct {
mu sync.RWMutex
servers map[string]*zeroconf.Server // forwardID -> server
aliases map[string]string // forwardID -> alias (for logging)
enabled bool
localIPs []string
}
// NewPublisher creates a new mDNS Publisher.
// If enabled is false, all registration calls will be no-ops.
func NewPublisher(enabled bool) *Publisher {
p := &Publisher{
servers: make(map[string]*zeroconf.Server),
aliases: make(map[string]string),
enabled: enabled,
localIPs: getLocalIPs(),
}
if enabled {
logger.Info("mDNS publisher initialized", map[string]interface{}{
"domain": mdnsDomain,
"local_ips": p.localIPs,
})
}
return p
}
// Register publishes an mDNS hostname for a forward.
// The hostname will be <alias>.local and will resolve to 127.0.0.1.
// If the forward has no alias or mDNS is disabled, this is a no-op.
func (p *Publisher) Register(forwardID, alias string, localPort int) error {
if !p.enabled || alias == "" {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
// Check if already registered
if _, exists := p.servers[forwardID]; exists {
logger.Debug("mDNS hostname already registered", map[string]interface{}{
"forward_id": forwardID,
"alias": alias,
})
return nil
}
// Register the mDNS service
// We use a generic service type and rely on the hostname registration
server, err := zeroconf.RegisterProxy(
alias, // Instance name (shown in service discovery)
"_kportal._tcp", // Service type (custom for kportal)
"local.", // Domain
localPort, // Port
alias, // Hostname (will be <alias>.local)
[]string{"127.0.0.1"}, // IPs to resolve to
[]string{fmt.Sprintf("forward=%s", forwardID)}, // TXT records
nil, // interfaces (nil = all)
)
if err != nil {
return fmt.Errorf("failed to register mDNS for %s: %w", alias, err)
}
p.servers[forwardID] = server
p.aliases[forwardID] = alias
logger.Info("mDNS hostname registered", map[string]interface{}{
"forward_id": forwardID,
"hostname": GetHostname(alias),
"port": localPort,
})
return nil
}
// Unregister removes the mDNS hostname for a forward.
func (p *Publisher) Unregister(forwardID string) {
if !p.enabled {
return
}
p.mu.Lock()
defer p.mu.Unlock()
server, exists := p.servers[forwardID]
if !exists {
return
}
alias := p.aliases[forwardID]
shutdownWithTimeout(server, forwardID)
delete(p.servers, forwardID)
delete(p.aliases, forwardID)
logger.Info("mDNS hostname unregistered", map[string]interface{}{
"forward_id": forwardID,
"hostname": GetHostname(alias),
})
}
// Stop shuts down all mDNS registrations.
func (p *Publisher) Stop() {
if !p.enabled {
return
}
p.mu.Lock()
defer p.mu.Unlock()
// Shutdown all servers concurrently with timeout
var wg sync.WaitGroup
for forwardID, server := range p.servers {
wg.Add(1)
go func(id string, srv *zeroconf.Server) {
defer wg.Done()
shutdownWithTimeout(srv, id)
}(forwardID, server)
}
// Wait for all shutdowns to complete (or timeout)
wg.Wait()
p.servers = make(map[string]*zeroconf.Server)
p.aliases = make(map[string]string)
logger.Info("mDNS publisher stopped", nil)
}
// shutdownWithTimeout attempts to shutdown a zeroconf server with a timeout.
// If shutdown hangs, it logs a warning and returns anyway.
func shutdownWithTimeout(server *zeroconf.Server, forwardID string) {
done := make(chan struct{})
go func() {
server.Shutdown()
close(done)
}()
select {
case <-done:
// Shutdown completed successfully
case <-time.After(shutdownTimeout):
logger.Warn("mDNS shutdown timed out, continuing anyway", map[string]interface{}{
"forward_id": forwardID,
"timeout": shutdownTimeout.String(),
})
}
}
// IsEnabled returns whether mDNS publishing is enabled.
func (p *Publisher) IsEnabled() bool {
return p.enabled
}
// GetDomain returns the mDNS domain being used (always "local" per RFC 6762).
func (p *Publisher) GetDomain() string {
return mdnsDomain
}
// GetHostname returns the full mDNS hostname for an alias.
// Example: GetHostname("myapp") returns "myapp.local"
func GetHostname(alias string) string {
return alias + "." + mdnsDomain
}
// GetRegisteredCount returns the number of currently registered hostnames.
func (p *Publisher) GetRegisteredCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.servers)
}
// getLocalIPs returns the local IP addresses for logging purposes.
func getLocalIPs() []string {
var ips []string
addrs, err := net.InterfaceAddrs()
if err != nil {
return []string{"127.0.0.1"}
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
ips = append(ips, ipnet.IP.String())
}
}
}
if len(ips) == 0 {
return []string{"127.0.0.1"}
}
return ips
}
+154
View File
@@ -0,0 +1,154 @@
package mdns
import (
"testing"
"github.com/stretchr/testify/assert"
)
// Note: Tests that actually register mDNS services require network I/O
// and can be slow or hang in CI environments. We test the logic paths
// without actually calling zeroconf for most tests.
func TestNewPublisher_Disabled(t *testing.T) {
p := NewPublisher(false)
assert.False(t, p.IsEnabled())
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestNewPublisher_Enabled(t *testing.T) {
p := NewPublisher(true)
assert.True(t, p.IsEnabled())
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestRegister_WhenDisabled_NoOp(t *testing.T) {
p := NewPublisher(false)
err := p.Register("forward-1", "test-alias", 8080)
assert.NoError(t, err)
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestRegister_EmptyAlias_NoOp(t *testing.T) {
p := NewPublisher(true)
err := p.Register("forward-1", "", 8080)
assert.NoError(t, err)
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestUnregister_WhenDisabled_NoOp(t *testing.T) {
p := NewPublisher(false)
// Should not panic
p.Unregister("forward-1")
}
func TestUnregister_NotRegistered_NoOp(t *testing.T) {
p := NewPublisher(true)
// Should not panic
p.Unregister("non-existent")
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestStop_WhenDisabled_NoOp(t *testing.T) {
p := NewPublisher(false)
// Should not panic
p.Stop()
}
func TestStop_WhenNoRegistrations(t *testing.T) {
p := NewPublisher(true)
// Should not panic
p.Stop()
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestGetLocalIPs(t *testing.T) {
ips := getLocalIPs()
// Should return at least one IP
assert.NotEmpty(t, ips, "getLocalIPs should return at least one IP")
// All IPs should be non-empty strings
for _, ip := range ips {
assert.NotEmpty(t, ip, "IP address should not be empty")
}
}
// Integration tests - only run when explicitly requested
// These tests actually register mDNS services and require network access
func TestRegister_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping mDNS integration test in short mode")
}
p := NewPublisher(true)
defer p.Stop()
err := p.Register("forward-1", "test-service", 8080)
assert.NoError(t, err)
assert.Equal(t, 1, p.GetRegisteredCount())
}
func TestRegister_Duplicate_Idempotent_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping mDNS integration test in short mode")
}
p := NewPublisher(true)
defer p.Stop()
// First registration
err := p.Register("forward-1", "test-service", 8080)
assert.NoError(t, err)
assert.Equal(t, 1, p.GetRegisteredCount())
// Second registration with same ID should be idempotent
err = p.Register("forward-1", "test-service", 8080)
assert.NoError(t, err)
assert.Equal(t, 1, p.GetRegisteredCount())
}
func TestRegister_MultipleForwards_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping mDNS integration test in short mode")
}
p := NewPublisher(true)
defer p.Stop()
err1 := p.Register("forward-1", "service-a", 8080)
err2 := p.Register("forward-2", "service-b", 8081)
err3 := p.Register("forward-3", "service-c", 8082)
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.NoError(t, err3)
assert.Equal(t, 3, p.GetRegisteredCount())
}
func TestUnregister_Success_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping mDNS integration test in short mode")
}
p := NewPublisher(true)
defer p.Stop()
p.Register("forward-1", "test-service", 8080)
assert.Equal(t, 1, p.GetRegisteredCount())
p.Unregister("forward-1")
assert.Equal(t, 0, p.GetRegisteredCount())
}