+
-
-

-
-
- Kubernetes port-forward manager for professionals
-
+

+
Kubernetes port-forward manager for professionals
-
- Built With
-
+
Documentation
+
+
+
+
Built With
- Bubble Tea
- Lipgloss
- -
- client-go
-
+ - client-go
-
-
- Made by
- Lukasz Raczylo, tested on animals. They loved it!
-
+
+
Made by Lukasz Raczylo
MIT License
@@ -870,7 +661,6 @@
menuCloseIcon.classList.toggle("hidden");
});
- // Close mobile menu when clicking on a link
const mobileMenuLinks = mobileMenu.querySelectorAll("a");
mobileMenuLinks.forEach(link => {
link.addEventListener("click", () => {
@@ -892,84 +682,48 @@
}
});
- // Copy to clipboard function with fallback
+ // Copy to clipboard
function copyToClipboard(text, button) {
- // Modern clipboard API (preferred)
if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard
- .writeText(text)
- .then(() => {
- showCopySuccess(button);
- })
- .catch((err) => {
- console.error("Clipboard API failed:", err);
- fallbackCopy(text, button);
- });
+ navigator.clipboard.writeText(text).then(() => showCopySuccess(button)).catch(() => fallbackCopy(text, button));
} else {
- // Fallback for older browsers or insecure contexts
fallbackCopy(text, button);
}
}
- // Fallback copy method using execCommand
function fallbackCopy(text, button) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
- textarea.style.top = "0";
- textarea.style.left = "0";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
- textarea.focus();
textarea.select();
-
try {
- const successful = document.execCommand("copy");
- if (successful) {
- showCopySuccess(button);
- } else {
- showCopyError(button);
- }
+ document.execCommand("copy") ? showCopySuccess(button) : showCopyError(button);
} catch (err) {
- console.error("Fallback copy failed:", err);
showCopyError(button);
}
-
document.body.removeChild(textarea);
}
- // Show success feedback
function showCopySuccess(button) {
- const originalHTML = button.innerHTML;
- button.innerHTML =
- '
';
- setTimeout(() => {
- button.innerHTML = originalHTML;
- }, 2000);
+ const original = button.innerHTML;
+ button.innerHTML = '
';
+ setTimeout(() => button.innerHTML = original, 2000);
}
- // Show error feedback
function showCopyError(button) {
- const originalHTML = button.innerHTML;
+ const original = button.innerHTML;
button.innerHTML = '
';
- setTimeout(() => {
- button.innerHTML = originalHTML;
- }, 2000);
+ setTimeout(() => button.innerHTML = original, 2000);
}
// Smooth scrolling
- document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
- anchor.addEventListener("click", function (e) {
+ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
+ anchor.addEventListener("click", function(e) {
e.preventDefault();
- const target = document.querySelector(
- this.getAttribute("href"),
- );
- if (target) {
- target.scrollIntoView({
- behavior: "smooth",
- block: "start",
- });
- }
+ const target = document.querySelector(this.getAttribute("href"));
+ if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
diff --git a/go.mod b/go.mod
index 47c36ec..a6cd42e 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/x/ansi v0.11.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
@@ -48,11 +49,13 @@ require (
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
+ github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/miekg/dns v1.1.27 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -68,6 +71,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/crypto v0.44.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sys v0.38.0 // indirect
diff --git a/go.sum b/go.sum
index cf0c246..b854074 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
+github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
@@ -84,6 +86,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
+github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
+github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -100,6 +104,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM=
+github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -122,6 +128,7 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -149,12 +156,17 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
+golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
@@ -166,6 +178,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -181,6 +194,7 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
diff --git a/internal/config/config.go b/internal/config/config.go
index fbc6573..f920b50 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"os"
+ "strings"
"time"
"gopkg.in/yaml.v3"
@@ -31,6 +32,13 @@ type Config struct {
Contexts []Context `yaml:"contexts"`
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
+ MDNS *MDNSSpec `yaml:"mdns,omitempty"`
+}
+
+// MDNSSpec configures mDNS (multicast DNS) hostname publishing
+// When enabled, forwards with aliases can be accessed via
.local hostnames
+type MDNSSpec struct {
+ Enabled bool `yaml:"enabled"` // Enable mDNS hostname publishing
}
// HealthCheckSpec configures health check behavior
@@ -133,6 +141,11 @@ func (c *Config) GetDialTimeout() time.Duration {
return parseDurationOrDefault(c.Reliability.DialTimeout, DefaultDialTimeout)
}
+// IsMDNSEnabled returns whether mDNS hostname publishing is enabled
+func (c *Config) IsMDNSEnabled() bool {
+ return c.MDNS != nil && c.MDNS.Enabled
+}
+
// Context represents a Kubernetes context with its namespaces
type Context struct {
Name string `yaml:"name"`
@@ -199,6 +212,25 @@ func (f *Forward) GetNamespace() string {
return f.namespaceName
}
+// GetMDNSAlias returns the alias to use for mDNS hostname registration.
+// If an explicit alias is set, it returns that.
+// Otherwise, it generates one from the resource name (e.g., "service/logto" -> "logto").
+func (f *Forward) GetMDNSAlias() string {
+ if f.Alias != "" {
+ return f.Alias
+ }
+
+ // Generate alias from resource name
+ // Format is "type/name" (e.g., "service/logto", "pod/my-app")
+ parts := strings.SplitN(f.Resource, "/", 2)
+ if len(parts) == 2 && parts[1] != "" {
+ return parts[1]
+ }
+
+ // Fallback: can't generate a valid alias (e.g., "pod" with selector)
+ return ""
+}
+
// LoadConfig loads and parses the configuration file from the given path.
func LoadConfig(path string) (*Config, error) {
// Validate file size before reading
diff --git a/internal/config/validator.go b/internal/config/validator.go
index bab8a4a..22ef252 100644
--- a/internal/config/validator.go
+++ b/internal/config/validator.go
@@ -61,6 +61,11 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
// Check for duplicate local ports
errs = append(errs, v.validateDuplicatePorts(cfg)...)
+ // Validate mDNS configuration
+ if cfg.IsMDNSEnabled() {
+ errs = append(errs, v.validateMDNS(cfg)...)
+ }
+
return errs
}
@@ -270,3 +275,85 @@ func FormatValidationErrors(errs []ValidationError) string {
return sb.String()
}
+
+// validateMDNS validates mDNS configuration when enabled.
+// It checks that aliases used for mDNS hostnames are valid and unique.
+// This includes both explicit aliases and auto-generated ones from resource names.
+func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
+ var errs []ValidationError
+
+ aliasMap := make(map[string][]string) // alias -> list of forward IDs using it
+
+ for _, ctx := range cfg.Contexts {
+ for _, ns := range ctx.Namespaces {
+ for _, fwd := range ns.Forwards {
+ // Get the mDNS alias (explicit or generated from resource name)
+ mdnsAlias := fwd.GetMDNSAlias()
+ if mdnsAlias == "" {
+ // No alias available (e.g., "pod" with selector only)
+ continue
+ }
+
+ // Validate alias is a valid hostname (RFC 1123)
+ if !isValidHostname(mdnsAlias) {
+ errs = append(errs, ValidationError{
+ Field: "alias",
+ Message: fmt.Sprintf("Forward %s has invalid mDNS hostname '%s' (must be a valid RFC 1123 hostname)", fwd.ID(), mdnsAlias),
+ })
+ }
+
+ aliasMap[mdnsAlias] = append(aliasMap[mdnsAlias], fwd.ID())
+ }
+ }
+ }
+
+ // Check for duplicate aliases (would cause mDNS conflicts)
+ for alias, forwards := range aliasMap {
+ if len(forwards) > 1 {
+ errs = append(errs, ValidationError{
+ Field: "alias",
+ Message: fmt.Sprintf("Duplicate mDNS hostname '%s' used by multiple forwards (would cause conflict)", alias),
+ Context: map[string]string{
+ "alias": alias,
+ "forwards": strings.Join(forwards, ", "),
+ },
+ })
+ }
+ }
+
+ return errs
+}
+
+// isValidHostname checks if a string is a valid RFC 1123 hostname.
+// Hostnames must start with alphanumeric, contain only alphanumeric and hyphens,
+// and be 1-63 characters long.
+func isValidHostname(name string) bool {
+ if len(name) == 0 || len(name) > 63 {
+ return false
+ }
+
+ // Must start with alphanumeric
+ if !isAlphanumeric(name[0]) {
+ return false
+ }
+
+ // Must end with alphanumeric
+ if !isAlphanumeric(name[len(name)-1]) {
+ return false
+ }
+
+ // Check all characters
+ for i := 0; i < len(name); i++ {
+ c := name[i]
+ if !isAlphanumeric(c) && c != '-' {
+ return false
+ }
+ }
+
+ return true
+}
+
+// isAlphanumeric returns true if the character is a letter or digit.
+func isAlphanumeric(c byte) bool {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
+}
diff --git a/internal/config/validator_test.go b/internal/config/validator_test.go
index 7569f56..5baadef 100644
--- a/internal/config/validator_test.go
+++ b/internal/config/validator_test.go
@@ -701,3 +701,274 @@ func TestValidator_ValidateStructure(t *testing.T) {
})
}
}
+
+func TestValidator_ValidateMDNS(t *testing.T) {
+ validator := NewValidator()
+
+ tests := []struct {
+ name string
+ config *Config
+ expectErrors bool
+ errorContains []string
+ }{
+ {
+ name: "mDNS disabled - no validation",
+ config: &Config{
+ Contexts: []Context{
+ {
+ Name: "dev",
+ Namespaces: []Namespace{
+ {
+ Name: "default",
+ Forwards: []Forward{
+ {Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "invalid_alias", contextName: "dev", namespaceName: "default"},
+ },
+ },
+ },
+ },
+ },
+ },
+ expectErrors: false,
+ },
+ {
+ name: "mDNS enabled - valid aliases",
+ config: &Config{
+ MDNS: &MDNSSpec{Enabled: true},
+ Contexts: []Context{
+ {
+ Name: "dev",
+ Namespaces: []Namespace{
+ {
+ Name: "default",
+ Forwards: []Forward{
+ {Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "my-app", contextName: "dev", namespaceName: "default"},
+ {Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "my-service", contextName: "dev", namespaceName: "default"},
+ },
+ },
+ },
+ },
+ },
+ },
+ expectErrors: false,
+ },
+ {
+ name: "mDNS enabled - no alias (allowed)",
+ config: &Config{
+ MDNS: &MDNSSpec{Enabled: true},
+ Contexts: []Context{
+ {
+ Name: "dev",
+ Namespaces: []Namespace{
+ {
+ Name: "default",
+ Forwards: []Forward{
+ {Resource: "pod/app", Port: 8080, LocalPort: 8080, contextName: "dev", namespaceName: "default"},
+ },
+ },
+ },
+ },
+ },
+ },
+ expectErrors: false,
+ },
+ {
+ name: "mDNS enabled - invalid alias with underscore",
+ config: &Config{
+ MDNS: &MDNSSpec{Enabled: true},
+ Contexts: []Context{
+ {
+ Name: "dev",
+ Namespaces: []Namespace{
+ {
+ Name: "default",
+ Forwards: []Forward{
+ {Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "my_app", contextName: "dev", namespaceName: "default"},
+ },
+ },
+ },
+ },
+ },
+ },
+ expectErrors: true,
+ errorContains: []string{"invalid mDNS hostname", "RFC 1123"},
+ },
+ {
+ name: "mDNS enabled - alias starts with hyphen",
+ config: &Config{
+ MDNS: &MDNSSpec{Enabled: true},
+ Contexts: []Context{
+ {
+ Name: "dev",
+ Namespaces: []Namespace{
+ {
+ Name: "default",
+ Forwards: []Forward{
+ {Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "-myapp", contextName: "dev", namespaceName: "default"},
+ },
+ },
+ },
+ },
+ },
+ },
+ expectErrors: true,
+ errorContains: []string{"invalid mDNS hostname"},
+ },
+ {
+ name: "mDNS enabled - alias ends with hyphen",
+ config: &Config{
+ MDNS: &MDNSSpec{Enabled: true},
+ Contexts: []Context{
+ {
+ Name: "dev",
+ Namespaces: []Namespace{
+ {
+ Name: "default",
+ Forwards: []Forward{
+ {Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "myapp-", contextName: "dev", namespaceName: "default"},
+ },
+ },
+ },
+ },
+ },
+ },
+ expectErrors: true,
+ errorContains: []string{"invalid mDNS hostname"},
+ },
+ {
+ name: "mDNS enabled - duplicate aliases",
+ config: &Config{
+ MDNS: &MDNSSpec{Enabled: true},
+ Contexts: []Context{
+ {
+ Name: "dev",
+ Namespaces: []Namespace{
+ {
+ Name: "default",
+ Forwards: []Forward{
+ {Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "myapp", contextName: "dev", namespaceName: "default"},
+ {Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "myapp", contextName: "dev", namespaceName: "default"},
+ },
+ },
+ },
+ },
+ },
+ },
+ expectErrors: true,
+ errorContains: []string{"Duplicate mDNS hostname", "conflict"},
+ },
+ {
+ name: "mDNS enabled - duplicate aliases across contexts",
+ config: &Config{
+ MDNS: &MDNSSpec{Enabled: true},
+ Contexts: []Context{
+ {
+ Name: "cluster1",
+ Namespaces: []Namespace{
+ {
+ Name: "default",
+ Forwards: []Forward{
+ {Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "shared-name", contextName: "cluster1", namespaceName: "default"},
+ },
+ },
+ },
+ },
+ {
+ Name: "cluster2",
+ Namespaces: []Namespace{
+ {
+ Name: "default",
+ Forwards: []Forward{
+ {Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "shared-name", contextName: "cluster2", namespaceName: "default"},
+ },
+ },
+ },
+ },
+ },
+ },
+ expectErrors: true,
+ errorContains: []string{"Duplicate mDNS hostname", "shared-name"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ errs := validator.ValidateConfig(tt.config)
+
+ if tt.expectErrors {
+ assert.NotEmpty(t, errs, "expected validation errors")
+
+ // Check that expected error messages are present
+ for _, expectedMsg := range tt.errorContains {
+ found := false
+ for _, err := range errs {
+ if strings.Contains(err.Message, expectedMsg) {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "expected error message '%s' not found in errors: %v", expectedMsg, errs)
+ }
+ } else {
+ assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
+ }
+ })
+ }
+}
+
+func TestIsValidHostname(t *testing.T) {
+ tests := []struct {
+ name string
+ hostname string
+ valid bool
+ }{
+ {"valid simple", "myservice", true},
+ {"valid with hyphen", "my-service", true},
+ {"valid with numbers", "service123", true},
+ {"valid mixed", "my-service-123", true},
+ {"valid uppercase", "MyService", true},
+ {"valid single char", "a", true},
+ {"valid single digit", "1", true},
+ {"valid max length (63)", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
+ {"invalid empty", "", false},
+ {"invalid too long (64)", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
+ {"invalid starts with hyphen", "-myservice", false},
+ {"invalid ends with hyphen", "myservice-", false},
+ {"invalid underscore", "my_service", false},
+ {"invalid dot", "my.service", false},
+ {"invalid space", "my service", false},
+ {"invalid special char", "my@service", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := isValidHostname(tt.hostname)
+ assert.Equal(t, tt.valid, result, "isValidHostname(%q) = %v, want %v", tt.hostname, result, tt.valid)
+ })
+ }
+}
+
+func TestIsAlphanumeric(t *testing.T) {
+ tests := []struct {
+ char byte
+ valid bool
+ }{
+ {'a', true},
+ {'z', true},
+ {'A', true},
+ {'Z', true},
+ {'0', true},
+ {'9', true},
+ {'-', false},
+ {'_', false},
+ {'.', false},
+ {' ', false},
+ {'@', false},
+ }
+
+ for _, tt := range tests {
+ t.Run(string(tt.char), func(t *testing.T) {
+ result := isAlphanumeric(tt.char)
+ assert.Equal(t, tt.valid, result, "isAlphanumeric(%q) = %v, want %v", tt.char, result, tt.valid)
+ })
+ }
+}
diff --git a/internal/forward/manager.go b/internal/forward/manager.go
index 390bef3..87fdcb8 100644
--- a/internal/forward/manager.go
+++ b/internal/forward/manager.go
@@ -10,6 +10,7 @@ import (
"github.com/nvm/kportal/internal/healthcheck"
"github.com/nvm/kportal/internal/k8s"
"github.com/nvm/kportal/internal/logger"
+ "github.com/nvm/kportal/internal/mdns"
)
// StatusUpdater is an interface for updating forward status
@@ -30,6 +31,7 @@ type Manager struct {
portChecker *PortChecker
healthChecker *healthcheck.Checker
watchdog *Watchdog
+ mdnsPublisher *mdns.Publisher
verbose bool
currentConfig *config.Config
statusUI StatusUpdater
@@ -117,6 +119,11 @@ func (m *Manager) SetStatusUI(ui StatusUpdater) {
m.statusUI = ui
}
+// SetMDNSPublisher sets the mDNS publisher for the manager
+func (m *Manager) SetMDNSPublisher(publisher *mdns.Publisher) {
+ m.mdnsPublisher = publisher
+}
+
// Start initializes and starts all port-forwards from the configuration.
func (m *Manager) Start(cfg *config.Config) error {
if cfg == nil {
@@ -186,6 +193,11 @@ func (m *Manager) Stop() {
m.healthChecker.Stop()
m.watchdog.Stop()
+ // Stop mDNS publisher
+ if m.mdnsPublisher != nil {
+ m.mdnsPublisher.Stop()
+ }
+
m.workersMu.Lock()
workers := make([]*ForwardWorker, 0, len(m.workers))
for _, worker := range m.workers {
@@ -391,6 +403,22 @@ func (m *Manager) startWorker(fwd config.Forward) error {
// Store worker
m.workers[fwd.ID()] = worker
+ // Register mDNS hostname if enabled
+ // Uses explicit alias if set, otherwise generates from resource name
+ if m.mdnsPublisher != nil {
+ mdnsAlias := fwd.GetMDNSAlias()
+ if mdnsAlias != "" {
+ if err := m.mdnsPublisher.Register(fwd.ID(), mdnsAlias, fwd.LocalPort); err != nil {
+ logger.Warn("Failed to register mDNS hostname", map[string]interface{}{
+ "forward_id": fwd.ID(),
+ "alias": mdnsAlias,
+ "error": err.Error(),
+ })
+ // Don't fail the forward start - mDNS is optional
+ }
+ }
+ }
+
return nil
}
@@ -414,6 +442,11 @@ func (m *Manager) stopWorkerInternal(id string, removeFromUI bool) error {
m.healthChecker.Unregister(id)
m.watchdog.UnregisterWorker(id)
+ // Unregister mDNS hostname
+ if m.mdnsPublisher != nil {
+ m.mdnsPublisher.Unregister(id)
+ }
+
// Notify UI - either remove or update to disabled status
if m.statusUI != nil {
if removeFromUI {
diff --git a/internal/forward/worker.go b/internal/forward/worker.go
index b4242b2..cccb0b8 100644
--- a/internal/forward/worker.go
+++ b/internal/forward/worker.go
@@ -85,7 +85,16 @@ func (w *ForwardWorker) Start() {
func (w *ForwardWorker) Stop() {
w.cancel()
close(w.stopChan)
- <-w.doneChan // Wait for worker to finish
+
+ // Wait for worker to finish with timeout to prevent blocking forever
+ select {
+ case <-w.doneChan:
+ // Worker finished gracefully
+ case <-time.After(3 * time.Second):
+ // Worker didn't finish in time, but we've cancelled its context
+ // so it will clean up eventually
+ log.Printf("[%s] Worker stop timed out, continuing...", w.forward.ID())
+ }
}
// run is the main worker loop that handles retries.
diff --git a/internal/mdns/publisher.go b/internal/mdns/publisher.go
new file mode 100644
index 0000000..56e2374
--- /dev/null
+++ b/internal/mdns/publisher.go
@@ -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 .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 .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 .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
+}
diff --git a/internal/mdns/publisher_test.go b/internal/mdns/publisher_test.go
new file mode 100644
index 0000000..3935360
--- /dev/null
+++ b/internal/mdns/publisher_test.go
@@ -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())
+}