webui: firewall — zone matrix, drill-down, conditional detection

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 <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:32 +02:00
parent 0ac5630be0
commit fc328eb79b
4 changed files with 266 additions and 151 deletions
+101 -69
View File
@@ -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 + ")"}
}
+51 -7
View File
@@ -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 */
+22
View File
@@ -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) {
+92 -75
View File
@@ -8,6 +8,7 @@
{{end}}
<section class="info-grid">
<div class="info-card">
<div class="card-header">Firewall Status</div>
<table class="info-table">
@@ -23,100 +24,116 @@
{{end}}
<tr>
<th>Lockdown Mode</th>
<td>{{if .Lockdown}}Active{{else}}Inactive{{end}}</td>
<td>{{if .Lockdown}}<span class="badge badge-drop">Active</span>{{else}}Inactive{{end}}</td>
</tr>
<tr><th>Log Denied Traffic</th><td>{{.Logging}}</td></tr>
</table>
</div>
</section>
{{if .Matrix}}
<section class="info-card">
<div class="card-header">Zone Matrix</div>
<div class="matrix-wrap">
<table class="zone-matrix">
{{if .Matrix}}
<div class="info-card">
<div class="card-header">Zone Matrix</div>
<div class="matrix-wrap">
<table class="zone-matrix">
<thead>
<tr>
<th class="matrix-corner">&rarr;</th>
{{range .ZoneNames}}<th>{{.}}</th>{{end}}
</tr>
</thead>
<tbody>
{{range .Matrix}}
<tr>
<th>{{.Zone}}</th>
{{range .Cells}}
<td class="matrix-cell {{.Class}}"
{{if ne .Verdict "self"}}
data-from="{{.From}}"
data-to="{{.To}}"
data-verdict="{{.Verdict}}"
data-detail="{{.Detail}}"
{{end}}>{{.Symbol}}</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
<div id="fw-flow-detail" class="fw-flow-detail" hidden>
<span class="fw-detail-flow"></span>
<span class="fw-detail-verdict"></span>
<span class="fw-detail-sep"></span>
<span class="fw-detail-text"></span>
</div>
<div class="matrix-legend">
<span class="legend-item"><span class="legend-swatch matrix-allow"></span> Allow</span>
<span class="legend-item"><span class="legend-swatch matrix-cond"></span> Conditional</span>
<span class="legend-item"><span class="legend-swatch matrix-deny"></span> Deny</span>
</div>
</div>
</div>
{{end}}
{{if .Zones}}
<div class="info-card info-grid-span-2">
<div class="card-header">Zones</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th class="matrix-corner">&rarr;</th>
{{range .ZoneNames}}<th><span class="zone-badge">{{.}}</span></th>{{end}}
<th>Name</th>
<th>Action</th>
<th>Interfaces</th>
<th>Networks</th>
<th>Host Services</th>
</tr>
</thead>
<tbody>
{{range .Matrix}}
{{range .Zones}}
<tr>
<th>{{.Zone}}</th>
{{range .Cells}}<td class="{{.Class}}">{{.Symbol}}</td>{{end}}
<td>{{.Name}}</td>
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
<td>{{.Interfaces}}</td>
<td>{{if .Networks}}{{.Networks}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{if .Services}}{{.Services}}{{else}}<span class="text-muted"></span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
<div class="matrix-legend">
<span class="legend-item"><span class="legend-swatch matrix-allow"></span> Allow</span>
<span class="legend-item"><span class="legend-swatch matrix-deny"></span> Deny</span>
</div>
</div>
</section>
{{end}}
{{end}}
{{if .Zones}}
<section class="info-card">
<div class="card-header">Zones</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Action</th>
<th>Interfaces</th>
<th>Networks</th>
<th>Services</th>
</tr>
</thead>
<tbody>
{{range .Zones}}
<tr>
<td>{{.Name}}</td>
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
<td>{{.Interfaces}}</td>
<td>{{.Networks}}</td>
<td>{{.Services}}</td>
</tr>
{{end}}
</tbody>
</table>
{{if .Policies}}
<div class="info-card info-grid-span-2">
<div class="card-header">Policies</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th class="policy-lock-col" title="Immutable"></th>
<th>Name</th>
<th>Action</th>
<th>Ingress</th>
<th>Egress</th>
<th>Services</th>
</tr>
</thead>
<tbody>
{{range .Policies}}
<tr{{if .Description}} title="{{.Description}}"{{end}}>
<td class="policy-lock-col">{{if .Immutable}}<span class="policy-lock" title="Built-in policy"></span>{{end}}</td>
<td>{{.Name}}</td>
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
<td>{{.Ingress}}</td>
<td>{{.Egress}}</td>
<td>{{if .Services}}{{.Services}} {{end}}{{if .Masquerade}}<span class="badge badge-neutral">masq</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</section>
{{end}}
{{end}}
{{if .Policies}}
<section class="info-card">
<div class="card-header">Policies</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Priority</th>
<th>Action</th>
<th>Ingress</th>
<th>Egress</th>
<th>Services</th>
</tr>
</thead>
<tbody>
{{range .Policies}}
<tr>
<td>{{.Name}}</td>
<td class="num">{{.Priority}}</td>
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
<td>{{.Ingress}}</td>
<td>{{.Egress}}</td>
<td>{{.Services}}{{if .Masquerade}} <span class="badge badge-continue">masq</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
{{end}}