diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py
index b4c5b34e..9a58a7ee 100755
--- a/src/statd/python/cli_pretty/cli_pretty.py
+++ b/src/statd/python/cli_pretty/cli_pretty.py
@@ -4149,6 +4149,7 @@ def show_firewall(json):
# Create tables
zone_table = firewall_zone_table(json)
policy_table = firewall_policy_table(json)
+ address_set_table = firewall_address_set_table(json)
# Add zone table
if zone_table:
@@ -4156,6 +4157,11 @@ def show_firewall(json):
canvas.add_table(zone_table)
canvas.add_spacing()
+ if address_set_table:
+ canvas.add_title("Address Sets")
+ canvas.add_table(address_set_table)
+ canvas.add_spacing()
+
# Add policy table
if policy_table:
canvas.add_title("Policies")
@@ -4447,13 +4453,13 @@ def firewall_matrix(fw, width=None):
zones = fw.get('zone', [])
policies = fw.get('policy', [])
- # Build zone list - include zones with interfaces OR networks
+ # Build zone list - include zones with interfaces, networks, or address-set sources
zone_names = []
for z in zones:
interfaces = z.get('interface', [])
networks = z.get('network', [])
- # Include if zone has interfaces OR networks (non-empty lists)
- if interfaces or networks:
+ address_sets = z.get('address-set', [])
+ if interfaces or networks or address_sets:
zone_names.append(z['name'])
# Always add the implicit HOST zone
@@ -4558,6 +4564,7 @@ def firewall_zone_table(json):
Column('NAME', flexible=True),
Column('TYPE'),
Column('DATA', flexible=True),
+ Column('ADDR SET'),
Column('ALLOWED HOST SERVICES', flexible=True)
])
@@ -4566,6 +4573,7 @@ def firewall_zone_table(json):
action = zone.get('action', 'reject')
interface_list = zone.get('interface', [])
network_list = zone.get('network', [])
+ address_set_list = zone.get('address-set', [])
port_forwards = zone.get('port-forward', [])
services = zone.get('service', [])
@@ -4586,7 +4594,7 @@ def firewall_zone_table(json):
if interface_list:
interfaces = compress_interface_list(interface_list)
config_lines.append(("iif", interfaces))
- else:
+ elif not network_list and not address_set_list and not port_forwards:
config_lines.append(("iif", "(none)"))
# Networks
@@ -4599,14 +4607,21 @@ def firewall_zone_table(json):
pf_display = format_port_forwards(port_forwards)
config_lines.append(("fwd", pf_display))
- # Add first line with zone name and services
- if config_lines:
- first_type, first_data = config_lines[0]
- zone_table.row(locked, name, first_type, first_data, services_display)
+ addr_rows = address_set_list[:] if address_set_list else ["(none)"]
+ row_count = max(len(config_lines), len(addr_rows))
+ if not config_lines:
+ config_lines = [("", "")]
- # Add additional configuration lines as separate rows
- for config_type, config_data in config_lines[1:]:
- zone_table.row('', '', config_type, config_data, '')
+ for i in range(row_count):
+ if i < len(config_lines):
+ config_type, config_data = config_lines[i]
+ else:
+ config_type, config_data = "", ""
+ addr_set = addr_rows[i] if i < len(addr_rows) else ""
+ if i == 0:
+ zone_table.row(locked, name, config_type, config_data, addr_set, services_display)
+ else:
+ zone_table.row('', '', config_type, config_data, addr_set, '')
return zone_table
@@ -4645,6 +4660,9 @@ def show_firewall_zone(json, zone_name=None):
networks = zone.get('network', [])
if not networks:
networks = ""
+ address_sets = zone.get('address-set', [])
+ if not address_sets:
+ address_sets = ""
services = zone.get('service', [])
action = zone.get('action', 'reject')
@@ -4660,6 +4678,7 @@ def show_firewall_zone(json, zone_name=None):
print(f"{'action':<20}: {action}")
print(f"{'interface':<20}: {compress_interface_list(interfaces)}")
print(f"{'networks':<20}: {', '.join(networks)}")
+ print(f"{'address-sets':<20}: {', '.join(address_sets)}")
print(f"{'services (to HOST)':<20}: {services_display}")
# Show port forwards if any
@@ -4928,6 +4947,75 @@ def show_firewall_service(json, name=None):
service_table.print()
+def firewall_address_set_table(json):
+ """Create firewall address-sets table (returns SimpleTable or None)"""
+ fw = json.get('infix-firewall:firewall', {})
+ sets = fw.get('address-set', [])
+
+ if not sets:
+ return None
+
+ set_table = SimpleTable([
+ Column('NAME', flexible=True),
+ Column('FAMILY'),
+ Column('TIMEOUT'),
+ Column('STATIC'),
+ Column('DYNAMIC')
+ ])
+ for aset in sets:
+ current = aset.get('current', [])
+ dynamic = sum(1 for cur in current if cur.get('dynamic'))
+ timeout = aset.get('timeout')
+ set_table.row(aset.get('name', ''),
+ aset.get('family', 'ipv4'),
+ f"{timeout} sec" if timeout else '',
+ str(len(current) - dynamic),
+ str(dynamic))
+ return set_table
+
+
+def show_firewall_address_set(json, name=None):
+ """Show firewall address-sets table or specific set details"""
+ fw = json.get('infix-firewall:firewall', {})
+ sets = fw.get('address-set', [])
+
+ if name:
+ aset = next((s for s in sets if s.get('name') == name), None)
+ if not aset:
+ print(f"Address-set '{name}' not found")
+ return
+
+ timeout = aset.get('timeout')
+ print(format_description('description', aset.get('description', '')))
+ print(f"{'name':<20}: {name}")
+ print(f"{'family':<20}: {aset.get('family', 'ipv4')}")
+ print(f"{'timeout':<20}: {f'{timeout} sec' if timeout else 'none'}")
+ print()
+
+ entry_table = SimpleTable([
+ Column('ENTRY'),
+ Column('TYPE'),
+ Column('EXPIRES')
+ ])
+ for cur in aset.get('current', []):
+ expires = cur.get('expires')
+ entry_table.row(cur.get('entry', ''),
+ 'dynamic' if cur.get('dynamic') else 'static',
+ f"{expires} sec" if expires is not None else '')
+ if entry_table.rows:
+ entry_table.min_width = 56
+ entry_table.print()
+ else:
+ print("(no entries)")
+ else:
+ set_table = firewall_address_set_table(json)
+ if set_table and set_table.rows:
+ set_table.min_width = 72
+ set_table.print()
+ else:
+ print("No address-sets configured")
+
+
def show_ospf(json_data):
"""Show OSPF general instance information"""
routing = json_data.get('ietf-routing:routing', {})
@@ -6018,6 +6106,8 @@ def main():
.add_argument('name', nargs='?', help='Policy name')
subparsers.add_parser('show-firewall-service', help='Show firewall services') \
.add_argument('name', nargs='?', help='Service name')
+ subparsers.add_parser('show-firewall-address-set', help='Show firewall address-sets') \
+ .add_argument('name', nargs='?', help='Address-set name')
subparsers.add_parser('show-firewall-log', help='Show firewall log') \
.add_argument('limit', nargs='?', help='Last N lines, default: all')
@@ -6095,6 +6185,8 @@ def main():
show_firewall_policy(json_data, args.name)
elif args.command == "show-firewall-service":
show_firewall_service(json_data, args.name)
+ elif args.command == "show-firewall-address-set":
+ show_firewall_address_set(json_data, args.name)
elif args.command == "show-firewall-log":
show_firewall_logs(args.limit)
elif args.command == "show-nacm":
diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py
index b06746ae..cb5b4fc3 100644
--- a/src/statd/python/yanger/infix_firewall.py
+++ b/src/statd/python/yanger/infix_firewall.py
@@ -7,8 +7,37 @@ for the full API, see:
--object-path /org/fedoraproject/FirewallD1
"""
import dbus
+import ipaddress
import re
from . import common
+from .host import HOST
+
+SHADOW_DIR = "/run/confd/address-sets"
+
+
+def normalize_entry(entry):
+ """Match firewalld entry normalization: bare address for host entries"""
+ try:
+ net = ipaddress.ip_network(str(entry), strict=False)
+ except ValueError:
+ return str(entry)
+
+ if net.prefixlen == net.max_prefixlen:
+ return str(net.network_address)
+ return str(net)
+
+
+def split_sources(sources):
+ """Zone sources are IP networks or 'ipset:NAME' address-set references"""
+ networks = []
+ ipsets = []
+ for src in sources:
+ src = str(src)
+ if src.startswith("ipset:"):
+ ipsets.append(src[len("ipset:"):])
+ else:
+ networks.append(src)
+ return networks, ipsets
def get_interface(interface="org.fedoraproject.FirewallD1"):
@@ -52,13 +81,15 @@ def get_zone_data(fw, name):
elif not short:
short = ""
+ networks, ipsets = split_sources(settings.get('sources', []))
zone = {
"name": name,
"short": short,
"immutable": immutable,
"description": settings.get('description', ''),
"interface": list(settings.get('interfaces', [])),
- "network": list(settings.get('sources', [])),
+ "network": networks,
+ "address-set": ipsets,
"action": action.get(target, "accept"),
"service": list(settings.get('services', []))
}
@@ -132,8 +163,10 @@ def get_zones(fw):
for name, zone_info in active_zones.items():
zone_data = get_zone_data(fwz, name)
if zone_data:
+ networks, ipsets = split_sources(zone_info.get('sources', []))
zone_data['interface'] = list(zone_info.get('interfaces', []))
- zone_data['network'] = list(zone_info.get('sources', []))
+ zone_data['network'] = networks
+ zone_data['address-set'] = ipsets
zones.append(zone_data)
except Exception as e:
@@ -281,6 +314,100 @@ def get_policies(fw):
return policies
+def nft_set_elems(name):
+ """Live contents of firewalld's nftables set
+
+ The kernel is the only source that sees entries in timeout sets,
+ and the only one tracking per-entry expiry. The firewalld table
+ is owner-protected, but reading is fine.
+ """
+ data = HOST.run_json(("nft", "-j", "list", "set", "inet", "firewalld", name),
+ default={})
+ for obj in data.get("nftables", []):
+ if "set" in obj:
+ return obj["set"].get("elem", [])
+ return []
+
+
+def nft_elem_parse(elem):
+ """Return (entry, expires) from an nft JSON set element"""
+ expires = None
+ if isinstance(elem, dict) and "elem" in elem:
+ expires = elem["elem"].get("expires")
+ elem = elem["elem"].get("val")
+
+ if isinstance(elem, dict) and "prefix" in elem:
+ entry = f"{elem['prefix']['addr']}/{elem['prefix']['len']}"
+ elif isinstance(elem, dict) and "range" in elem:
+ entry = f"{elem['range'][0]}-{elem['range'][1]}"
+ else:
+ entry = str(elem)
+
+ return normalize_entry(entry), expires
+
+
+def get_address_set(fwi, name):
+ try:
+ settings = fwi.getIPSetSettings(name)
+ # (version, short, description, type, options, entries)
+ options = settings[4]
+ tracked = [normalize_entry(e) for e in settings[5]]
+ except Exception as e:
+ common.LOG.warning("Failed querying ipset %s via D-Bus: %s", name, e)
+ return None
+
+ aset = {"name": str(name)}
+
+ description = str(settings[2])
+ if description:
+ aset["description"] = description
+
+ aset["family"] = "ipv6" if options.get("family") == "inet6" else "ipv4"
+
+ timeout = int(options.get("timeout", 0))
+ if timeout:
+ aset["timeout"] = timeout
+
+ lines = HOST.read_multiline(f"{SHADOW_DIR}/{name}", default=[])
+ shadow = {normalize_entry(line) for line in lines if line}
+
+ static = [e for e in tracked if e not in shadow]
+ if static:
+ aset["entry"] = static
+
+ current = []
+ for elem in nft_set_elems(name):
+ entry, expires = nft_elem_parse(elem)
+ cur = {"entry": entry, "dynamic": bool(timeout) or entry in shadow}
+ if expires is not None:
+ cur["expires"] = int(expires)
+ current.append(cur)
+ if current:
+ aset["current"] = current
+
+ return aset
+
+
+def get_address_sets():
+ sets = []
+ fwi = get_interface("org.fedoraproject.FirewallD1.ipset")
+ if not fwi:
+ return sets
+
+ try:
+ names = fwi.getIPSets()
+ except Exception as e:
+ common.LOG.warning("Failed querying ipsets: %s", e)
+ return sets
+
+ for name in names:
+ data = get_address_set(fwi, name)
+ if data:
+ sets.append(data)
+
+ return sets
+
+
def get_service_data(fw, name):
try:
settings = fw.getServiceSettings2(name)
@@ -360,4 +487,8 @@ def operational():
if services:
data["infix-firewall:firewall"]["service"] = services
+ address_sets = get_address_sets()
+ if address_sets:
+ data["infix-firewall:firewall"]["address-set"] = address_sets
+
return data
diff --git a/src/webui/internal/handlers/configure_firewall.go b/src/webui/internal/handlers/configure_firewall.go
index 1f477dfe..72cc1f8b 100644
--- a/src/webui/internal/handlers/configure_firewall.go
+++ b/src/webui/internal/handlers/configure_firewall.go
@@ -38,8 +38,10 @@ type cfgZoneRow struct {
IfaceCount int
IfaceSet map[string]bool
ServiceSet map[string]bool
+ AddrSetSet map[string]bool
ServicesTxt string // fallback when ServiceOptions unavailable
NetworksTxt string // comma-separated, shown read-only when zone uses networks
+ AddrSetsTxt string // comma-separated address-set sources
}
type cfgPolicyRow struct {
@@ -56,6 +58,20 @@ type cfgServiceRow struct {
PortsDisplay string // "tcp:80,443; udp:53" — at-a-glance
}
+type cfgAddrSetRow struct {
+ addressSetJSON
+ EntriesTxt string // one entry per line for the textarea
+}
+
+// toSet builds a membership map for template "index" lookups.
+func toSet(ss []string) map[string]bool {
+ set := make(map[string]bool, len(ss))
+ for _, s := range ss {
+ set[s] = true
+ }
+ return set
+}
+
// cfgFwSvcWrapper is used when reading a single service by path.
type cfgFwSvcWrapper struct {
Service []fwServiceJSON `json:"infix-firewall:service"`
@@ -74,12 +90,15 @@ type cfgFirewallPageData struct {
ZoneNames []string // for policy ingress/egress multi-select
Policies []cfgPolicyRow
Services []cfgServiceRow
+ AddressSets []cfgAddrSetRow
+ AddressSetNames []string // for zone source multi-select
ProtoOptions []schema.IdentityOption
Desc map[string]string
LoggingOptions []schema.IdentityOption
ActionOptions []schema.IdentityOption
PolicyActionOptions []schema.IdentityOption
ServiceOptions []schema.IdentityOption
+ FamilyOptions []schema.IdentityOption
AllInterfaces []string
Error string
}
@@ -107,6 +126,7 @@ func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Reque
zPath := fwPath + "/zone"
pPath := fwPath + "/policy"
sPath := fwPath + "/service"
+ aPath := fwPath + "/address-set"
data.Desc = map[string]string{
"enabled": schema.DescriptionOf(mgr, fwPath+"/enabled"),
"default": schema.DescriptionOf(mgr, fwPath+"/default"),
@@ -127,12 +147,19 @@ func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Reque
"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"),
+ "zone-address-set": schema.DescriptionOf(mgr, zPath+"/address-set"),
+ "addrset-name": schema.DescriptionOf(mgr, aPath+"/name"),
+ "addrset-description": schema.DescriptionOf(mgr, aPath+"/description"),
+ "addrset-family": schema.DescriptionOf(mgr, aPath+"/family"),
+ "addrset-timeout": schema.DescriptionOf(mgr, aPath+"/timeout"),
+ "addrset-entry": schema.DescriptionOf(mgr, aPath+"/entry"),
}
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")
+ data.FamilyOptions = schema.OptionsFor(mgr, aPath+"/family")
}
fw, active, err := h.fetchFirewall(r.Context())
@@ -150,44 +177,45 @@ func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Reque
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,
+ IfaceSet: toSet(z.Interface),
+ ServiceSet: toSet(z.Service),
+ AddrSetSet: toSet(z.AddressSet),
ServicesTxt: strings.Join(z.Service, "\n"),
NetworksTxt: strings.Join(z.Network, ", "),
+ AddrSetsTxt: strings.Join(z.AddressSet, ", "),
})
data.ZoneNames = append(data.ZoneNames, z.Name)
}
+ for _, s := range fw.AddressSet {
+ if s.Family == "" {
+ // Display the schema default when the leaf is unset.
+ for _, opt := range data.FamilyOptions {
+ if opt.IsDefault {
+ s.Family = opt.Value
+ }
+ }
+ }
+ data.AddressSets = append(data.AddressSets, cfgAddrSetRow{
+ addressSetJSON: s,
+ EntriesTxt: strings.Join(s.Entry, "\n"),
+ })
+ data.AddressSetNames = append(data.AddressSetNames, s.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,
+ IngressSet: toSet(p.Ingress),
+ EgressSet: toSet(p.Egress),
})
}
for _, s := range fw.Service {
@@ -303,7 +331,7 @@ func (h *ConfigureFirewallHandler) DeleteZone(w http.ResponseWriter, r *http.Req
renderSavedRedirect(w, "Zone deleted", "/configure/firewall")
}
-// SaveZone updates a zone's action, description, interfaces, and services.
+// SaveZone updates a zone's action, description, interfaces, address-sets, 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}
@@ -340,12 +368,18 @@ func (h *ConfigureFirewallHandler) SaveZone(w http.ResponseWriter, r *http.Reque
svcs = []string{}
}
cur.Service = svcs
+ sets := r.Form["address-sets"]
+ if sets == nil {
+ sets = []string{}
+ }
+ cur.AddressSet = sets
zone := map[string]any{
- "name": cur.Name,
- "action": cur.Action,
- "interface": cur.Interface,
- "service": cur.Service,
+ "name": cur.Name,
+ "action": cur.Action,
+ "interface": cur.Interface,
+ "service": cur.Service,
+ "address-set": cur.AddressSet,
}
if cur.Description != "" {
zone["description"] = cur.Description
@@ -398,6 +432,9 @@ func (h *ConfigureFirewallHandler) resetZoneLeafList(w http.ResponseWriter, r *h
if len(cur.Network) > 0 {
zone["network"] = cur.Network
}
+ if len(cur.AddressSet) > 0 {
+ zone["address-set"] = cur.AddressSet
+ }
if len(cur.Service) > 0 {
zone["service"] = cur.Service
}
@@ -692,6 +729,89 @@ func (h *ConfigureFirewallHandler) DeleteService(w http.ResponseWriter, r *http.
renderSavedRedirect(w, "Service deleted", "/configure/firewall")
}
+// ─── Address-sets CRUD ───────────────────────────────────────────────────────
+
+// parseAddressSet builds the RESTCONF address-set body from the add/save
+// form. Entries come from a textarea, one address or prefix per line.
+func parseAddressSet(r *http.Request, name string) (map[string]any, error) {
+ set := map[string]any{"name": name}
+ if desc := strings.TrimSpace(r.FormValue("description")); desc != "" {
+ set["description"] = desc
+ }
+ if fam := r.FormValue("family"); fam != "" {
+ set["family"] = fam
+ }
+ if t := strings.TrimSpace(r.FormValue("timeout")); t != "" {
+ tv, err := strconv.Atoi(t)
+ if err != nil || tv < 1 {
+ return nil, fmt.Errorf("timeout must be a positive number of seconds")
+ }
+ set["timeout"] = tv
+ }
+ entries := []string{}
+ for _, line := range strings.Split(r.FormValue("entries"), "\n") {
+ if line = strings.TrimSpace(line); line != "" {
+ entries = append(entries, line)
+ }
+ }
+ if len(entries) > 0 {
+ set["entry"] = entries
+ }
+ return set, nil
+}
+
+func (h *ConfigureFirewallHandler) putAddressSet(w http.ResponseWriter, r *http.Request, name, saved string) {
+ set, err := parseAddressSet(r, name)
+ if err != nil {
+ renderSaveError(w, err)
+ return
+ }
+ body := map[string]any{"infix-firewall:address-set": []map[string]any{set}}
+ if err := h.RC.Put(r.Context(), fwConfigPath+"/address-set="+restconf.EscapeKey(name), body); err != nil {
+ log.Printf("configure firewall address-set save %q: %v", name, err)
+ renderSaveError(w, err)
+ return
+ }
+ renderSavedRedirect(w, saved, "/configure/firewall")
+}
+
+// AddAddressSet creates a new address-set.
+// POST /configure/firewall/address-sets
+func (h *ConfigureFirewallHandler) AddAddressSet(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("address-set name is required"))
+ return
+ }
+ h.putAddressSet(w, r, name, "Address-set added")
+}
+
+// SaveAddressSet updates an existing address-set.
+// POST /configure/firewall/address-sets/{name}
+func (h *ConfigureFirewallHandler) SaveAddressSet(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ h.putAddressSet(w, r, r.PathValue("name"), "Address-set saved")
+}
+
+// DeleteAddressSet removes an address-set.
+// DELETE /configure/firewall/address-sets/{name}
+func (h *ConfigureFirewallHandler) DeleteAddressSet(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ if err := h.RC.Delete(r.Context(), fwConfigPath+"/address-set="+restconf.EscapeKey(name)); err != nil {
+ log.Printf("configure firewall address-set delete %q: %v", name, err)
+ renderSaveError(w, err)
+ return
+ }
+ renderSavedRedirect(w, "Address-set deleted", "/configure/firewall")
+}
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
// fetchInterfaceNames returns configured interface names from candidate (fallback running).
diff --git a/src/webui/internal/handlers/configure_firewall_test.go b/src/webui/internal/handlers/configure_firewall_test.go
new file mode 100644
index 00000000..518fecd2
--- /dev/null
+++ b/src/webui/internal/handlers/configure_firewall_test.go
@@ -0,0 +1,163 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "strings"
+ "testing"
+
+ "infix/webui/internal/restconf"
+ "infix/webui/internal/schema"
+ "infix/webui/internal/security"
+ "infix/webui/internal/testutil"
+)
+
+var minimalCfgFwTmpl = template.Must(template.New("configure-firewall.html").Parse(
+ `{{define "configure-firewall.html"}}{{template "content" .}}{{end}}` +
+ `{{define "content"}}sets={{len .AddressSets}}` +
+ `{{range .AddressSets}};{{.Name}}:{{.EntriesTxt}}:{{if .Timeout}}{{.Timeout}}{{end}}{{end}}` +
+ `{{range .Zones}};zone-{{.Name}}:{{.AddrSetsTxt}}{{end}}{{end}}`,
+))
+
+func TestConfigureFirewallOverview_AddressSets(t *testing.T) {
+ mock := testutil.NewMockFetcher()
+ mock.SetResponse(candidatePath+"/infix-firewall:firewall", map[string]any{
+ "infix-firewall:firewall": map[string]any{
+ "default": "trusted",
+ "zone": []map[string]any{{
+ "name": "trusted",
+ "action": "accept",
+ "address-set": []string{"allowed"},
+ }},
+ "address-set": []map[string]any{{
+ "name": "allowed",
+ "entry": []string{"192.168.1.40", "10.0.0.0/24"},
+ }, {
+ "name": "banned",
+ "timeout": 3600,
+ }},
+ },
+ })
+
+ h := &ConfigureFirewallHandler{
+ Template: minimalCfgFwTmpl,
+ RC: mock,
+ Schema: schema.NewCache(mock, t.TempDir()),
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/configure/firewall", nil)
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ for _, want := range []string{
+ "sets=2",
+ ";allowed:192.168.1.40\n10.0.0.0/24:",
+ ";banned::3600",
+ ";zone-trusted:allowed",
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("body missing %q; body: %s", want, body)
+ }
+ }
+}
+
+type recordingFetcher struct {
+ *testutil.MockFetcher
+ putCalls int
+ lastPath string
+ lastBody any
+}
+
+func (r *recordingFetcher) Put(_ context.Context, path string, body any) error {
+ r.putCalls++
+ r.lastPath = path
+ r.lastBody = body
+ return nil
+}
+
+func TestConfigureFirewallSaveZoneAllowsInterfacesWithAddressSets(t *testing.T) {
+ mock := &recordingFetcher{MockFetcher: testutil.NewMockFetcher()}
+ mock.SetResponse(candidatePath+"/infix-firewall:firewall/zone=public", map[string]any{
+ "infix-firewall:zone": []map[string]any{{
+ "name": "public",
+ "action": "drop",
+ "interface": []string{"eth0"},
+ }},
+ })
+
+ h := &ConfigureFirewallHandler{
+ Template: minimalCfgFwTmpl,
+ RC: mock,
+ Schema: schema.NewCache(mock, t.TempDir()),
+ }
+
+ form := url.Values{
+ "action": {"drop"},
+ "description": {"Public zone"},
+ "interfaces": {"eth0"},
+ "address-sets": {"allowed"},
+ }
+ req := httptest.NewRequest(http.MethodPost, "/configure/firewall/zones/public", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetPathValue("name", "public")
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.SaveZone(w, req)
+
+ if mock.putCalls != 1 {
+ t.Fatalf("want 1 PUT call got %d", mock.putCalls)
+ }
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("want 204 got %d; body: %s", w.Code, w.Body.String())
+ }
+ if got, want := mock.lastPath, candidatePath+"/infix-firewall:firewall/zone=public"; got != want {
+ t.Fatalf("want PUT path %q got %q", want, got)
+ }
+ body, ok := mock.lastBody.(map[string]any)
+ if !ok {
+ t.Fatalf("unexpected PUT body type %T", mock.lastBody)
+ }
+ zones, ok := body["infix-firewall:zone"].([]map[string]any)
+ if !ok || len(zones) != 1 {
+ t.Fatalf("unexpected PUT zone payload %#v", body["infix-firewall:zone"])
+ }
+ zone := zones[0]
+ if got, want := zone["interface"], []string{"eth0"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("want interfaces %#v got %#v", want, got)
+ }
+ if got, want := zone["address-set"], []string{"allowed"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("want address-sets %#v got %#v", want, got)
+ }
+ var trig map[string]string
+ if err := json.Unmarshal([]byte(w.Header().Get("HX-Trigger")), &trig); err != nil {
+ t.Fatalf("unmarshal HX-Trigger: %v", err)
+ }
+ if got := trig["cfgSaved"]; !strings.Contains(got, "Zone saved") {
+ t.Fatalf("unexpected success message %q", got)
+ }
+}
diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go
index 9d2737ac..c4abc17c 100644
--- a/src/webui/internal/handlers/firewall.go
+++ b/src/webui/internal/handlers/firewall.go
@@ -19,13 +19,32 @@ 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"`
- Service []fwServiceJSON `json:"service"`
+ 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"`
+ AddressSet []addressSetJSON `json:"address-set"`
+}
+
+// addressSetJSON models a named set of IP addresses/networks usable as zone
+// source. The current list is operational state: the live set contents,
+// including dynamic entries added at runtime.
+type addressSetJSON struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Family string `json:"family"`
+ Timeout yangInt64 `json:"timeout"`
+ Entry []string `json:"entry"`
+ Current []addrSetCurJSON `json:"current"`
+}
+
+type addrSetCurJSON struct {
+ Entry string `json:"entry"`
+ Dynamic bool `json:"dynamic"`
+ Expires *yangInt64 `json:"expires"`
}
// fwServiceJSON models a user-defined firewall service (port + protocol bundle).
@@ -50,6 +69,7 @@ type zoneJSON struct {
Description string `json:"description"`
Interface []string `json:"interface"`
Network []string `json:"network"`
+ AddressSet []string `json:"address-set"`
Service []string `json:"service"`
PortForward []portForwardJSON `json:"port-forward"`
Immutable bool `json:"immutable"`
@@ -92,6 +112,7 @@ type firewallData struct {
Matrix []matrixRow
Zones []zoneEntry
Policies []policyEntry
+ AddressSets []addressSetEntry
Error string
}
@@ -110,11 +131,25 @@ type matrixCell struct {
}
type zoneEntry struct {
- Name string
- Action string
- Interfaces string
- Networks string
- Services string // services allowed to HOST from this zone
+ Name string
+ Action string
+ Interfaces string
+ Networks string
+ AddressSets string
+ Services string // services allowed to HOST from this zone
+}
+
+type addressSetEntry struct {
+ Name string
+ Family string
+ Timeout string // "3600 s" for expiring sets, "" otherwise
+ Entries []addressSetEntryRow
+}
+
+type addressSetEntryRow struct {
+ Entry string
+ Dynamic bool
+ Expires string // remaining lifetime, "" when not expiring
}
type policyEntry struct {
@@ -169,14 +204,39 @@ func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) {
for _, z := range f.Zone {
data.Zones = append(data.Zones, zoneEntry{
- Name: z.Name,
- Action: z.Action,
- Interfaces: strings.Join(z.Interface, ", "),
- Networks: strings.Join(z.Network, ", "),
- Services: strings.Join(z.Service, ", "),
+ Name: z.Name,
+ Action: z.Action,
+ Interfaces: strings.Join(z.Interface, ", "),
+ Networks: strings.Join(z.Network, ", "),
+ AddressSets: strings.Join(z.AddressSet, ", "),
+ Services: strings.Join(z.Service, ", "),
})
}
+ for _, s := range f.AddressSet {
+ set := addressSetEntry{
+ Name: s.Name,
+ Family: s.Family,
+ }
+ if set.Family == "" {
+ set.Family = "ipv4" // YANG default
+ }
+ if s.Timeout > 0 {
+ set.Timeout = formatDuration(int64(s.Timeout))
+ }
+ for _, cur := range s.Current {
+ row := addressSetEntryRow{
+ Entry: cur.Entry,
+ Dynamic: cur.Dynamic,
+ }
+ if cur.Expires != nil {
+ row.Expires = formatDuration(int64(*cur.Expires))
+ }
+ set.Entries = append(set.Entries, row)
+ }
+ data.AddressSets = append(data.AddressSets, set)
+ }
+
for _, p := range f.Policy {
data.Policies = append(data.Policies, policyEntry{
Name: p.Name,
diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go
index c8f20ef3..b02bc8a5 100644
--- a/src/webui/internal/server/server.go
+++ b/src/webui/internal/server/server.go
@@ -424,6 +424,9 @@ func New(
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("POST /configure/firewall/address-sets", cfgFw.AddAddressSet)
+ mux.HandleFunc("POST /configure/firewall/address-sets/{name}", cfgFw.SaveAddressSet)
+ mux.HandleFunc("DELETE /configure/firewall/address-sets/{name}", cfgFw.DeleteAddressSet)
mux.HandleFunc("GET /configure/hardware", cfgHw.Overview)
mux.HandleFunc("POST /configure/hardware", cfgHw.CreateHardware)
mux.HandleFunc("POST /configure/hardware/usb/{name}", cfgHw.SaveUSBPort)
diff --git a/src/webui/templates/pages/configure-firewall.html b/src/webui/templates/pages/configure-firewall.html
index 011798bb..234cf73e 100644
--- a/src/webui/templates/pages/configure-firewall.html
+++ b/src/webui/templates/pages/configure-firewall.html
@@ -112,6 +112,7 @@
Action{{template "field-info" (index $d "zone-action")}}
Description{{template "field-info" (index $d "zone-description")}}
Interfaces
+ Address sets{{template "field-info" (index $d "zone-address-set")}}
@@ -128,6 +129,7 @@
{{if .Action}}{{.Action}}{{else}}reject{{end}}
{{if .Description}}{{.Description}}{{else}}— {{end}}
{{.IfaceCount}}
+ {{if .AddressSet}}{{.AddrSetsTxt}}{{else}}— {{end}}
{{if not .Immutable}}
-
+
{{if .Immutable}}
This zone is system-defined and cannot be modified.
{{if .Interface}}
Interfaces: {{range $j, $iface := .Interface}}{{if $j}}, {{end}}{{$iface}}{{end}}
{{end}}
{{if .Network}}
Networks: {{.NetworksTxt}}
{{end}}
+ {{if .AddressSet}}
Address sets: {{.AddrSetsTxt}}
{{end}}
{{if .Service}}
Services: {{.ServicesTxt}}
{{end}}
{{else}}
{{end}}
+ {{if $.AddressSetNames}}
+
+ Address sets{{template "field-info" (index $d "zone-address-set")}}
+
+
+ {{if .AddressSet}}{{.AddrSetsTxt}}{{else}}(None){{end}}
+
+ {{range $.AddressSetNames}}
+
+
+ {{.}}
+
+ {{end}}
+
+
+
+
+ {{end}}
Services{{template "field-info" (index $d "zone-service")}}
@@ -723,6 +744,160 @@
+ {{/* ── Address Sets ─────────────────────────────────────────────────── */}}
+
+
+
+ Named sets of IP addresses and networks, usable as zone sources.
+ Entries configured here are static; dynamic entries can be added at
+ runtime from the CLI or over NETCONF/RESTCONF, see the Firewall
+ status page for the live contents.
+
+
+
+
+
+ Name{{template "field-info" (index $d "addrset-name")}}
+ Description{{template "field-info" (index $d "addrset-description")}}
+ Family{{template "field-info" (index $d "addrset-family")}}
+ Timeout{{template "field-info" (index $d "addrset-timeout")}}
+ Entries
+
+
+
+
+ {{range $i, $s := .AddressSets}}
+
+
+
+ ▶ {{.Name}}
+
+
+ {{if .Description}}{{.Description}}{{else}}— {{end}}
+ {{.Family}}
+ {{if .Timeout}}{{.Timeout}} s{{else}}— {{end}}
+ {{len .Entry}}
+
+
+ {{template "icon-trash"}}
+
+
+
+
+
+
+
+
+ {{end}}
+
+ {{if not .AddressSets}}
+ No address-sets configured
+ {{end}}
+
+ {{/* Hidden add-address-set row */}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{end}}
@@ -731,4 +906,3 @@
{{end}}{{/* end {{else}} — schema ready */}}
{{end}}
-
diff --git a/src/webui/templates/pages/firewall.html b/src/webui/templates/pages/firewall.html
index 41701d20..2ae2f87d 100644
--- a/src/webui/templates/pages/firewall.html
+++ b/src/webui/templates/pages/firewall.html
@@ -90,6 +90,7 @@
Action
Interfaces
Networks
+ Address Sets
Host Services
@@ -100,6 +101,7 @@
{{.Action}}
{{.Interfaces}}
{{if .Networks}}{{.Networks}}{{else}}— {{end}}
+ {{if .AddressSets}}{{.AddressSets}}{{else}}— {{end}}
{{if .Services}}{{.Services}}{{else}}— {{end}}
{{end}}
@@ -109,6 +111,40 @@
{{end}}
+ {{if .AddressSets}}
+
+
+
+
+
+
+ Name
+ Family
+ Timeout
+ Entries
+
+
+
+ {{range .AddressSets}}
+
+ {{.Name}}
+ {{.Family}}
+ {{if .Timeout}}{{.Timeout}}{{else}}— {{end}}
+
+ {{if .Entries}}
+ {{range $i, $e := .Entries}}{{if $i}}, {{end}}{{.Entry}}{{if .Dynamic}} dyn{{if .Expires}} {{.Expires}}{{end}} {{end}}{{end}}
+ {{else}}
+ (empty)
+ {{end}}
+
+
+ {{end}}
+
+
+
+
+ {{end}}
+
{{if .Policies}}
diff --git a/test/case/cli_pretty/all.yaml b/test/case/cli_pretty/all.yaml
index 64de1bf6..b56be0ee 100644
--- a/test/case/cli_pretty/all.yaml
+++ b/test/case/cli_pretty/all.yaml
@@ -37,3 +37,6 @@
- "json/bloated.json"
- "show-interfaces"
- "-n br0"
+
+- case: firewall_overview.sh
+ name: "firewall-overview"
diff --git a/test/case/cli_pretty/firewall_overview.sh b/test/case/cli_pretty/firewall_overview.sh
new file mode 100755
index 00000000..ad12a345
--- /dev/null
+++ b/test/case/cli_pretty/firewall_overview.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+SCRIPT_PATH="$(dirname "$(readlink -f "$0")")"
+JSON="$SCRIPT_PATH/json/firewall-overview.json"
+CLI="$SCRIPT_PATH/../../../src/statd/python/cli_pretty/cli_pretty.py"
+
+strip_ansi() {
+ sed 's/\x1b\[[0-9;]*m//g'
+}
+
+echo "1..2"
+
+OUT1="$(cat "$JSON" | "$CLI" show-firewall | strip_ansi)"
+if printf '%s\n' "$OUT1" | grep -q "Address Sets" &&
+ printf '%s\n' "$OUT1" | grep -q "allowed" &&
+ printf '%s\n' "$OUT1" | grep -q "greylist" &&
+ printf '%s\n' "$OUT1" | grep -q "ADDR SET" &&
+ printf '%s\n' "$OUT1" | grep -q "trusted" &&
+ printf '%s\n' "$OUT1" | grep -q "greylist" ; then
+ echo "ok 1 - show-firewall includes address-set summaries"
+else
+ echo "not ok 1 - show-firewall missing address-set overview"
+ exit 1
+fi
+
+OUT2="$(cat "$JSON" | "$CLI" show-firewall-zone trusted | strip_ansi)"
+if printf '%s\n' "$OUT2" | grep -q "address-sets" &&
+ printf '%s\n' "$OUT2" | grep -q "allowed, greylist" ; then
+ echo "ok 2 - show-firewall-zone shows zone address-sets"
+ exit 0
+fi
+
+echo "not ok 2 - show-firewall-zone missing zone address-sets"
+exit 1
diff --git a/test/case/cli_pretty/json/firewall-overview.json b/test/case/cli_pretty/json/firewall-overview.json
new file mode 100644
index 00000000..6ce93921
--- /dev/null
+++ b/test/case/cli_pretty/json/firewall-overview.json
@@ -0,0 +1,61 @@
+{
+ "infix-firewall:firewall": {
+ "default": "public",
+ "logging": "all",
+ "zone": [
+ {
+ "name": "mgmt",
+ "action": "accept",
+ "interface": ["e0"],
+ "service": ["ssh", "netconf", "restconf"]
+ },
+ {
+ "name": "public",
+ "action": "drop",
+ "interface": ["e1"]
+ },
+ {
+ "name": "trusted",
+ "action": "accept",
+ "address-set": ["allowed", "greylist"]
+ }
+ ],
+ "address-set": [
+ {
+ "name": "allowed",
+ "family": "ipv4",
+ "current": [
+ {
+ "entry": "192.168.1.40",
+ "dynamic": false
+ },
+ {
+ "entry": "192.168.1.50",
+ "dynamic": true
+ }
+ ]
+ },
+ {
+ "name": "greylist",
+ "family": "ipv4",
+ "timeout": 10,
+ "current": [
+ {
+ "entry": "192.168.1.60",
+ "dynamic": true,
+ "expires": 7
+ }
+ ]
+ }
+ ],
+ "policy": [
+ {
+ "name": "public-to-mgmt",
+ "action": "reject",
+ "priority": 100,
+ "ingress": ["public"],
+ "egress": ["mgmt"]
+ }
+ ]
+ }
+}
diff --git a/test/case/firewall/address-set/Readme.adoc b/test/case/firewall/address-set/Readme.adoc
new file mode 120000
index 00000000..ae32c841
--- /dev/null
+++ b/test/case/firewall/address-set/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/firewall/address-set/test.adoc b/test/case/firewall/address-set/test.adoc
new file mode 100644
index 00000000..472014d4
--- /dev/null
+++ b/test/case/firewall/address-set/test.adoc
@@ -0,0 +1,39 @@
+=== Firewall Address-Sets with Dynamic Entries
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/firewall/address-set]
+
+==== Description
+
+Verifies firewall address-sets used as zone sources, in a setup where
+all traffic from the data network is dropped by default and end devices
+are granted access per-IP at runtime.
+
+image::topology.svg[align=center, scaledwidth=50%]
+
+- The default zone "public" drops all traffic on the data interface
+- The "trusted" zone accepts traffic from members of the "allowed"
+ address-set: one static entry from the configuration, and dynamic
+ entries managed at runtime with the add/remove/flush actions
+- Dynamic entries must survive unrelated firewall configuration
+ changes, but are not saved to the configuration
+- Static entries cannot be removed with the remove action
+- Entries in timeout sets ("greylist") expire on their own
+
+==== Topology
+
+image::topology.svg[Firewall Address-Sets with Dynamic Entries topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target
+. Configure firewall with address-set as zone source
+. Verify host is blocked by default
+. Add dynamic entry for host, verify it is allowed
+. Verify operational state distinguishes static/dynamic
+. Verify dynamic entry survives configuration change
+. Verify static entry cannot be removed with action
+. Remove dynamic entry, verify host is blocked
+. Flush dynamic entries, static remain
+. Verify entries in timeout set expire on their own
+
+
diff --git a/test/case/firewall/address-set/test.py b/test/case/firewall/address-set/test.py
new file mode 100755
index 00000000..7ed809d6
--- /dev/null
+++ b/test/case/firewall/address-set/test.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""Firewall Address-Sets with Dynamic Entries
+
+Verifies firewall address-sets used as zone sources, in a setup where
+all traffic from the data network is dropped by default and end devices
+are granted access per-IP at runtime.
+
+image::topology.svg[align=center, scaledwidth=50%]
+
+- The default zone "public" drops all traffic on the data interface
+- The "trusted" zone accepts traffic from members of the "allowed"
+ address-set: one static entry from the configuration, and dynamic
+ entries managed at runtime with the add/remove/flush actions
+- Dynamic entries must survive unrelated firewall configuration
+ changes, but are not saved to the configuration
+- Static entries cannot be removed with the remove action
+- Entries in timeout sets ("greylist") expire on their own
+"""
+
+import infamy
+from infamy.util import until
+
+
+def get_address_set(target, name):
+ """Fetch operational state for a named address-set"""
+ data = target.get_data("/infix-firewall:firewall")
+ sets = data["firewall"].get("address-set", [])
+ return next((s for s in sets if s["name"] == name), None)
+
+
+def current_entries(target, name):
+ """Return {entry: dynamic} for the live contents of an address-set"""
+ aset = get_address_set(target, name)
+ if not aset:
+ return {}
+ return {c["entry"]: c["dynamic"] for c in aset.get("current", [])}
+
+
+def must_fail(fn, *args):
+ """Assert that an action call raises an error"""
+ try:
+ fn(*args)
+ except Exception:
+ return
+ raise AssertionError(f"{args} unexpectedly succeeded")
+
+
+with infamy.Test() as test:
+ ALLOWED = "/infix-firewall:firewall/address-set[name='allowed']"
+ GREYLIST = "/infix-firewall:firewall/address-set[name='greylist']"
+
+ with test.step("Set up topology and attach to target"):
+ env = infamy.Env()
+ target = env.attach("target", "mgmt")
+ _, data_if = env.ltop.xlate("target", "data")
+ _, mgmt_if = env.ltop.xlate("target", "mgmt")
+ _, host_data = env.ltop.xlate("host", "data")
+ TARGET_IP = "192.168.1.1"
+ HOST_IP = "192.168.1.42"
+ STATIC_IP = "192.168.1.40"
+ EXTRA_IP = "192.168.1.50"
+
+ with test.step("Configure firewall with address-set as zone source"):
+ target.put_config_dicts({
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": data_if,
+ "enabled": True,
+ "ipv4": {
+ "address": [{
+ "ip": TARGET_IP,
+ "prefix-length": 24
+ }]
+ }
+ }]
+ }
+ },
+ "infix-firewall": {
+ "firewall": {
+ "default": "public",
+ "logging": "all",
+ "address-set": [{
+ "name": "allowed",
+ "description": "End devices allowed to access the target",
+ "entry": [STATIC_IP]
+ }, {
+ "name": "greylist",
+ "description": "Entries expire on their own",
+ "timeout": 10
+ }],
+ "zone": [{
+ "name": "mgmt",
+ "description": "Management network - for test automation",
+ "action": "accept",
+ "interface": [mgmt_if],
+ "service": ["ssh", "netconf", "restconf"]
+ }, {
+ "name": "public",
+ "description": "Untrusted data network, drop everything",
+ "action": "drop",
+ "interface": [data_if]
+ }, {
+ "name": "trusted",
+ "description": "Allowed end devices",
+ "action": "accept",
+ "address-set": ["allowed"]
+ }]
+ }
+ }
+ })
+
+ infamy.Firewall.wait_for_operational(target, {
+ "public": {"action": "drop"},
+ "trusted": {"action": "accept"},
+ "mgmt": {"action": "accept"}
+ })
+
+ aset = get_address_set(target, "allowed")
+ assert aset, "Address-set 'allowed' not found in operational"
+ assert STATIC_IP in aset.get("entry", []), \
+ f"Static entry {STATIC_IP} missing from configuration"
+
+ with infamy.IsolatedMacVlan(host_data) as ns:
+ ns.addip(HOST_IP)
+
+ with test.step("Verify host is blocked by default"):
+ ns.must_not_reach(TARGET_IP, timeout=5)
+
+ with test.step("Add dynamic entry for host, verify it is allowed"):
+ target.call_action(f"{ALLOWED}/add", {"entry": HOST_IP})
+ ns.must_reach(TARGET_IP, timeout=10)
+
+ with test.step("Verify operational state distinguishes static/dynamic"):
+ def entries_settled():
+ current = current_entries(target, "allowed")
+ return current.get(STATIC_IP) is False and \
+ current.get(HOST_IP) is True
+
+ until(entries_settled, attempts=10)
+
+ with test.step("Verify dynamic entry survives configuration change"):
+ target.put_config_dicts({
+ "infix-firewall": {
+ "firewall": {
+ "logging": "unicast"
+ }
+ }
+ })
+
+ ns.must_reach(TARGET_IP, timeout=30)
+ current = current_entries(target, "allowed")
+ assert current.get(HOST_IP) is True, \
+ f"Dynamic entry {HOST_IP} lost in firewall reload"
+
+ with test.step("Verify static entry cannot be removed with action"):
+ must_fail(target.call_action, f"{ALLOWED}/remove",
+ {"entry": STATIC_IP})
+
+ with test.step("Remove dynamic entry, verify host is blocked"):
+ target.call_action(f"{ALLOWED}/remove", {"entry": HOST_IP})
+ ns.must_not_reach(TARGET_IP, timeout=5)
+
+ with test.step("Flush dynamic entries, static remain"):
+ target.call_action(f"{ALLOWED}/add", {"entry": HOST_IP})
+ target.call_action(f"{ALLOWED}/add", {"entry": EXTRA_IP})
+ target.call_action(f"{ALLOWED}/flush")
+
+ def only_static_left():
+ current = current_entries(target, "allowed")
+ return list(current.keys()) == [STATIC_IP]
+
+ until(only_static_left, attempts=10)
+ ns.must_not_reach(TARGET_IP, timeout=5)
+
+ with test.step("Verify entries in timeout set expire on their own"):
+ target.call_action(f"{GREYLIST}/add", {"entry": "10.0.0.1"})
+
+ aset = get_address_set(target, "greylist")
+ assert aset, "Address-set 'greylist' not found in operational"
+ current = {c["entry"]: c for c in aset.get("current", [])}
+ assert "10.0.0.1" in current, "Entry missing from timeout set"
+ assert current["10.0.0.1"].get("expires") is not None, \
+ "Entry in timeout set has no expiry"
+
+ must_fail(target.call_action, f"{GREYLIST}/remove",
+ {"entry": "10.0.0.1"})
+
+ def entry_expired():
+ return "10.0.0.1" not in current_entries(target, "greylist")
+
+ until(entry_expired, attempts=20)
+
+ test.succeed()
diff --git a/test/case/firewall/address-set/topology.dot b/test/case/firewall/address-set/topology.dot
new file mode 100644
index 00000000..84948360
--- /dev/null
+++ b/test/case/firewall/address-set/topology.dot
@@ -0,0 +1,23 @@
+graph "1x2" {
+ layout = "neato";
+ overlap = false;
+ esep = "+30";
+
+ node [shape=record, fontname="DejaVu Sans Mono, Book"];
+ edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
+
+ host [
+ label="host | {
mgmt | data }",
+ pos="10,10!",
+ requires="controller"
+ ];
+
+ target [
+ label="{ mgmt | data } | target",
+ pos="40,10!",
+ requires="infix",
+ ];
+
+ host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"]
+ host:data -- target:data [color=black]
+}
diff --git a/test/case/firewall/address-set/topology.svg b/test/case/firewall/address-set/topology.svg
new file mode 100644
index 00000000..35a4d0ca
--- /dev/null
+++ b/test/case/firewall/address-set/topology.svg
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+1x2
+
+
+
+host
+
+host
+
+mgmt
+
+data
+
+
+
+target
+
+mgmt
+
+data
+
+target
+
+
+
+host:mgmt--target:mgmt
+
+
+
+
+host:data--target:data
+
+
+
+
diff --git a/test/case/firewall/all.yaml b/test/case/firewall/all.yaml
index 2778a31b..008c31c2 100644
--- a/test/case/firewall/all.yaml
+++ b/test/case/firewall/all.yaml
@@ -2,6 +2,9 @@
- name: Basic Firewall for End Devices
case: basic/test.py
+- name: Firewall Address-Sets with Dynamic Entries
+ case: address-set/test.py
+
- name: LAN-WAN Firewall with Masquerading
case: lan-wan/test.py