mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-15 07:57:49 +00:00
bugfixes nov2025 pt4 (#7)
* Add mDNS resolution. * Update the website and documentation
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user