From e36aac736e6a539e71bc48522bcc4360aae37b95 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 29 Mar 2026 03:59:44 +0200 Subject: [PATCH] webui: new page, mdns neighbor table Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/mdns.go | 287 +++++++++++++++++++++++ src/webui/internal/server/server.go | 6 + src/webui/static/css/style.css | 53 +++++ src/webui/static/img/icons/mdns.svg | 7 + src/webui/static/js/app.js | 14 ++ src/webui/templates/layouts/sidebar.html | 10 + src/webui/templates/pages/mdns.html | 77 ++++++ 7 files changed, 454 insertions(+) create mode 100644 src/webui/internal/handlers/mdns.go create mode 100644 src/webui/static/img/icons/mdns.svg create mode 100644 src/webui/templates/pages/mdns.html 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 + +
  • 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 Status
    + + + + + + + {{if .Allow}}{{end}} + {{if .Deny}}{{end}} + {{if .Reflector}} + + + + + {{end}} +
    mDNS + + {{.EnabledText}} +
    Domain{{.Domain}}
    Allow{{.Allow}}
    Deny{{.Deny}}
    Reflector Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}}
    +
    + + {{if .Neighbors}} +
    +
    Neighbors
    +
    + + + + + + + + + + + {{range .Neighbors}} + {{$host := .Hostname}} + + + + + + + {{range .ExtraAddrs}} + + + + + + + {{end}} + {{end}} + +
    HostnameAddressLast SeenServices
    {{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}}
    {{.}}
    +
    +
    + {{else if not .Error}} +
    +
    Neighbors
    +

    No mDNS neighbors discovered.

    +
    + {{end}} + +
    +{{end}}