From fc328eb79bf09429e9d1815cb3c7e084cb710f88 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 29 Mar 2026 03:49:36 +0200 Subject: [PATCH] =?UTF-8?q?webui:=20firewall=20=E2=80=94=20zone=20matrix,?= =?UTF-8?q?=20drill-down,=20conditional=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a zone-to-zone traffic matrix matching the CLI's traffic_flow logic: - HOST row/column always present; zone→HOST uses zone action + services (not policies), producing allow/conditional/deny verdicts correctly - Clicking a matrix cell shows a detail panel with the policy or zone reason for the verdict - Conditional (⚠) detection for zones with services or port-forwarding Enrich the policy and zone tables: - Immutable (built-in) policies flagged with ⚷ in column header and row - Policy rows show description as tooltip; sorted by priority - Zones table renamed "Host Services" column; Policies drops Priority column - Wide tables (Zones, Policies) span two grid columns like Dashboard WiFi Fix the info-grid layout: #content lacked width:100% so margin:0 auto was overriding flex stretch, collapsing the grid to a single column on both the firewall and dashboard pages. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/firewall.go | 170 ++++++++++++++---------- src/webui/static/css/style.css | 58 +++++++- src/webui/static/js/app.js | 22 +++ src/webui/templates/pages/firewall.html | 167 ++++++++++++----------- 4 files changed, 266 insertions(+), 151 deletions(-) diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go index dcf03765..e4332f18 100644 --- a/src/webui/internal/handlers/firewall.go +++ b/src/webui/internal/handlers/firewall.go @@ -3,6 +3,7 @@ package handlers import ( + "errors" "html/template" "log" "net/http" @@ -30,6 +31,7 @@ type firewallJSON struct { type zoneJSON struct { Name string `json:"name"` Action string `json:"action"` + Description string `json:"description"` Interface []string `json:"interface"` Network []string `json:"network"` Service []string `json:"service"` @@ -45,30 +47,31 @@ type portForwardJSON struct { } type policyJSON struct { - Name string `json:"name"` - Action string `json:"action"` - Priority yangInt64 `json:"priority"` - Ingress []string `json:"ingress"` - Egress []string `json:"egress"` - Service []string `json:"service"` - Masquerade bool `json:"masquerade"` - Immutable bool `json:"immutable"` + Name string `json:"name"` + Action string `json:"action"` + Description string `json:"description"` + Priority yangInt64 `json:"priority"` + Ingress []string `json:"ingress"` + Egress []string `json:"egress"` + Service []string `json:"service"` + Masquerade bool `json:"masquerade"` + Immutable bool `json:"immutable"` } // Template data structures. type firewallData struct { PageData - Enabled bool - EnabledText string - DefaultZone string - Lockdown bool - Logging string - ZoneNames []string - Matrix []matrixRow - Zones []zoneEntry - Policies []policyEntry - Error string + Enabled bool + EnabledText string + DefaultZone string + Lockdown bool + Logging string + ZoneNames []string + Matrix []matrixRow + Zones []zoneEntry + Policies []policyEntry + Error string } type matrixRow struct { @@ -77,8 +80,12 @@ type matrixRow struct { } type matrixCell struct { - Class string - Symbol string + Class string + Symbol string + Verdict string // "allow", "deny", "conditional", "self" + From string + To string + Detail string // human-readable reason for the drill-down panel } type zoneEntry struct { @@ -86,17 +93,19 @@ type zoneEntry struct { Action string Interfaces string Networks string - Services string + Services string // services allowed to HOST from this zone } type policyEntry struct { - Name string - Action string - Priority int64 - Ingress string - Egress string - Services string - Masquerade bool + Name string + Action string + Priority int64 + Ingress string + Egress string + Services string + Masquerade bool + Immutable bool + Description string } // FirewallHandler serves the firewall overview page. @@ -112,10 +121,18 @@ func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { } var fw firewallWrapper - if err := h.RC.Get(r.Context(), "/data/infix-firewall:firewall", &fw); err != nil { - log.Printf("restconf firewall: %v", err) - data.Error = "Could not fetch firewall configuration" - } else { + err := h.RC.Get(r.Context(), "/data/infix-firewall:firewall", &fw) + if err != nil { + var rcErr *restconf.Error + if errors.As(err, &rcErr) && rcErr.StatusCode == http.StatusNotFound { + // Firewall module not active — show disabled state, not an error. + data.EnabledText = "Inactive" + } else { + log.Printf("restconf firewall: %v", err) + data.Error = "Could not fetch firewall configuration" + } + } + if err == nil { f := fw.Firewall data.Enabled = f.Enabled == nil || bool(*f.Enabled) if data.Enabled { @@ -142,13 +159,15 @@ func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { for _, p := range f.Policy { data.Policies = append(data.Policies, policyEntry{ - Name: p.Name, - Action: p.Action, - Priority: int64(p.Priority), - Ingress: strings.Join(p.Ingress, ", "), - Egress: strings.Join(p.Egress, ", "), - Services: strings.Join(p.Service, ", "), - Masquerade: p.Masquerade, + Name: p.Name, + Action: p.Action, + Priority: int64(p.Priority), + Ingress: strings.Join(p.Ingress, ", "), + Egress: strings.Join(p.Egress, ", "), + Services: strings.Join(p.Service, ", "), + Masquerade: p.Masquerade, + Immutable: p.Immutable, + Description: p.Description, }) } @@ -179,10 +198,10 @@ func buildMatrix(zones []zoneJSON, policies []policyJSON) ([]string, []matrixRow } names := []string{"HOST"} - zoneAction := map[string]string{} + zoneByName := map[string]zoneJSON{} for _, z := range zones { names = append(names, z.Name) - zoneAction[z.Name] = z.Action + zoneByName[z.Name] = z } // Sort policies by priority for evaluation. @@ -196,21 +215,24 @@ func buildMatrix(zones []zoneJSON, policies []policyJSON) ([]string, []matrixRow for i, src := range names { rows[i] = matrixRow{Zone: src, Cells: make([]matrixCell, len(names))} for j, dst := range names { + var cell matrixCell switch { - case src == "HOST" && dst == "HOST": - rows[i].Cells[j] = matrixCell{Class: "matrix-self", Symbol: "\u2014"} - case src == "HOST": - // Traffic originating from the device is always allowed. - rows[i].Cells[j] = matrixCell{Class: "matrix-allow", Symbol: "\u2713"} case src == dst: - rows[i].Cells[j] = matrixCell{Class: "matrix-self", Symbol: "\u2014"} + cell = matrixCell{Class: "matrix-self", Symbol: "—", Verdict: "self"} + case src == "HOST": + // Traffic from the device to any zone is always allowed. + cell = matrixCell{Class: "matrix-allow", Symbol: "✓", Verdict: "allow", + Detail: "HOST can reach all zones"} case dst == "HOST": - // Input to device: governed by zone action + policies. - rows[i].Cells[j] = zoneToHost(src, zoneAction, sorted) + // Input to device: governed by zone action + zone services. + cell = zoneToHost(zoneByName[src]) default: // Forwarding between zones: governed by policies. - rows[i].Cells[j] = evalForward(src, dst, sorted) + cell = evalForward(src, dst, sorted) } + cell.From = src + cell.To = dst + rows[i].Cells[j] = cell } } @@ -218,30 +240,38 @@ func buildMatrix(zones []zoneJSON, policies []policyJSON) ([]string, []matrixRow } // zoneToHost determines traffic flow from a zone to the device (HOST). -// Policies are checked first; if none gives a verdict, the zone's -// default action is used. -func zoneToHost(zone string, zoneAction map[string]string, policies []policyJSON) matrixCell { - if v := evalPolicies(zone, "HOST", policies); v != "" { - return makeCell(v) +// This mirrors the CLI: it is based solely on the zone's action and services, +// not on policies (per cli_pretty.py traffic_flow logic). +func zoneToHost(zone zoneJSON) matrixCell { + if zone.Action == "accept" { + return matrixCell{Class: "matrix-allow", Symbol: "✓", Verdict: "allow", + Detail: "Zone default action: accept"} } - if zoneAction[zone] == "accept" { - return matrixCell{Class: "matrix-allow", Symbol: "\u2713"} + if len(zone.Service) > 0 || len(zone.PortForward) > 0 { + if len(zone.Service) > 0 { + return matrixCell{Class: "matrix-cond", Symbol: "⚠", Verdict: "conditional", + Detail: "Services: " + strings.Join(zone.Service, ", ")} + } + return matrixCell{Class: "matrix-cond", Symbol: "⚠", Verdict: "conditional", + Detail: "Port-forwarding rules apply"} } - return matrixCell{Class: "matrix-deny", Symbol: "\u2717"} + return matrixCell{Class: "matrix-deny", Symbol: "✗", Verdict: "deny", + Detail: "Zone default action: " + zone.Action} } -// evalForward determines traffic flow between two different zones. +// evalForward determines traffic flow between two different zones via policies. func evalForward(src, dst string, policies []policyJSON) matrixCell { - if v := evalPolicies(src, dst, policies); v != "" { - return makeCell(v) + if v, name := evalPolicies(src, dst, policies); v != "" { + return makeCell(v, name) } - return matrixCell{Class: "matrix-deny", Symbol: "\u2717"} + return matrixCell{Class: "matrix-deny", Symbol: "✗", Verdict: "deny", + Detail: "No policy — default deny"} } // evalPolicies walks the sorted policy list and returns the first terminal -// verdict (accept/reject/drop) for traffic from src to dst. +// verdict (accept/reject/drop) for traffic from src to dst, plus the policy name. // "continue" policies are skipped (they don't produce a final verdict). -func evalPolicies(src, dst string, policies []policyJSON) string { +func evalPolicies(src, dst string, policies []policyJSON) (verdict, name string) { for _, p := range policies { if !matchesZone(src, p.Ingress) || !matchesZone(dst, p.Egress) { continue @@ -249,9 +279,9 @@ func evalPolicies(src, dst string, policies []policyJSON) string { if p.Action == "continue" { continue } - return p.Action + return p.Action, p.Name } - return "" + return "", "" } // matchesZone checks whether zone appears in list, treating "ANY" as a wildcard. @@ -264,9 +294,11 @@ func matchesZone(zone string, list []string) bool { return false } -func makeCell(verdict string) matrixCell { +func makeCell(verdict, policyName string) matrixCell { if verdict == "accept" { - return matrixCell{Class: "matrix-allow", Symbol: "\u2713"} + return matrixCell{Class: "matrix-allow", Symbol: "✓", Verdict: "allow", + Detail: "Policy: " + policyName + " (accept)"} } - return matrixCell{Class: "matrix-deny", Symbol: "\u2717"} + return matrixCell{Class: "matrix-deny", Symbol: "✗", Verdict: "deny", + Detail: "Policy: " + policyName + " (" + verdict + ")"} } diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index b9b5637d..a5c527bf 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -198,10 +198,12 @@ a:hover { #content { flex: 1; + width: 100%; padding: 2rem; overflow-y: auto; max-width: 1200px; margin: 0 auto; + box-sizing: border-box; } /* ========================================================================== @@ -953,26 +955,63 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { color: #991b1b; font-weight: 700; } +.matrix-cond { + background: #fef9c3; + color: #854d0e; + font-weight: 700; +} .matrix-self { background: var(--slate-100); color: var(--slate-400); } +/* Clickable cells */ +.matrix-cell[data-verdict] { + cursor: pointer; +} +.matrix-cell[data-verdict]:hover { + filter: brightness(0.93); +} @media (prefers-color-scheme: dark) { .matrix-allow { background: rgba(22, 163, 74, 0.2); color: #86efac; } - .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } - .matrix-self { background: var(--slate-800); color: var(--slate-600); } + .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } + .matrix-cond { background: rgba(234, 179, 8, 0.2); color: #fde047; } + .matrix-self { background: var(--slate-800); color: var(--slate-600); } } .dark .matrix-allow { background: rgba(22, 163, 74, 0.2); color: #86efac; } -.dark .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } -.dark .matrix-self { background: var(--slate-800); color: var(--slate-600); } +.dark .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } +.dark .matrix-cond { background: rgba(234, 179, 8, 0.2); color: #fde047; } +.dark .matrix-self { background: var(--slate-800); color: var(--slate-600); } +.light .matrix-cond { background: #fef9c3; color: #854d0e; } + +/* Flow detail panel — shown below matrix when a cell is clicked. + display:flex overrides HTML [hidden], so we restore that here. */ +.fw-flow-detail { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--surface-alt); + border-top: 1px solid var(--border); + font-size: 0.875rem; +} +.fw-flow-detail[hidden] { display: none !important; } +.fw-detail-flow { font-weight: 600; } +.fw-detail-sep { color: var(--fg-muted); } +.fw-detail-text { color: var(--fg-muted); } + +/* Policy immutable lock icon */ +.policy-lock-col { width: 1.5rem; min-width: 1.5rem; padding: 0 0.25rem !important; text-align: center; } +.policy-lock { color: var(--fg-muted); font-size: 0.85rem; user-select: none; } +.data-table td.policy-lock-col, +.data-table th.policy-lock-col { vertical-align: middle; } .matrix-legend { display: flex; gap: 1.5rem; font-size: 0.85rem; color: var(--fg-muted); - margin-top: 1rem; + padding: 0.75rem 1rem; } .legend-item { @@ -982,10 +1021,15 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { } .legend-swatch { - width: 16px; - height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.25rem; border-radius: 4px; border: 1px solid var(--border); + font-weight: 700; + font-size: 0.75rem; } /* Badges */ diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index e244897f..6f915b2a 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -432,6 +432,28 @@ }); })(); +// Firewall zone matrix cell drill-down +(function() { + var verdictLabel = { allow: '✓ Allow', deny: '✗ Deny', conditional: '⚠ Conditional' }; + + document.addEventListener('click', function(e) { + var td = e.target.closest('.matrix-cell[data-verdict]'); + var panel = document.getElementById('fw-flow-detail'); + if (!panel) return; + + if (!td) { + panel.hidden = true; + return; + } + panel.querySelector('.fw-detail-flow').textContent = + td.getAttribute('data-from') + ' → ' + td.getAttribute('data-to'); + panel.querySelector('.fw-detail-verdict').textContent = + verdictLabel[td.getAttribute('data-verdict')] || td.getAttribute('data-verdict'); + panel.querySelector('.fw-detail-text').textContent = td.getAttribute('data-detail') || ''; + panel.hidden = false; + }); +})(); + // Keystore key detail row toggle (function() { document.addEventListener('click', function(e) { diff --git a/src/webui/templates/pages/firewall.html b/src/webui/templates/pages/firewall.html index 828278c6..701ffee5 100644 --- a/src/webui/templates/pages/firewall.html +++ b/src/webui/templates/pages/firewall.html @@ -8,6 +8,7 @@ {{end}}
+
Firewall Status
@@ -23,100 +24,116 @@ {{end}} - +
Lockdown Mode{{if .Lockdown}}Active{{else}}Inactive{{end}}{{if .Lockdown}}Active{{else}}Inactive{{end}}
Log Denied Traffic{{.Logging}}
-
-{{if .Matrix}} -
-
Zone Matrix
-
- + {{if .Matrix}} +
+
Zone Matrix
+
+
+ + + + {{range .ZoneNames}}{{end}} + + + + {{range .Matrix}} + + + {{range .Cells}} + + {{end}} + + {{end}} + +
{{.}}
{{.Zone}}{{.Symbol}}
+ +
+ Allow + Conditional + Deny +
+
+ + {{end}} + + {{if .Zones}} +
+
Zones
+
+ - - {{range .ZoneNames}}{{end}} + + + + + - {{range .Matrix}} + {{range .Zones}} - - {{range .Cells}}{{end}} + + + + + {{end}}
{{.}}NameActionInterfacesNetworksHost Services
{{.Zone}}{{.Symbol}}{{.Name}}{{.Action}}{{.Interfaces}}{{if .Networks}}{{.Networks}}{{else}}{{end}}{{if .Services}}{{.Services}}{{else}}{{end}}
-
- Allow - Deny
-
-{{end}} + {{end}} -{{if .Zones}} -
-
Zones
-
- - - - - - - - - - - - {{range .Zones}} - - - - - - - - {{end}} - -
NameActionInterfacesNetworksServices
{{.Name}}{{.Action}}{{.Interfaces}}{{.Networks}}{{.Services}}
+ {{if .Policies}} +
+
Policies
+
+ + + + + + + + + + + + + {{range .Policies}} + + + + + + + + + {{end}} + +
NameActionIngressEgressServices
{{if .Immutable}}{{end}}{{.Name}}{{.Action}}{{.Ingress}}{{.Egress}}{{if .Services}}{{.Services}} {{end}}{{if .Masquerade}}masq{{end}}
+
-
-{{end}} + {{end}} -{{if .Policies}} -
-
Policies
-
- - - - - - - - - - - - - {{range .Policies}} - - - - - - - - - {{end}} - -
NamePriorityActionIngressEgressServices
{{.Name}}{{.Priority}}{{.Action}}{{.Ingress}}{{.Egress}}{{.Services}}{{if .Masquerade}} masq{{end}}
-
{{end}} -{{end}}