diff --git a/src/webui/internal/handlers/mdns.go b/src/webui/internal/handlers/mdns.go
new file mode 100644
index 00000000..5cb8ef94
--- /dev/null
+++ b/src/webui/internal/handlers/mdns.go
@@ -0,0 +1,287 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// ─── RESTCONF JSON types ──────────────────────────────────────────────────────
+
+type mdnsWrapper struct {
+ MDNS mdnsJSON `json:"infix-services:mdns"`
+}
+
+type mdnsJSON struct {
+ Enabled *yangBool `json:"enabled"`
+ Domain string `json:"domain"`
+ Hostname string `json:"hostname"`
+ Interfaces mdnsIfacesJSON `json:"interfaces"`
+ Reflector mdnsReflJSON `json:"reflector"`
+ Neighbors mdnsNeighborsJSON `json:"neighbors"`
+}
+
+type mdnsIfacesJSON struct {
+ Allow []string `json:"allow"`
+ Deny []string `json:"deny"`
+}
+
+type mdnsReflJSON struct {
+ Enabled *yangBool `json:"enabled"`
+ ServiceFilter []string `json:"service-filter"`
+}
+
+type mdnsNeighborsJSON struct {
+ Neighbor []mdnsNeighborJSON `json:"neighbor"`
+}
+
+type mdnsNeighborJSON struct {
+ Hostname string `json:"hostname"`
+ Address []string `json:"address"`
+ LastSeen string `json:"last-seen"`
+ Service []mdnsServiceJSON `json:"service"`
+}
+
+type mdnsServiceJSON struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Port uint16 `json:"port"`
+ Txt []string `json:"txt"`
+}
+
+// ─── Template data ────────────────────────────────────────────────────────────
+
+type mdnsPageData struct {
+ PageData
+ Enabled bool
+ EnabledText string
+ Domain string
+ Allow string
+ Deny string
+ Reflector bool
+ SvcFilter string
+ Neighbors []mdnsNeighborEntry
+ Error string
+}
+
+type mdnsNeighborEntry struct {
+ Hostname string
+ PrimaryAddr string
+ ExtraAddrs []string // additional addresses shown in fold-out
+ LastSeen string // HH:MM:SS
+ Services []mdnsSvcEntry
+}
+
+type mdnsSvcEntry struct {
+ Label string // "https", "ssh", etc.
+ Port uint16
+ URL template.URL // non-empty for http/https — safe to use in href
+}
+
+// ─── Handler ─────────────────────────────────────────────────────────────────
+
+// MDNSHandler serves the mDNS overview page.
+type MDNSHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the mDNS page (GET /mdns).
+func (h *MDNSHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ data := mdnsPageData{
+ PageData: newPageData(r, "mdns", "mDNS"),
+ }
+
+ var raw mdnsWrapper
+ if err := h.RC.Get(r.Context(), "/data/infix-services:mdns", &raw); err != nil {
+ log.Printf("restconf mdns: %v", err)
+ data.Error = "Could not fetch mDNS data"
+ } else {
+ m := raw.MDNS
+ data.Enabled = m.Enabled == nil || bool(*m.Enabled)
+ if data.Enabled {
+ data.EnabledText = "Active"
+ } else {
+ data.EnabledText = "Inactive"
+ }
+ data.Domain = m.Domain
+ if data.Domain == "" {
+ data.Domain = "local"
+ }
+ data.Allow = strings.Join(m.Interfaces.Allow, ", ")
+ data.Deny = strings.Join(m.Interfaces.Deny, ", ")
+ data.Reflector = m.Reflector.Enabled != nil && bool(*m.Reflector.Enabled)
+ data.SvcFilter = strings.Join(m.Reflector.ServiceFilter, ", ")
+
+ for _, n := range m.Neighbors.Neighbor {
+ data.Neighbors = append(data.Neighbors, buildMDNSNeighbor(n))
+ }
+ sort.Slice(data.Neighbors, func(i, j int) bool {
+ return data.Neighbors[i].Hostname < data.Neighbors[j].Hostname
+ })
+ }
+
+ tmplName := "mdns.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+func buildMDNSNeighbor(n mdnsNeighborJSON) mdnsNeighborEntry {
+ // mdnsSortAddrs puts IPv4 first, then global IPv6, then link-local — so
+ // addrs[0] is already the best address for URL construction.
+ addrs := mdnsSortAddrs(n.Address)
+
+ primary := ""
+ if len(addrs) > 0 {
+ primary = addrs[0]
+ }
+
+ entry := mdnsNeighborEntry{
+ Hostname: n.Hostname,
+ PrimaryAddr: primary,
+ LastSeen: mdnsLastSeen(n.LastSeen),
+ }
+ if len(addrs) > 1 {
+ entry.ExtraAddrs = addrs[1:]
+ }
+
+ for _, svc := range n.Service {
+ entry.Services = append(entry.Services, buildMDNSSvc(svc, primary, n.Hostname))
+ }
+ return entry
+}
+
+func buildMDNSSvc(svc mdnsServiceJSON, addr, host string) mdnsSvcEntry {
+ meta := mdnsParseTxt(svc.Txt)
+
+ var rawURL string
+ switch svc.Type {
+ case "_http._tcp":
+ if meta.adminurl != "" {
+ rawURL = rebaseURLHost(meta.adminurl, host)
+ } else {
+ rawURL = fmt.Sprintf("http://%s:%d%s", addr, svc.Port, meta.path)
+ }
+ case "_https._tcp":
+ if meta.adminurl != "" {
+ rawURL = rebaseURLHost(meta.adminurl, host)
+ } else {
+ rawURL = fmt.Sprintf("https://%s:%d%s", addr, svc.Port, meta.path)
+ }
+ }
+
+ // Use the DNS-SD instance name for clickable services so that two https
+ // entries on different ports ("Infix NOS" vs "ttyd") are distinguishable.
+ label := mdnsSvcLabel(svc.Type)
+ if rawURL != "" && svc.Name != "" {
+ label = svc.Name
+ }
+
+ return mdnsSvcEntry{
+ Label: label,
+ Port: svc.Port,
+ URL: template.URL(rawURL), // #nosec G203 — URL built from trusted operational data
+ }
+}
+
+// rebaseURLHost rewrites the host of raw to host, keeping scheme, port, and
+// path. It corrects stale adminurl TXT records: avahi resolves hostname
+// conflicts at runtime by appending -2, -3, … but the advertiser writes the
+// adminurl with its configured hostname and never updates it, so a device
+// renamed infix-2.local still advertises adminurl=http://infix.local — which
+// resolves to the *other* device. The mDNS-resolved hostname is authoritative.
+// A no-op when raw is unparseable, hostless, or already points at host.
+func rebaseURLHost(raw, host string) string {
+ if host == "" {
+ return raw
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Host == "" {
+ return raw
+ }
+ if port := u.Port(); port != "" {
+ u.Host = host + ":" + port
+ } else {
+ u.Host = host
+ }
+ return u.String()
+}
+
+// "_https._tcp" → "https", "_netconf-ssh._tcp" → "netconf-ssh"
+func mdnsSvcLabel(stype string) string {
+ s := strings.TrimPrefix(stype, "_")
+ if idx := strings.Index(s, "._"); idx >= 0 {
+ s = s[:idx]
+ }
+ return s
+}
+
+func mdnsSortAddrs(addrs []string) []string {
+ out := make([]string, len(addrs))
+ copy(out, addrs)
+ sort.SliceStable(out, func(i, j int) bool {
+ return mdnsAddrPrio(out[i]) < mdnsAddrPrio(out[j])
+ })
+ return out
+}
+
+func mdnsAddrPrio(a string) int {
+ if !strings.Contains(a, ":") {
+ return 0 // IPv4
+ }
+ if strings.HasPrefix(strings.ToLower(a), "fe80:") {
+ return 2 // link-local IPv6
+ }
+ return 1 // global IPv6
+}
+
+func mdnsLastSeen(ts string) string {
+ if ts == "" {
+ return ""
+ }
+ parts := strings.SplitN(ts, "T", 2)
+ if len(parts) < 2 {
+ return ts
+ }
+ t := parts[1]
+ if len(t) >= 8 {
+ return t[:8]
+ }
+ return t
+}
+
+type mdnsTxtMeta struct {
+ path string
+ adminurl string
+}
+
+func mdnsParseTxt(records []string) mdnsTxtMeta {
+ var m mdnsTxtMeta
+ for _, r := range records {
+ switch {
+ case m.path == "" && strings.HasPrefix(r, "path="):
+ m.path = r[5:]
+ case m.adminurl == "" && strings.HasPrefix(r, "adminurl="):
+ m.adminurl = r[9:]
+ }
+ if m.path != "" && m.adminurl != "" {
+ break
+ }
+ }
+ return m
+}
diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go
index e012bcf8..613cccce 100644
--- a/src/webui/internal/server/server.go
+++ b/src/webui/internal/server/server.go
@@ -77,6 +77,10 @@ func New(
if err != nil {
return nil, err
}
+ mdnsTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/mdns.html")
+ if err != nil {
+ return nil, err
+ }
containersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/containers.html")
if err != nil {
return nil, err
@@ -121,6 +125,7 @@ func New(
dhcp := &handlers.DHCPHandler{Template: dhcpTmpl, RC: rc}
ntp := &handlers.NTPHandler{Template: ntpTmpl, RC: rc}
lldp := &handlers.LLDPHandler{Template: lldpTmpl, RC: rc}
+ mdns := &handlers.MDNSHandler{Template: mdnsTmpl, RC: rc}
containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc}
mux := http.NewServeMux()
@@ -153,6 +158,7 @@ func New(
mux.HandleFunc("GET /dhcp", dhcp.Overview)
mux.HandleFunc("GET /ntp", ntp.Overview)
mux.HandleFunc("GET /lldp", lldp.Overview)
+ mux.HandleFunc("GET /mdns", mdns.Overview)
mux.HandleFunc("GET /containers", containers.Overview)
handler := authMiddleware(store, mux)
diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css
index a5c527bf..d0ccc698 100644
--- a/src/webui/static/css/style.css
+++ b/src/webui/static/css/style.css
@@ -1083,6 +1083,59 @@ details[open].nav-group-top > summary.nav-group-summary-top::after {
.light .badge-info { background: #dbeafe; color: #1e40af; }
.light .badge-neutral { background: var(--slate-100); color: var(--slate-600); }
+/* mDNS service chips — self-contained, no .badge inheritance */
+.mdns-svc {
+ display: inline-block;
+ font-size: 0.75rem;
+ font-weight: 500;
+ padding: 0.15rem 0.45rem;
+ border-radius: var(--radius-sm);
+ white-space: nowrap;
+ background: var(--slate-100);
+ color: var(--slate-600);
+}
+.mdns-svc-link {
+ background: #2563eb;
+ color: #ffffff;
+ text-decoration: none;
+}
+.mdns-svc-link:hover { background: #1d4ed8; color: #ffffff; }
+@media (prefers-color-scheme: dark) {
+ .mdns-svc { background: var(--slate-800); color: var(--slate-400); }
+ .mdns-svc-link { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
+ .mdns-svc-link:hover { background: rgba(59, 130, 246, 0.35); color: #93c5fd; }
+}
+.dark .mdns-svc { background: var(--slate-800); color: var(--slate-400); }
+.dark .mdns-svc-link { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
+.dark .mdns-svc-link:hover { background: rgba(59, 130, 246, 0.35); color: #93c5fd; }
+.light .mdns-svc { background: var(--slate-100); color: var(--slate-600); }
+.light .mdns-svc-link { background: #2563eb; color: #ffffff; }
+.light .mdns-svc-link:hover { background: #1d4ed8; color: #ffffff; }
+
+/* mDNS extra-address rows — hidden until toggle */
+.mdns-extra-row { display: none; }
+.mdns-extra-addr { color: var(--fg-muted); padding-top: 0.1rem; padding-bottom: 0.1rem; }
+
+/* mDNS toggle column and chevron button */
+.mdns-toggle-col { width: 1.5rem; min-width: 1.5rem; padding: 0 0.25rem !important; text-align: center; }
+.data-table td.mdns-toggle-col,
+.data-table th.mdns-toggle-col { vertical-align: middle; }
+.mdns-addr-toggle {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ color: var(--fg-muted);
+ font-size: 0.75rem;
+ line-height: 1;
+}
+.mdns-addr-toggle[aria-expanded="true"] .mdns-addr-arrow {
+ display: inline-block;
+ transform: rotate(90deg);
+}
+
+.mdns-lastseen { white-space: nowrap; }
+
/* Sub-heading within a card body (e.g. "Active Leases" in DHCP) */
.section-subtitle {
font-size: 0.75rem;
diff --git a/src/webui/static/img/icons/mdns.svg b/src/webui/static/img/icons/mdns.svg
new file mode 100644
index 00000000..8f876fdf
--- /dev/null
+++ b/src/webui/static/img/icons/mdns.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js
index 6f915b2a..f33dfb8a 100644
--- a/src/webui/static/js/app.js
+++ b/src/webui/static/js/app.js
@@ -467,6 +467,20 @@
});
})();
+// mDNS extra-address row toggle
+(function() {
+ document.addEventListener('click', function(e) {
+ var btn = e.target.closest('.mdns-addr-toggle');
+ if (!btn) return;
+ var group = btn.getAttribute('data-group');
+ var expanded = btn.getAttribute('aria-expanded') === 'true';
+ btn.setAttribute('aria-expanded', expanded ? 'false' : 'true');
+ document.querySelectorAll('[data-mdns-extra="' + group + '"]').forEach(function(row) {
+ row.style.display = expanded ? '' : 'table-row';
+ });
+ });
+})();
+
// Bridge/LAG member row collapse toggle
(function() {
document.addEventListener('click', function(e) {
diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html
index 425e1fe7..efbe51ed 100644
--- a/src/webui/templates/layouts/sidebar.html
+++ b/src/webui/templates/layouts/sidebar.html
@@ -108,6 +108,16 @@
LLDP
+
+
+
+ mDNS
+
+
Services
diff --git a/src/webui/templates/pages/mdns.html b/src/webui/templates/pages/mdns.html
new file mode 100644
index 00000000..9a6f2407
--- /dev/null
+++ b/src/webui/templates/pages/mdns.html
@@ -0,0 +1,77 @@
+{{define "mdns.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+
+
+
+
+
+
+ | mDNS |
+
+
+ {{.EnabledText}}
+ |
+
+ | Domain | {{.Domain}} |
+ {{if .Allow}}| Allow | {{.Allow}} |
{{end}}
+ {{if .Deny}}| Deny | {{.Deny}} |
{{end}}
+ {{if .Reflector}}
+
+ | Reflector |
+ Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}} |
+
+ {{end}}
+
+
+
+ {{if .Neighbors}}
+
+
+
+
+
+
+ | Hostname |
+ Address |
+ Last Seen |
+ Services |
+
+
+
+ {{range .Neighbors}}
+ {{$host := .Hostname}}
+
+ | {{if .ExtraAddrs}} {{end}}{{.Hostname}} |
+ {{.PrimaryAddr}} |
+ {{.LastSeen}} |
+ {{range $i, $svc := .Services}}{{if $i}} {{end}}{{if $svc.URL}}{{$svc.Label}}{{else}}{{$svc.Label}}({{$svc.Port}}){{end}}{{end}} |
+
+ {{range .ExtraAddrs}}
+
+ {{end}}
+ {{end}}
+
+
+
+
+ {{else if not .Error}}
+
+
+
No mDNS neighbors discovered.
+
+ {{end}}
+
+
+{{end}}