From 974ea7260f0b76b55c2069a996ba77463e256dfb Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 27 Apr 2026 19:26:23 +0200 Subject: [PATCH] =?UTF-8?q?webui:=20configure=20firewall=20=E2=80=94=20zon?= =?UTF-8?q?e=20and=20policy=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/configure.go | 17 + .../internal/handlers/configure_firewall.go | 732 +++++++++++++++++ src/webui/internal/handlers/firewall.go | 42 +- src/webui/internal/handlers/interfaces.go | 11 +- src/webui/internal/server/server.go | 34 + src/webui/static/css/style.css | 4 + src/webui/static/img/icons/status-tree.svg | 4 + src/webui/static/js/app.js | 13 + .../templates/fragments/yang-leaf-group.html | 68 +- .../templates/fragments/yang-list-table.html | 14 +- .../templates/fragments/yang-node-detail.html | 13 +- .../templates/fragments/yang-tree-node.html | 4 +- src/webui/templates/layouts/sidebar.html | 26 +- .../templates/pages/configure-firewall.html | 747 ++++++++++++++++++ .../templates/pages/configure-keystore.html | 24 +- .../templates/pages/configure-users.html | 14 - src/webui/templates/pages/iface-detail.html | 9 +- src/webui/templates/pages/routing.html | 4 +- src/webui/templates/pages/yang-tree.html | 8 +- 19 files changed, 1719 insertions(+), 69 deletions(-) create mode 100644 src/webui/internal/handlers/configure_firewall.go create mode 100644 src/webui/static/img/icons/status-tree.svg create mode 100644 src/webui/templates/pages/configure-firewall.html diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go index 5f2058b4..0d83e8aa 100644 --- a/src/webui/internal/handlers/configure.go +++ b/src/webui/internal/handlers/configure.go @@ -80,6 +80,23 @@ func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } +// DeleteLeaf removes a single leaf from the candidate datastore so the YANG +// default takes effect. Used by curated-page ↺ reset buttons. +// DELETE /configure/leaf?path=...&redirect=... +func (h *ConfigureHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + redirect := r.URL.Query().Get("redirect") + if path == "" || redirect == "" { + http.Error(w, "path and redirect required", http.StatusBadRequest) + return + } + if err := h.RC.Delete(r.Context(), candidatePath+path); err != nil { + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Reset to default", redirect) +} + // Save copies running → startup, persisting the active configuration. // Clears the cfg-unsaved cookie and does a full-page refresh so the banner disappears. // POST /configure/save diff --git a/src/webui/internal/handlers/configure_firewall.go b/src/webui/internal/handlers/configure_firewall.go new file mode 100644 index 00000000..21c2ec3f --- /dev/null +++ b/src/webui/internal/handlers/configure_firewall.go @@ -0,0 +1,732 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "errors" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/schema" +) + +const fwConfigPath = candidatePath + "/infix-firewall:firewall" + +// ─── RESTCONF read wrappers ─────────────────────────────────────────────────── + +// cfgFwWrapper wraps the firewall presence container for GET responses. +// Using a pointer distinguishes "present" from "absent". +type cfgFwWrapper struct { + Firewall *firewallJSON `json:"infix-firewall:firewall,omitempty"` +} + +// cfgFwZoneWrapper is used when reading a single zone by path. +type cfgFwZoneWrapper struct { + Zone []zoneJSON `json:"infix-firewall:zone"` +} + +// ─── Template display rows ──────────────────────────────────────────────────── + +type cfgZoneRow struct { + zoneJSON + IfaceCount int + IfaceSet map[string]bool + ServiceSet map[string]bool + ServicesTxt string // fallback when ServiceOptions unavailable + NetworksTxt string // comma-separated, shown read-only when zone uses networks +} + +type cfgPolicyRow struct { + policyJSON + IngressDisplay string + EgressDisplay string + MasqDisplay string + IngressSet map[string]bool + EgressSet map[string]bool +} + +type cfgServiceRow struct { + fwServiceJSON + PortsDisplay string // "tcp:80,443; udp:53" — at-a-glance +} + +// cfgFwSvcWrapper is used when reading a single service by path. +type cfgFwSvcWrapper struct { + Service []fwServiceJSON `json:"infix-firewall:service"` +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type cfgFirewallPageData struct { + PageData + Loading bool + Active bool // firewall presence container exists + Enabled bool + Default string + Logging string + Zones []cfgZoneRow + ZoneNames []string // for policy ingress/egress multi-select + Policies []cfgPolicyRow + Services []cfgServiceRow + ProtoOptions []schema.IdentityOption + Desc map[string]string + LoggingOptions []schema.IdentityOption + ActionOptions []schema.IdentityOption + PolicyActionOptions []schema.IdentityOption + ServiceOptions []schema.IdentityOption + AllInterfaces []string + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ConfigureFirewallHandler serves the Configure > Firewall page. +type ConfigureFirewallHandler struct { + Template *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +// Overview renders the Configure > Firewall page. +// GET /configure/firewall +func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgFirewallPageData{ + PageData: newPageData(r, "configure-firewall", "Configure: Firewall"), + } + + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + fwPath := "/infix-firewall:firewall" + zPath := fwPath + "/zone" + pPath := fwPath + "/policy" + sPath := fwPath + "/service" + data.Desc = map[string]string{ + "enabled": schema.DescriptionOf(mgr, fwPath+"/enabled"), + "default": schema.DescriptionOf(mgr, fwPath+"/default"), + "logging": schema.DescriptionOf(mgr, fwPath+"/logging"), + "zone-name": schema.DescriptionOf(mgr, zPath+"/name"), + "zone-action": schema.DescriptionOf(mgr, zPath+"/action"), + "zone-description": schema.DescriptionOf(mgr, zPath+"/description"), + "zone-interface": schema.DescriptionOf(mgr, zPath+"/interface"), + "zone-service": schema.DescriptionOf(mgr, zPath+"/service"), + "policy-name": schema.DescriptionOf(mgr, pPath+"/name"), + "policy-action": schema.DescriptionOf(mgr, pPath+"/action"), + "policy-ingress": schema.DescriptionOf(mgr, pPath+"/ingress"), + "policy-egress": schema.DescriptionOf(mgr, pPath+"/egress"), + "policy-masquerade": schema.DescriptionOf(mgr, pPath+"/masquerade"), + "service-name": schema.DescriptionOf(mgr, sPath+"/name"), + "service-description": schema.DescriptionOf(mgr, sPath+"/description"), + "service-destination": schema.DescriptionOf(mgr, sPath+"/destination"), + "service-port-lower": schema.DescriptionOf(mgr, sPath+"/port/lower"), + "service-port-upper": schema.DescriptionOf(mgr, sPath+"/port/upper"), + "service-port-proto": schema.DescriptionOf(mgr, sPath+"/port/proto"), + } + data.LoggingOptions = schema.OptionsFor(mgr, fwPath+"/logging") + data.ActionOptions = schema.OptionsFor(mgr, zPath+"/action") + data.PolicyActionOptions = schema.OptionsFor(mgr, pPath+"/action") + data.ServiceOptions = schema.OptionsFor(mgr, zPath+"/service") + data.ProtoOptions = schema.OptionsFor(mgr, sPath+"/port/proto") + } + + fw, active, err := h.fetchFirewall(r.Context()) + if err != nil { + log.Printf("configure firewall: %v", err) + data.Error = "Could not read firewall configuration" + } + data.Active = active + if active && fw != nil { + if fw.Enabled == nil { + data.Enabled = true // YANG default + } else { + data.Enabled = bool(*fw.Enabled) + } + data.Default = fw.Default + data.Logging = fw.Logging + for _, z := range fw.Zone { + ifaceSet := make(map[string]bool, len(z.Interface)) + for _, iface := range z.Interface { + ifaceSet[iface] = true + } + svcSet := make(map[string]bool, len(z.Service)) + for _, svc := range z.Service { + svcSet[svc] = true + } + data.Zones = append(data.Zones, cfgZoneRow{ + zoneJSON: z, + IfaceCount: len(z.Interface), + IfaceSet: ifaceSet, + ServiceSet: svcSet, + ServicesTxt: strings.Join(z.Service, "\n"), + NetworksTxt: strings.Join(z.Network, ", "), + }) + data.ZoneNames = append(data.ZoneNames, z.Name) + } + for _, p := range fw.Policy { + masq := "—" + if p.Masquerade { + masq = "Yes" + } + ingressSet := make(map[string]bool, len(p.Ingress)) + for _, z := range p.Ingress { + ingressSet[z] = true + } + egressSet := make(map[string]bool, len(p.Egress)) + for _, z := range p.Egress { + egressSet[z] = true + } + data.Policies = append(data.Policies, cfgPolicyRow{ + policyJSON: p, + IngressDisplay: strings.Join(p.Ingress, ", "), + EgressDisplay: strings.Join(p.Egress, ", "), + MasqDisplay: masq, + IngressSet: ingressSet, + EgressSet: egressSet, + }) + } + for _, s := range fw.Service { + data.Services = append(data.Services, cfgServiceRow{ + fwServiceJSON: s, + PortsDisplay: formatServicePorts(s.Port), + }) + } + // Custom services are leafref'd by the zone service leaf-list — surface + // them in the dropdown alongside the YANG-defined well-known identities + // so the zone editor can pick either. + for _, s := range fw.Service { + data.ServiceOptions = append(data.ServiceOptions, schema.IdentityOption{ + Value: s.Name, + Label: s.Name, + }) + } + } + + data.AllInterfaces = h.fetchInterfaceNames(r.Context()) + + tmplName := "configure-firewall.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) + } +} + +// Enable creates the firewall presence container with an initial "trusted" zone. +// POST /configure/firewall/enable +func (h *ConfigureFirewallHandler) Enable(w http.ResponseWriter, r *http.Request) { + body := map[string]any{ + "infix-firewall:firewall": map[string]any{ + "enabled": true, + "logging": "off", + "default": "trusted", + "zone": []map[string]any{{ + "name": "trusted", + "action": "accept", + }}, + }, + } + if err := h.RC.Put(r.Context(), fwConfigPath, body); err != nil { + log.Printf("configure firewall enable: %v", err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Firewall enabled", "/configure/firewall") +} + +// SaveSettings patches the global firewall settings (enabled, logging, default zone). +// POST /configure/firewall/settings +func (h *ConfigureFirewallHandler) SaveSettings(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + body := map[string]any{ + "infix-firewall:firewall": map[string]any{ + "enabled": r.FormValue("enabled") == "true", + "logging": r.FormValue("logging"), + "default": r.FormValue("default"), + }, + } + if err := h.RC.Patch(r.Context(), fwConfigPath, body); err != nil { + log.Printf("configure firewall settings: %v", err) + renderSaveError(w, err) + return + } + renderSaved(w, "Settings saved") +} + +// AddZone creates a new zone in the firewall candidate. +// POST /configure/firewall/zones +func (h *ConfigureFirewallHandler) AddZone(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("zone name is required")) + return + } + zone := map[string]any{ + "name": name, + "action": r.FormValue("action"), + } + if desc := strings.TrimSpace(r.FormValue("description")); desc != "" { + zone["description"] = desc + } + body := map[string]any{"infix-firewall:zone": []map[string]any{zone}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall zone add %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Zone added", "/configure/firewall") +} + +// DeleteZone removes a zone from the firewall. +// DELETE /configure/firewall/zones/{name} +func (h *ConfigureFirewallHandler) DeleteZone(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name)); err != nil { + log.Printf("configure firewall zone delete %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Zone deleted", "/configure/firewall") +} + +// SaveZone updates a zone's action, description, interfaces, and services. +// Uses read-modify-write to preserve fields not managed by this UI (network, +// port-forward). Note: port-forward entries are lost on save (Phase 3 limitation). +// POST /configure/firewall/zones/{name} +func (h *ConfigureFirewallHandler) SaveZone(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + + var wrap cfgFwZoneWrapper + if err := h.RC.Get(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), &wrap); err != nil { + log.Printf("configure firewall zone save %q: GET: %v", name, err) + renderSaveError(w, err) + return + } + cur := zoneJSON{Name: name} + if len(wrap.Zone) > 0 { + cur = wrap.Zone[0] + } + + cur.Action = r.FormValue("action") + cur.Description = strings.TrimSpace(r.FormValue("description")) + + if len(cur.Network) == 0 { + ifaces := r.Form["interfaces"] + if ifaces == nil { + ifaces = []string{} + } + cur.Interface = ifaces + } + svcs := r.Form["services"] + if svcs == nil { + svcs = []string{} + } + cur.Service = svcs + + zone := map[string]any{ + "name": cur.Name, + "action": cur.Action, + "interface": cur.Interface, + "service": cur.Service, + } + if cur.Description != "" { + zone["description"] = cur.Description + } + if len(cur.Network) > 0 { + zone["network"] = cur.Network + } + body := map[string]any{"infix-firewall:zone": []map[string]any{zone}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall zone save %q: PUT: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Zone saved", "/configure/firewall") +} + +// ResetZoneLeafList clears a leaf-list (interface or service) on a zone by +// re-PUTting the zone container without that field. RFC 8040 leaf-list +// DELETE requires per-entry key predicates, so a bulk clear has to go +// through the parent. +func (h *ConfigureFirewallHandler) resetZoneLeafList(w http.ResponseWriter, r *http.Request, leaf string) { + name := r.PathValue("name") + var wrap cfgFwZoneWrapper + if err := h.RC.Get(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), &wrap); err != nil { + log.Printf("configure firewall zone reset %s/%s: GET: %v", name, leaf, err) + renderSaveError(w, err) + return + } + cur := zoneJSON{Name: name} + if len(wrap.Zone) > 0 { + cur = wrap.Zone[0] + } + switch leaf { + case "interface": + cur.Interface = nil + case "service": + cur.Service = nil + } + + zone := map[string]any{ + "name": cur.Name, + "action": cur.Action, + } + if cur.Description != "" { + zone["description"] = cur.Description + } + if len(cur.Interface) > 0 { + zone["interface"] = cur.Interface + } + if len(cur.Network) > 0 { + zone["network"] = cur.Network + } + if len(cur.Service) > 0 { + zone["service"] = cur.Service + } + body := map[string]any{"infix-firewall:zone": []map[string]any{zone}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/zone="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall zone reset %s/%s: PUT: %v", name, leaf, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Reset to default") +} + +// ResetZoneInterfaces clears the zone's interface leaf-list. +// DELETE /configure/firewall/zones/{name}/interfaces +func (h *ConfigureFirewallHandler) ResetZoneInterfaces(w http.ResponseWriter, r *http.Request) { + h.resetZoneLeafList(w, r, "interface") +} + +// ResetZoneServices clears the zone's service leaf-list. +// DELETE /configure/firewall/zones/{name}/services +func (h *ConfigureFirewallHandler) ResetZoneServices(w http.ResponseWriter, r *http.Request) { + h.resetZoneLeafList(w, r, "service") +} + +// AddPortForward inserts a new DNAT rule into the zone's port-forward list. +// POST /configure/firewall/zones/{name}/port-forwards +func (h *ConfigureFirewallHandler) AddPortForward(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + zone := r.PathValue("name") + lower, err := strconv.Atoi(strings.TrimSpace(r.FormValue("lower"))) + if err != nil || lower < 1 || lower > 65535 { + renderSaveError(w, fmt.Errorf("lower port must be 1–65535")) + return + } + proto := strings.TrimSpace(r.FormValue("proto")) + if proto == "" { + renderSaveError(w, fmt.Errorf("protocol is required")) + return + } + pf := map[string]any{"lower": lower, "proto": proto} + if up := strings.TrimSpace(r.FormValue("upper")); up != "" { + if uv, err := strconv.Atoi(up); err == nil && uv >= lower && uv <= 65535 { + pf["upper"] = uv + } + } + to := map[string]any{} + if addr := strings.TrimSpace(r.FormValue("to-addr")); addr != "" { + to["addr"] = addr + } + if tp := strings.TrimSpace(r.FormValue("to-port")); tp != "" { + if tpv, err := strconv.Atoi(tp); err == nil { + to["port"] = tpv + } + } + if len(to) > 0 { + pf["to"] = to + } + body := map[string]any{"infix-firewall:port-forward": []map[string]any{pf}} + keyPath := fmt.Sprintf("%s/zone=%s/port-forward=%d,%s", fwConfigPath, url.PathEscape(zone), lower, url.PathEscape(proto)) + if err := h.RC.Put(r.Context(), keyPath, body); err != nil { + log.Printf("configure firewall port-forward add %s/%d/%s: %v", zone, lower, proto, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Port-forward added", "/configure/firewall") +} + +// DeletePortForward removes a DNAT rule from the zone. +// DELETE /configure/firewall/zones/{name}/port-forwards/{lower}/{proto} +func (h *ConfigureFirewallHandler) DeletePortForward(w http.ResponseWriter, r *http.Request) { + zone := r.PathValue("name") + lower := r.PathValue("lower") + proto := r.PathValue("proto") + keyPath := fmt.Sprintf("%s/zone=%s/port-forward=%s,%s", fwConfigPath, url.PathEscape(zone), url.PathEscape(lower), url.PathEscape(proto)) + if err := h.RC.Delete(r.Context(), keyPath); err != nil { + log.Printf("configure firewall port-forward delete %s/%s/%s: %v", zone, lower, proto, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Port-forward removed", "/configure/firewall") +} + +// AddPolicy creates a new inter-zone forwarding policy. +// POST /configure/firewall/policies +func (h *ConfigureFirewallHandler) AddPolicy(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + ingress := r.Form["ingress"] + egress := r.Form["egress"] + if name == "" { + renderSaveError(w, fmt.Errorf("policy name is required")) + return + } + if len(ingress) == 0 || len(egress) == 0 { + renderSaveError(w, fmt.Errorf("policy requires at least one ingress and one egress zone")) + return + } + policy := map[string]any{ + "name": name, + "action": r.FormValue("action"), + "ingress": ingress, + "egress": egress, + } + if r.FormValue("masquerade") == "on" { + policy["masquerade"] = true + } + body := map[string]any{"infix-firewall:policy": []map[string]any{policy}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/policy="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall policy add %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Policy added", "/configure/firewall") +} + +// DeletePolicy removes an inter-zone forwarding policy. +// DELETE /configure/firewall/policies/{name} +func (h *ConfigureFirewallHandler) DeletePolicy(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), fwConfigPath+"/policy="+url.PathEscape(name)); err != nil { + log.Printf("configure firewall policy delete %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Policy deleted", "/configure/firewall") +} + +// SavePolicy updates an existing policy's action, ingress, egress, masquerade. +// POST /configure/firewall/policies/{name} +func (h *ConfigureFirewallHandler) SavePolicy(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + ingress := r.Form["ingress"] + egress := r.Form["egress"] + if len(ingress) == 0 || len(egress) == 0 { + renderSaveError(w, fmt.Errorf("policy requires at least one ingress and one egress zone")) + return + } + policy := map[string]any{ + "name": name, + "action": r.FormValue("action"), + "ingress": ingress, + "egress": egress, + "masquerade": r.FormValue("masquerade") == "true", + } + body := map[string]any{"infix-firewall:policy": []map[string]any{policy}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/policy="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall policy save %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Policy saved", "/configure/firewall") +} + +// ─── Services CRUD ─────────────────────────────────────────────────────────── + +// parseServicePorts converts the form's per-port arrays into the YANG body +// shape. Empty rows (no lower port) are silently dropped, so the user can +// leave trailing rows blank without invalidating the submission. +func parseServicePorts(r *http.Request) []map[string]any { + lower := r.Form["port-lower"] + upper := r.Form["port-upper"] + proto := r.Form["port-proto"] + out := []map[string]any{} + for i, lo := range lower { + lo = strings.TrimSpace(lo) + if lo == "" { + continue + } + lower, err := strconv.Atoi(lo) + if err != nil { + continue + } + entry := map[string]any{"lower": lower} + if i < len(proto) && proto[i] != "" { + entry["proto"] = proto[i] + } + if i < len(upper) { + up := strings.TrimSpace(upper[i]) + if up != "" { + if uv, err := strconv.Atoi(up); err == nil { + entry["upper"] = uv + } + } + } + out = append(out, entry) + } + return out +} + +// formatServicePorts renders the port list as a one-line summary for the +// services table — "tcp:80,443-445; udp:53". +func formatServicePorts(ports []fwServicePortJSON) string { + if len(ports) == 0 { + return "" + } + byProto := map[string][]string{} + order := []string{} + for _, p := range ports { + if _, ok := byProto[p.Proto]; !ok { + order = append(order, p.Proto) + } + one := fmt.Sprintf("%d", int(p.Lower)) + if int(p.Upper) > int(p.Lower) { + one = fmt.Sprintf("%d-%d", int(p.Lower), int(p.Upper)) + } + byProto[p.Proto] = append(byProto[p.Proto], one) + } + parts := make([]string, 0, len(order)) + for _, proto := range order { + parts = append(parts, fmt.Sprintf("%s:%s", proto, strings.Join(byProto[proto], ","))) + } + return strings.Join(parts, "; ") +} + +// AddService creates a new user-defined service. +// POST /configure/firewall/services +func (h *ConfigureFirewallHandler) AddService(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("service name is required")) + return + } + svc := map[string]any{"name": name} + if desc := strings.TrimSpace(r.FormValue("description")); desc != "" { + svc["description"] = desc + } + if dest := strings.TrimSpace(r.FormValue("destination")); dest != "" { + svc["destination"] = dest + } + if ports := parseServicePorts(r); len(ports) > 0 { + svc["port"] = ports + } + body := map[string]any{"infix-firewall:service": []map[string]any{svc}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/service="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall service add %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Service added", "/configure/firewall") +} + +// SaveService updates an existing user-defined service. +// POST /configure/firewall/services/{name} +func (h *ConfigureFirewallHandler) SaveService(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + svc := map[string]any{"name": name} + if desc := strings.TrimSpace(r.FormValue("description")); desc != "" { + svc["description"] = desc + } + if dest := strings.TrimSpace(r.FormValue("destination")); dest != "" { + svc["destination"] = dest + } + if ports := parseServicePorts(r); len(ports) > 0 { + svc["port"] = ports + } + body := map[string]any{"infix-firewall:service": []map[string]any{svc}} + if err := h.RC.Put(r.Context(), fwConfigPath+"/service="+url.PathEscape(name), body); err != nil { + log.Printf("configure firewall service save %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Service saved") +} + +// DeleteService removes a user-defined service. +// DELETE /configure/firewall/services/{name} +func (h *ConfigureFirewallHandler) DeleteService(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), fwConfigPath+"/service="+url.PathEscape(name)); err != nil { + log.Printf("configure firewall service delete %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Service deleted", "/configure/firewall") +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// fetchInterfaceNames returns configured interface names from candidate (fallback running). +func (h *ConfigureFirewallHandler) fetchInterfaceNames(ctx context.Context) []string { + var wrap interfacesWrapper + if err := h.RC.Get(ctx, candidatePath+"/ietf-interfaces:interfaces", &wrap); err != nil { + h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &wrap) //nolint:errcheck + } + names := make([]string, 0, len(wrap.Interfaces.Interface)) + for _, iface := range wrap.Interfaces.Interface { + names = append(names, iface.Name) + } + return names +} + +// fetchFirewall reads the firewall presence container from candidate, +// falling back to running. Returns (nil, false, nil) when absent everywhere. +func (h *ConfigureFirewallHandler) fetchFirewall(ctx context.Context) (*firewallJSON, bool, error) { + var wrap cfgFwWrapper + err := h.RC.Get(ctx, fwConfigPath, &wrap) + if err == nil { + return wrap.Firewall, wrap.Firewall != nil, nil + } + var rcErr *restconf.Error + if errors.As(err, &rcErr) && rcErr.StatusCode == http.StatusNotFound { + runErr := h.RC.Get(ctx, "/data/infix-firewall:firewall", &wrap) + if runErr == nil { + return wrap.Firewall, wrap.Firewall != nil, nil + } + var rcRun *restconf.Error + if errors.As(runErr, &rcRun) && rcRun.StatusCode == http.StatusNotFound { + return nil, false, nil + } + return nil, false, runErr + } + return nil, false, err +} diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go index e4332f18..7a069c21 100644 --- a/src/webui/internal/handlers/firewall.go +++ b/src/webui/internal/handlers/firewall.go @@ -20,12 +20,29 @@ type firewallWrapper struct { } type firewallJSON struct { - Enabled *yangBool `json:"enabled"` // YANG default: true; nil means enabled - Default string `json:"default"` - Logging string `json:"logging"` - Lockdown yangBool `json:"lockdown"` - Zone []zoneJSON `json:"zone"` - Policy []policyJSON `json:"policy"` + Enabled *yangBool `json:"enabled"` // YANG default: true; nil means enabled + Default string `json:"default"` + Logging string `json:"logging"` + Lockdown yangBool `json:"lockdown"` + Zone []zoneJSON `json:"zone"` + Policy []policyJSON `json:"policy"` + Service []fwServiceJSON `json:"service"` +} + +// fwServiceJSON models a user-defined firewall service (port + protocol bundle). +// Custom services appear in the zone-service dropdown alongside the YANG-defined +// well-known identities (ssh, http, …). +type fwServiceJSON struct { + Name string `json:"name"` + Description string `json:"description"` + Destination string `json:"destination"` + Port []fwServicePortJSON `json:"port"` +} + +type fwServicePortJSON struct { + Lower yangInt64 `json:"lower"` + Upper yangInt64 `json:"upper"` + Proto string `json:"proto"` } type zoneJSON struct { @@ -40,10 +57,15 @@ type zoneJSON struct { } type portForwardJSON struct { - Port string `json:"port"` - Protocol string `json:"protocol"` - ToAddr string `json:"to-addr"` - ToPort string `json:"to-port"` + Lower yangInt64 `json:"lower"` + Upper yangInt64 `json:"upper"` + Proto string `json:"proto"` + To *portForwardTo `json:"to"` +} + +type portForwardTo struct { + Addr string `json:"addr"` + Port yangInt64 `json:"port"` } type policyJSON struct { diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index 224bd132..9de5b3ec 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -279,6 +279,11 @@ func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) { } } +const ( + ifTypeEthernet = "ethernet" + ifTypeLoopback = "loopback" +) + // prettyIfType converts a YANG interface type identity to the display // name used by the Infix CLI (cli_pretty). func prettyIfType(full string) string { @@ -341,8 +346,8 @@ func buildIfaceList(raw []ifaceJSON, fwdSet map[string]bool) []ifaceEntry { } } sort.Slice(topLevel, func(i, j int) bool { - li := prettyIfType(topLevel[i].Type) == "loopback" - lj := prettyIfType(topLevel[j].Type) == "loopback" + li := prettyIfType(topLevel[i].Type) == ifTypeLoopback + lj := prettyIfType(topLevel[j].Type) == ifTypeLoopback if li != lj { return li } @@ -574,7 +579,7 @@ func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData { } } - if iface.Ethernet != nil { + if iface.Ethernet != nil && prettyIfType(iface.Type) == ifTypeEthernet { d.Speed = prettySpeed(iface.Ethernet.Speed) d.Duplex = iface.Ethernet.Duplex if iface.Ethernet.AutoNegotiation != nil { diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 3606e827..a12acad6 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -116,6 +116,10 @@ func New( if err != nil { return nil, err } + cfgFwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-firewall.html") + if err != nil { + return nil, err + } yangTreeTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", @@ -186,6 +190,7 @@ func New( cfgSys := &handlers.ConfigureSystemHandler{Template: cfgSysTmpl, RC: rc, Schema: schemaCache} cfgUsers := &handlers.ConfigureUsersHandler{Template: cfgUsersTmpl, RC: rc, Schema: schemaCache} cfgRoutes := &handlers.ConfigureRoutesHandler{Template: cfgRoutesTmpl, RC: rc, Schema: schemaCache} + cfgFw := &handlers.ConfigureFirewallHandler{Template: cfgFwTmpl, RC: rc, Schema: schemaCache} schemaH := &handlers.SchemaHandler{Cache: schemaCache} dataH := &handlers.DataHandler{RC: rc, Schema: schemaCache} treeH := &handlers.TreeHandler{ @@ -194,6 +199,13 @@ func New( PageTmpl: yangTreeTmpl, FragTmpl: yangFragTmpl, } + statusTreeH := &handlers.TreeHandler{ + Cache: schemaCache, + RC: rc, + PageTmpl: yangTreeTmpl, + FragTmpl: yangFragTmpl, + ReadOnly: true, + } mux := http.NewServeMux() @@ -247,12 +259,29 @@ func New( mux.HandleFunc("POST /configure/apply-and-save", cfg.ApplyAndSave) mux.HandleFunc("POST /configure/abort", cfg.Abort) mux.HandleFunc("POST /configure/save", cfg.Save) + mux.HandleFunc("DELETE /configure/leaf", cfg.DeleteLeaf) mux.HandleFunc("GET /configure/system", cfgSys.Overview) mux.HandleFunc("POST /configure/system/identity", cfgSys.SaveIdentity) mux.HandleFunc("POST /configure/system/clock", cfgSys.SaveClock) mux.HandleFunc("PUT /configure/system/ntp", cfgSys.SaveNTP) mux.HandleFunc("PUT /configure/system/dns", cfgSys.SaveDNS) mux.HandleFunc("POST /configure/system/preferences", cfgSys.SavePreferences) + mux.HandleFunc("GET /configure/firewall", cfgFw.Overview) + mux.HandleFunc("POST /configure/firewall/enable", cfgFw.Enable) + mux.HandleFunc("POST /configure/firewall/settings", cfgFw.SaveSettings) + mux.HandleFunc("POST /configure/firewall/zones", cfgFw.AddZone) + mux.HandleFunc("POST /configure/firewall/zones/{name}", cfgFw.SaveZone) + mux.HandleFunc("DELETE /configure/firewall/zones/{name}", cfgFw.DeleteZone) + mux.HandleFunc("DELETE /configure/firewall/zones/{name}/interfaces", cfgFw.ResetZoneInterfaces) + mux.HandleFunc("DELETE /configure/firewall/zones/{name}/services", cfgFw.ResetZoneServices) + mux.HandleFunc("POST /configure/firewall/zones/{name}/port-forwards", cfgFw.AddPortForward) + mux.HandleFunc("DELETE /configure/firewall/zones/{name}/port-forwards/{lower}/{proto}", cfgFw.DeletePortForward) + mux.HandleFunc("POST /configure/firewall/policies", cfgFw.AddPolicy) + mux.HandleFunc("POST /configure/firewall/policies/{name}", cfgFw.SavePolicy) + mux.HandleFunc("DELETE /configure/firewall/policies/{name}", cfgFw.DeletePolicy) + mux.HandleFunc("POST /configure/firewall/services", cfgFw.AddService) + mux.HandleFunc("POST /configure/firewall/services/{name}", cfgFw.SaveService) + mux.HandleFunc("DELETE /configure/firewall/services/{name}", cfgFw.DeleteService) mux.HandleFunc("GET /configure/routes", cfgRoutes.Overview) mux.HandleFunc("POST /configure/routes", cfgRoutes.AddRoute) mux.HandleFunc("PUT /configure/routes", cfgRoutes.UpdateRoute) @@ -298,6 +327,11 @@ func New( mux.HandleFunc("DELETE /configure/tree/presence", treeH.TogglePresence) mux.HandleFunc("DELETE /configure/tree/container", treeH.DeleteContainer) + // Status tree (read-only operational view). + mux.HandleFunc("GET /status/tree", statusTreeH.Overview) + mux.HandleFunc("GET /status/tree/children", statusTreeH.TreeChildren) + mux.HandleFunc("GET /status/tree/node", statusTreeH.TreeNode) + handler := authMiddleware(store, mux) handler = csrfMiddleware(handler) handler = securityHeadersMiddleware(handler) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index dae929d3..6b55e888 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2119,6 +2119,10 @@ select.cfg-input { } .cfg-input-sm { max-width: 8rem; } .cfg-input-mono { font-family: var(--font-mono); font-size: 0.85rem; } +.cfg-reset-col { width: 1%; white-space: nowrap; padding-left: 0.25rem; } +.fw-check-scroll { max-height: 12rem; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; padding: 0.4rem 0.6rem; background: var(--input-bg, var(--surface)); } +.fw-check-grid { display: flex; flex-wrap: wrap; gap: 0.35rem 1.25rem; } +.fw-check-grid label { display: flex; align-items: center; gap: 0.35rem; font-weight: normal; cursor: pointer; } /* Editable table rows */ .cfg-table td { padding: 0.45rem 0.75rem; vertical-align: middle; } diff --git a/src/webui/static/img/icons/status-tree.svg b/src/webui/static/img/icons/status-tree.svg new file mode 100644 index 00000000..650ff59a --- /dev/null +++ b/src/webui/static/img/icons/status-tree.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 89b6540b..1c073ca6 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -934,6 +934,19 @@ function openModal(message, onConfirm) { dlg.showModal(); } +// ─── data-close-detail: collapse a key-detail-row from inside ─────────────── +(function() { + document.addEventListener('click', function(e) { + var btn = e.target.closest('[data-close-detail]'); + if (!btn) return; + var target = btn.getAttribute('data-close-detail'); + var row = document.getElementById('key-detail-' + target); + if (row) row.classList.remove('is-open'); + var toggle = document.querySelector('[data-target="' + target + '"]'); + if (toggle) toggle.setAttribute('aria-expanded', 'false'); + }); +})(); + // ─── data-show / data-hide: toggle element visibility ────────────────────── (function() { document.addEventListener('click', function(e) { diff --git a/src/webui/templates/fragments/yang-leaf-group.html b/src/webui/templates/fragments/yang-leaf-group.html index fe01a607..11237016 100644 --- a/src/webui/templates/fragments/yang-leaf-group.html +++ b/src/webui/templates/fragments/yang-leaf-group.html @@ -1,19 +1,21 @@ {{/* Renders a container or list-instance as a level page. - Direct leaves appear as an inline editable form. + Direct leaves appear as an inline editable form (or read-only in ReadOnly mode). Structural children appear as navigation items below. Data: *leafGroupData */}} {{define "yang-leaf-group"}} +{{$ro := .ReadOnly}} +{{$nodeBase := "/configure/tree"}}{{if $ro}}{{$nodeBase = "/status/tree"}}{{end}}
{{if .ParentPath}} {{end}} {{.Name}} - {{if .Presence}} + {{if and .Presence (not $ro)}}
{{if .Exists}} {{end}}
+ {{else if and .Presence $ro}} +
+ + {{if .Exists}}● Enabled{{else}}○ Disabled{{end}} + +
{{end}} {{/* Non-presence container with data: offer "Delete" so the user can wipe out the whole subtree as a unit. Required for the @@ -52,9 +60,11 @@ {{/* ── Direct leaves ────────────────────────────────────────────── */}} {{if .Leaves}} + {{if not $ro}}
+ {{end}}
{{range .Leaves}} {{$cv := .CurrentValue}} @@ -66,8 +76,24 @@ {{.Name}}{{if .Mandatory}}*{{end}} - {{if not .Config}} - {{$cv}} + {{if or $ro (not .Config)}} + {{if and .IsBinary .HasBinary}} +
+
+ binary data + +
+ {{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}} +
+ {{else}} + {{if $cv}}{{$cv}}{{else}}—{{end}} + {{end}} {{else if and .Type (eq .Type.Kind "boolean")}}
+ {{if not $ro}} {{end}} + {{end}} {{/* ── Inline simple sub-lists ────────────────────────────────── */}} {{range $i, $list := .InlineLists}} @@ -150,7 +178,7 @@ {{if $list.Complex}}{{end}} {{range $list.Columns}}{{.DisplayName}}{{end}} - + {{if not $list.ReadOnly}}{{end}} @@ -158,7 +186,7 @@ {{$row := .}} {{if $list.Complex}} @@ -170,6 +198,7 @@ {{end}} {{range $list.Columns}}{{index $row.Values .Name}}{{end}} + {{if not $list.ReadOnly}} + {{end}} {{end}} {{if not $list.Rows}} No entries {{end}} - {{if not $list.Complex}} + {{if and (not $list.Complex) (not $list.ReadOnly)}} {{/* Hidden inline add row — simple lists only */}} @@ -235,6 +265,7 @@
+ {{if not $list.ReadOnly}}
{{if $list.Complex}} {{end}}
+ {{end}}
{{end}} @@ -254,9 +286,11 @@
{{$sub.Name}}
{{if $sub.Leaves}} + {{if not $sub.ReadOnly}}
+ {{end}}
{{range $sub.Leaves}} {{$cv := .CurrentValue}} @@ -268,11 +302,11 @@ {{.Name}}{{if .Mandatory}}*{{end}} - {{if not .Config}} - {{if and .IsBinary $cv}} + {{if or $sub.ReadOnly (not .Config)}} + {{if and .IsBinary .HasBinary}}
- {{if .BinaryHint}}{{.BinaryHint}}{{else}}binary data{{end}} + binary data
- {{$cv}} + {{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}}
{{else}} - {{if $cv}}{{$cv}}{{else}}—{{end}} + {{if $cv}}{{$cv}}{{else}}—{{end}} {{end}} {{else if and .Type (eq .Type.Kind "boolean")}}
@@ -322,7 +356,7 @@ {{if and .UsingDefault .Default}}placeholder="{{.Default}}"{{end}}> {{end}} - {{if and .Config (not .Mandatory)}} + {{if and (not $sub.ReadOnly) .Config (not .Mandatory)}}
+ {{if not $sub.ReadOnly}} {{end}} + {{end}}
{{end}} @@ -364,7 +400,7 @@ {{range .SubNodes}}
+ {{end}}
{{end}} diff --git a/src/webui/templates/fragments/yang-node-detail.html b/src/webui/templates/fragments/yang-node-detail.html index 3c481d49..a5170538 100644 --- a/src/webui/templates/fragments/yang-node-detail.html +++ b/src/webui/templates/fragments/yang-node-detail.html @@ -10,8 +10,17 @@
Saved to candidate
{{end}} - {{/* ── Edit form — only for writable leaves ──────────────────────── */}} - {{if and .Config (or (eq .Kind "leaf") (eq .Kind "leaf-list"))}} + {{/* ── Read-only value display for status tree ──────────────────── */}} + {{if and .ReadOnly (or (eq .Kind "leaf") (eq .Kind "leaf-list"))}} + + {{end}} + + {{/* ── Edit form — only for writable leaves in non-ReadOnly mode ── */}} + {{if and (not .ReadOnly) .Config (or (eq .Kind "leaf") (eq .Kind "leaf-list"))}}
+ + +