webui: configure firewall — zone and policy management

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:14 +02:00
parent b0bd1dad11
commit 974ea7260f
19 changed files with 1719 additions and 69 deletions
+17
View File
@@ -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
@@ -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 165535"))
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
}
+32 -10
View File
@@ -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 {
+8 -3
View File
@@ -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 {
+34
View File
@@ -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)
+4
View File
@@ -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; }
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>

After

Width:  |  Height:  |  Size: 273 B

+13
View File
@@ -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) {
@@ -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}}
<div class="info-card">
<div class="card-header">
{{if .ParentPath}}
<button type="button" class="btn btn-outline btn-sm yt-back-btn"
data-yang-path="{{.ParentPath}}"
hx-get="/configure/tree/node?path={{.ParentPath | urlquery}}"
hx-get="{{$nodeBase}}/node?path={{.ParentPath | urlquery}}"
hx-target="#yang-detail"
hx-swap="innerHTML">← Back</button>
{{end}}
{{.Name}}
{{if .Presence}}
{{if and .Presence (not $ro)}}
<div class="yt-presence-header">
{{if .Exists}}
<button class="btn btn-sm yt-presence-toggle yt-presence-on"
@@ -29,6 +31,12 @@
title="{{.Presence}} — click to enable">&#9675; Disabled</button>
{{end}}
</div>
{{else if and .Presence $ro}}
<div class="yt-presence-header">
<span class="yt-presence-toggle {{if .Exists}}yt-presence-on{{else}}yt-presence-off{{end}}">
{{if .Exists}}&#9679; Enabled{{else}}&#9675; Disabled{{end}}
</span>
</div>
{{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}}
<form class="yt-group-form"
hx-put="/configure/tree/group?path={{.Path | urlquery}}{{if .ParentPath}}&parent={{.ParentPath | urlquery}}{{end}}"
hx-swap="none">
{{end}}
<div class="yt-group-list">
{{range .Leaves}}
{{$cv := .CurrentValue}}
@@ -66,8 +76,24 @@
{{.Name}}{{if .Mandatory}}<span class="yt-mandatory" title="mandatory">*</span>{{end}}
</span>
<span class="yt-leaf-input yt-sp">
{{if not .Config}}
<span class="text-muted">{{$cv}}</span>
{{if or $ro (not .Config)}}
{{if and .IsBinary .HasBinary}}
<div class="yt-binary-wrap">
<div class="yt-binary-row">
<span class="yt-binary-hint">binary data</span>
<button type="button" class="btn-icon yt-binary-eye" title="Show/hide">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
<code class="yt-binary-content yt-binary-pem">{{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}}</code>
</div>
{{else}}
<span class="{{if not .Config}}text-muted{{end}}">{{if $cv}}{{$cv}}{{else}}—{{end}}</span>
{{end}}
{{else if and .Type (eq .Type.Kind "boolean")}}
<div class="yt-bool-group">
<label><input type="radio" name="{{.Name}}" value="true"
@@ -107,7 +133,7 @@
{{if and .UsingDefault .Default}}placeholder="{{.Default}}"{{end}}>
{{end}}
</span>
{{if and .Config (not .Mandatory)}}
{{if and (not $ro) .Config (not .Mandatory)}}
<button class="btn btn-secondary btn-sm yt-sp" type="button"
title="Reset to YANG default"
hx-delete="/configure/tree/node?path={{.Path | urlquery}}"
@@ -133,12 +159,14 @@
</details>
{{end}}
</div>
{{if not $ro}}
<div class="cfg-card-footer">
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{end}}
{{end}}
{{/* ── Inline simple sub-lists ────────────────────────────────── */}}
{{range $i, $list := .InlineLists}}
@@ -150,7 +178,7 @@
<tr>
{{if $list.Complex}}<th class="yt-nav-col"></th>{{end}}
{{range $list.Columns}}<th>{{.DisplayName}}</th>{{end}}
<th></th>
{{if not $list.ReadOnly}}<th></th>{{end}}
</tr>
</thead>
<tbody>
@@ -158,7 +186,7 @@
{{$row := .}}
<tr class="yt-list-row{{if $list.Complex}} yt-list-row-nav{{end}}"
data-yang-path="{{$row.InstancePath}}"
hx-get="/configure/tree/node?path={{$row.InstancePath | urlquery}}{{if $list.ParentPath}}&parent={{$list.ParentPath | urlquery}}{{end}}"
hx-get="{{$nodeBase}}/node?path={{$row.InstancePath | urlquery}}{{if $list.ParentPath}}&parent={{$list.ParentPath | urlquery}}{{end}}"
hx-target="#yang-detail"
hx-swap="innerHTML">
{{if $list.Complex}}
@@ -170,6 +198,7 @@
</td>
{{end}}
{{range $list.Columns}}<td>{{index $row.Values .Name}}</td>{{end}}
{{if not $list.ReadOnly}}
<td class="cfg-action-col">
<button type="button" class="btn-icon btn-icon-danger yt-sp"
title="Delete {{$row.InstanceName}}"
@@ -185,12 +214,13 @@
</svg>
</button>
</td>
{{end}}
</tr>
{{end}}
{{if not $list.Rows}}
<tr><td class="yt-table-empty" colspan="99">No entries</td></tr>
{{end}}
{{if not $list.Complex}}
{{if and (not $list.Complex) (not $list.ReadOnly)}}
{{/* Hidden inline add row — simple lists only */}}
<tr id="yt-add-row-{{$i}}" hidden>
<td colspan="99" class="key-detail-cell">
@@ -235,6 +265,7 @@
</tbody>
</table>
</div>
{{if not $list.ReadOnly}}
<div style="padding: 0.35rem 0.75rem 0.5rem">
{{if $list.Complex}}
<button class="btn btn-secondary btn-sm"
@@ -245,6 +276,7 @@
<button class="btn btn-secondary btn-sm" data-show="yt-add-row-{{$i}}">+ Add</button>
{{end}}
</div>
{{end}}
</div>
{{end}}
@@ -254,9 +286,11 @@
<div class="yt-inline-container">
<div class="yt-subsections-header">{{$sub.Name}}</div>
{{if $sub.Leaves}}
{{if not $sub.ReadOnly}}
<form class="yt-group-form"
hx-put="/configure/tree/group?path={{$sub.Path | urlquery}}&parent={{$sub.ParentPath | urlquery}}"
hx-swap="none">
{{end}}
<div class="yt-group-list">
{{range $sub.Leaves}}
{{$cv := .CurrentValue}}
@@ -268,11 +302,11 @@
{{.Name}}{{if .Mandatory}}<span class="yt-mandatory" title="mandatory">*</span>{{end}}
</span>
<span class="yt-leaf-input yt-sp">
{{if not .Config}}
{{if and .IsBinary $cv}}
{{if or $sub.ReadOnly (not .Config)}}
{{if and .IsBinary .HasBinary}}
<div class="yt-binary-wrap">
<div class="yt-binary-row">
<span class="yt-binary-hint">{{if .BinaryHint}}{{.BinaryHint}}{{else}}binary data{{end}}</span>
<span class="yt-binary-hint">binary data</span>
<button type="button" class="btn-icon yt-binary-eye" title="Show/hide">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -281,10 +315,10 @@
</svg>
</button>
</div>
<code class="yt-binary-content yt-binary-pem">{{$cv}}</code>
<code class="yt-binary-content yt-binary-pem">{{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}}</code>
</div>
{{else}}
<span class="text-muted">{{if $cv}}{{$cv}}{{else}}—{{end}}</span>
<span class="{{if not .Config}}text-muted{{end}}">{{if $cv}}{{$cv}}{{else}}—{{end}}</span>
{{end}}
{{else if and .Type (eq .Type.Kind "boolean")}}
<div class="yt-bool-group">
@@ -322,7 +356,7 @@
{{if and .UsingDefault .Default}}placeholder="{{.Default}}"{{end}}>
{{end}}
</span>
{{if and .Config (not .Mandatory)}}
{{if and (not $sub.ReadOnly) .Config (not .Mandatory)}}
<button class="btn btn-secondary btn-sm yt-sp" type="button"
title="Reset to YANG default"
hx-delete="/configure/tree/node?path={{.Path | urlquery}}"
@@ -348,12 +382,14 @@
</details>
{{end}}
</div>
{{if not $sub.ReadOnly}}
<div class="cfg-card-footer">
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{end}}
{{end}}
</div>
{{end}}
@@ -364,7 +400,7 @@
{{range .SubNodes}}
<button class="yt-subsection-item"
data-yang-path="{{.Path}}"
hx-get="/configure/tree/node?path={{.Path | urlquery}}"
hx-get="{{$nodeBase}}/node?path={{.Path | urlquery}}"
hx-target="#yang-detail"
hx-swap="innerHTML">
<span class="yt-subsection-name">{{.Name}}</span>
@@ -7,10 +7,12 @@
{{if .SavedOK}}<div class="alert alert-success">Saved to candidate</div>{{end}}
<div class="data-table-wrap">
<table class="data-table">
{{$ro := .ReadOnly}}
{{$nodeBase := "/configure/tree"}}{{if $ro}}{{$nodeBase = "/status/tree"}}{{end}}
<thead>
<tr>
{{range .Columns}}<th>{{.DisplayName}}</th>{{end}}
<th></th>
{{if not $ro}}<th></th>{{end}}
</tr>
</thead>
<tbody>
@@ -18,10 +20,11 @@
{{$row := .}}
<tr class="yt-list-row"
data-yang-path="{{.InstancePath}}"
hx-get="/configure/tree/node?path={{.InstancePath | urlquery}}"
hx-get="{{$nodeBase}}/node?path={{.InstancePath | urlquery}}"
hx-target="#yang-detail"
hx-swap="innerHTML">
{{range $.Columns}}<td>{{index $row.Values .Name}}</td>{{end}}
{{if not $ro}}
<td class="cfg-action-col">
<button type="button"
class="btn-icon btn-icon-danger yt-sp"
@@ -38,12 +41,14 @@
</svg>
</button>
</td>
{{end}}
</tr>
{{end}}
{{if not .Rows}}
<tr><td class="yt-table-empty" colspan="99">No entries</td></tr>
{{end}}
{{/* Hidden inline add row */}}
{{/* Hidden inline add row — config mode only */}}
{{if not $ro}}
<tr id="yt-list-add-row" hidden>
<td colspan="99" class="key-detail-cell">
<form class="user-add-inline"
@@ -88,12 +93,15 @@
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if not $ro}}
<div class="cfg-card-footer">
<button class="btn btn-secondary" data-show="yt-list-add-row">+ Add</button>
</div>
{{end}}
</div>
{{end}}
@@ -10,8 +10,17 @@
<div class="alert alert-success">Saved to candidate</div>
{{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"))}}
<div class="cfg-card-footer">
<span class="yt-value-label">
{{if .CurrentValue}}{{.CurrentValue}}{{else}}<span class="text-muted"></span>{{end}}
</span>
</div>
{{end}}
{{/* ── Edit form — only for writable leaves in non-ReadOnly mode ── */}}
{{if and (not .ReadOnly) .Config (or (eq .Kind "leaf") (eq .Kind "leaf-list"))}}
<form class="yt-edit-form"
hx-put="/configure/tree/node?path={{.Path | urlquery}}"
hx-target="#yang-detail"
@@ -8,13 +8,13 @@
{{if or (eq .Kind "container") (eq .Kind "list") (eq .Kind "list-instance")}}
<li>
<details class="yt-node"
hx-get="/configure/tree/children?path={{.Path | urlquery}}"
hx-get="{{.TreeBase}}/children?path={{.Path | urlquery}}"
hx-target="find .yt-children"
hx-trigger="toggle once"
hx-swap="innerHTML">
<summary class="yt-label"
data-yang-path="{{.Path}}"
hx-get="/configure/tree/node?path={{.Path | urlquery}}"
hx-get="{{.TreeBase}}/node?path={{.Path | urlquery}}"
hx-target="#yang-detail"
hx-swap="innerHTML">
<svg class="yt-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none"
+25 -1
View File
@@ -144,10 +144,24 @@
</li>
</ul>
<div class="nav-section-label">Advanced</div>
<ul class="nav-group-items">
<li>
<a href="/status/tree"
hx-get="/status/tree"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "status-tree"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/status-tree.svg')"></span>
Advanced
</a>
</li>
</ul>
</details>
<details class="nav-group-top" id="nav-configure"
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-routes") (eq .ActivePage "configure-users") (eq .ActivePage "configure-keystore") (eq .ActivePage "configure-tree")}}open{{end}}>
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-firewall") (eq .ActivePage "configure-routes") (eq .ActivePage "configure-users") (eq .ActivePage "configure-keystore") (eq .ActivePage "configure-tree")}}open{{end}}>
<summary class="nav-group-summary-top">Configure</summary>
<ul class="nav-group-items">
<li>
@@ -160,6 +174,16 @@
System
</a>
</li>
<li>
<a href="/configure/firewall"
hx-get="/configure/firewall"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "configure-firewall"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/firewall.svg')"></span>
Firewall
</a>
</li>
<li>
<a href="/configure/routes"
hx-get="/configure/routes"
@@ -0,0 +1,747 @@
{{define "configure-firewall.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<div class="configure-page">
{{if .Loading}}
<div class="alert alert-info"
hx-get="/configure/firewall"
hx-trigger="every 3s"
hx-target="#content"
hx-swap="innerHTML">
Downloading YANG schema from device&hellip; this takes a moment on first login.
</div>
{{template "configure-toolbar" .}}
</div>
{{else}}
{{$d := .Desc}}
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
{{if not .Active}}
<section class="info-card">
<div class="card-header">Firewall</div>
<p class="empty-message">Firewall is not configured on this device.</p>
<div class="cfg-card-footer">
<button class="btn btn-primary"
hx-post="/configure/firewall/enable"
hx-swap="none">Enable Firewall</button>
<span class="cfg-save-status"></span>
</div>
</section>
{{else}}
<section class="info-grid">
{{/* ── Global settings ─────────────────────────────────────────────── */}}
<section class="info-card info-grid-span-2">
<div class="card-header">Global Settings</div>
<form hx-post="/configure/firewall/settings" hx-swap="none">
<table class="info-table">
<tr>
<th>Enabled{{template "field-info" (index $d "enabled")}}</th>
<td>
<div class="yt-bool-group">
<label><input type="radio" name="enabled" value="true" {{if .Enabled}}checked{{end}}> Yes</label>
<label><input type="radio" name="enabled" value="false" {{if not .Enabled}}checked{{end}}> No</label>
</div>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path=/infix-firewall:firewall/enabled&redirect=/configure/firewall"
hx-swap="none"
hx-confirm="Reset enabled to its YANG default?"></button>
</td>
</tr>
<tr>
<th>Default zone{{template "field-info" (index $d "default")}}</th>
<td>
<select class="cfg-input" name="default" {{if not .Zones}}disabled{{end}}>
{{if not .Zones}}
<option value="">— add a zone first —</option>
{{end}}
{{range .Zones}}
<option value="{{.Name}}" {{if eq .Name $.Default}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path=/infix-firewall:firewall/default&redirect=/configure/firewall"
hx-swap="none"
hx-confirm="Reset default zone to its YANG default?"></button>
</td>
</tr>
<tr>
<th>Logging{{template "field-info" (index $d "logging")}}</th>
<td>
{{$log := .Logging}}
<select class="cfg-input" name="logging">
{{range .LoggingOptions}}
<option value="{{.Value}}" {{if eq $log .Value}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path=/infix-firewall:firewall/logging&redirect=/configure/firewall"
hx-swap="none"
hx-confirm="Reset logging to its YANG default?"></button>
</td>
</tr>
</table>
<div class="cfg-card-footer">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
</section>
{{/* ── Zones ───────────────────────────────────────────────────────── */}}
<section class="info-card info-grid-span-2">
<div class="card-header">Zones</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Name{{template "field-info" (index $d "zone-name")}}</th>
<th>Action{{template "field-info" (index $d "zone-action")}}</th>
<th>Description{{template "field-info" (index $d "zone-description")}}</th>
<th style="text-align:center">Interfaces</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $z := .Zones}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="zone-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}
{{if .Immutable}}<span class="text-muted" style="font-size:0.75em;margin-left:0.4em">(system)</span>{{end}}
</button>
</td>
<td>{{if .Action}}{{.Action}}{{else}}reject{{end}}</td>
<td>{{if .Description}}{{.Description}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td style="text-align:center">{{.IfaceCount}}</td>
<td style="text-align:right">
{{if not .Immutable}}
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/firewall/zones/{{.Name}}"
hx-confirm="Delete zone {{.Name}}?"
hx-swap="none"
title="Delete zone">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6M14 11v6"></path>
</svg>
</button>
{{end}}
</td>
</tr>
<tr class="key-detail-row" id="key-detail-zone-{{$i}}">
<td colspan="5" class="key-detail-cell">
<div class="key-detail-body">
{{if .Immutable}}
<p class="empty-message">This zone is system-defined and cannot be modified.</p>
{{if .Interface}}<p class="text-muted" style="margin-top:0.5rem">Interfaces: {{range $j, $iface := .Interface}}{{if $j}}, {{end}}{{$iface}}{{end}}</p>{{end}}
{{if .Network}}<p class="text-muted" style="margin-top:0.25rem">Networks: {{.NetworksTxt}}</p>{{end}}
{{if .Service}}<p class="text-muted" style="margin-top:0.25rem">Services: {{.ServicesTxt}}</p>{{end}}
{{else}}
<form hx-post="/configure/firewall/zones/{{.Name}}" hx-swap="none">
<table class="info-table">
<tr>
<th>Action{{template "field-info" (index $d "zone-action")}}</th>
<td>
{{$action := .Action}}
<div class="yt-bool-group">
{{range $.ActionOptions}}
<label><input type="radio" name="action" value="{{.Value}}"
{{if or (eq $action .Value) (and (eq $action "") .IsDefault)}}checked{{end}}> {{.Label}}</label>
{{end}}
</div>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{printf "/infix-firewall:firewall/zone=%s/action" .Name | urlquery}}&redirect=/configure/firewall"
hx-swap="none"
hx-confirm="Reset action to its YANG default?"></button>
</td>
</tr>
<tr>
<th>Description{{template "field-info" (index $d "zone-description")}}</th>
<td><input class="cfg-input" type="text" name="description"
value="{{.Description}}" placeholder="Optional description"></td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{printf "/infix-firewall:firewall/zone=%s/description" .Name | urlquery}}&redirect=/configure/firewall"
hx-swap="none"
hx-confirm="Reset description to its YANG default?"></button>
</td>
</tr>
{{if .Network}}
<tr>
<th>Networks</th>
<td colspan="2">
<span class="text-muted">{{.NetworksTxt}}</span>
<span class="text-muted" style="font-size:0.8em;display:block;margin-top:0.2em">Use Configure → Advanced to modify networks.</span>
</td>
</tr>
{{else}}
<tr>
<th>Interfaces{{template "field-info" (index $d "zone-interface")}}</th>
<td>
{{if $.AllInterfaces}}
<div class="fw-check-scroll">
<div class="fw-check-grid">
{{range $.AllInterfaces}}
<label><input type="checkbox" name="interfaces" value="{{.}}" {{if index $z.IfaceSet .}}checked{{end}}> {{.}}</label>
{{end}}
</div>
</div>
{{else}}
<textarea class="cfg-input" name="interfaces" rows="3"
placeholder="One interface per line (e.g. eth0)">{{range $j, $iface := .Interface}}{{if $j}}
{{end}}{{$iface}}{{end}}</textarea>
{{end}}
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/firewall/zones/{{.Name}}/interfaces"
hx-swap="none"
hx-confirm="Clear interfaces from zone {{.Name}}?">{{template "icon-reset"}}</button>
</td>
</tr>
{{end}}
<tr>
<th>Services{{template "field-info" (index $d "zone-service")}}</th>
<td>
{{if $.ServiceOptions}}
<select class="cfg-input" name="services" multiple size="8">
{{range $.ServiceOptions}}
<option value="{{.Value}}" {{if index $z.ServiceSet .Value}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
{{else}}
<textarea class="cfg-input" name="services" rows="3"
placeholder="One service per line (e.g. ssh, http)">{{.ServicesTxt}}</textarea>
{{end}}
<span class="text-muted" style="font-size:0.8em;display:block;margin-top:0.25em">Applied when action is reject or drop. Ctrl/⌘-click to select multiple.</span>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/firewall/zones/{{.Name}}/services"
hx-swap="none"
hx-confirm="Clear services from zone {{.Name}}?">{{template "icon-reset"}}</button>
</td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<button type="button" class="btn btn-sm" data-close-detail="zone-{{$i}}">Cancel</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{/* Port forwarding (DNAT) — own forms, edits land directly. */}}
<h5 class="cfg-subsection-head" style="margin-top:1rem">Port Forwarding</h5>
{{if .PortForward}}
<table class="data-table cfg-table" style="margin-bottom:0.5rem">
<thead>
<tr>
<th style="width:8em">Port</th>
<th style="width:5em">Proto</th>
<th>To address</th>
<th style="width:6em">To port</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .PortForward}}
<tr>
<td>{{.Lower}}{{if gt .Upper .Lower}}-{{.Upper}}{{end}}</td>
<td>{{.Proto}}</td>
<td>{{if and .To .To.Addr}}{{.To.Addr}}{{else}}<span class="text-muted">— local —</span>{{end}}</td>
<td>{{if and .To .To.Port}}{{.To.Port}}{{else}}<span class="text-muted">same</span>{{end}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/firewall/zones/{{$z.Name}}/port-forwards/{{.Lower}}/{{.Proto}}"
hx-confirm="Remove port-forward {{.Lower}}/{{.Proto}}?"
hx-swap="none"
title="Remove port-forward">
{{template "icon-trash"}}
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<form hx-post="/configure/firewall/zones/{{.Name}}/port-forwards" hx-swap="none"
class="user-add-inline" style="flex-wrap:wrap">
<input class="cfg-input cfg-input-sm" type="number" name="lower"
min="1" max="65535" placeholder="port" required style="width:6rem">
<input class="cfg-input cfg-input-sm" type="number" name="upper"
min="1" max="65535" placeholder="upper" style="width:6rem">
<select class="cfg-input cfg-input-sm" name="proto" required style="width:5rem">
{{range $.ProtoOptions}}
<option value="{{.Value}}" {{if .IsDefault}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<input class="cfg-input cfg-input-sm" type="text" name="to-addr"
placeholder="to addr (optional)" style="flex:2;min-width:10rem">
<input class="cfg-input cfg-input-sm" type="number" name="to-port"
min="1" max="65535" placeholder="to port" style="width:7rem">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<span class="cfg-save-status"></span>
</form>
{{end}}
</div>
</td>
</tr>
{{end}}
{{if not .Zones}}
<tr><td colspan="5" class="yt-table-empty">No zones configured</td></tr>
{{end}}
{{/* Hidden add-zone row */}}
<tr id="add-zone-row" hidden>
<td colspan="5" class="key-detail-cell">
<form hx-post="/configure/firewall/zones" hx-swap="none" class="user-add-inline">
<input class="cfg-input" type="text" name="name" required
pattern="[a-zA-Z0-9\-_]+" placeholder="Zone name">
<div class="yt-bool-group" style="display:inline-flex">
{{range .ActionOptions}}
<label><input type="radio" name="action" value="{{.Value}}" {{if .IsDefault}}checked{{end}}> {{.Label}}</label>
{{end}}
</div>
<input class="cfg-input" type="text" name="description"
placeholder="Description (optional)">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-zone-row">Cancel</button>
<span class="cfg-save-status"></span>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="add-zone-row">+ Add Zone</button>
</div>
</section>
{{/* ── Policies ─────────────────────────────────────────────────────── */}}
<section class="info-card info-grid-span-2">
<div class="card-header">Policies</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Name{{template "field-info" (index $d "policy-name")}}</th>
<th>Action{{template "field-info" (index $d "policy-action")}}</th>
<th>Ingress{{template "field-info" (index $d "policy-ingress")}}</th>
<th>Egress{{template "field-info" (index $d "policy-egress")}}</th>
<th style="text-align:center">Masquerade{{template "field-info" (index $d "policy-masquerade")}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $p := .Policies}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="policy-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}
{{if .Immutable}}<span class="text-muted" style="font-size:0.75em;margin-left:0.4em">(system)</span>{{end}}
</button>
</td>
<td>{{if .Action}}{{.Action}}{{else}}reject{{end}}</td>
<td>{{if .IngressDisplay}}{{.IngressDisplay}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{if .EgressDisplay}}{{.EgressDisplay}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td style="text-align:center">{{.MasqDisplay}}</td>
<td style="text-align:right">
{{if not .Immutable}}
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/firewall/policies/{{.Name}}"
hx-confirm="Delete policy {{.Name}}?"
hx-swap="none"
title="Delete policy">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6M14 11v6"></path>
</svg>
</button>
{{end}}
</td>
</tr>
<tr class="key-detail-row" id="key-detail-policy-{{$i}}">
<td colspan="6" class="key-detail-cell">
<div class="key-detail-body">
{{if .Immutable}}
<p class="empty-message">This policy is system-defined and cannot be modified.</p>
{{else}}
<form hx-post="/configure/firewall/policies/{{.Name}}" hx-swap="none">
<table class="info-table">
<tr>
<th>Action{{template "field-info" (index $d "policy-action")}}</th>
<td>
{{$action := .Action}}
<select class="cfg-input" name="action">
{{range $.PolicyActionOptions}}
<option value="{{.Value}}" {{if or (eq $action .Value) (and (eq $action "") .IsDefault)}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{printf "/infix-firewall:firewall/policy=%s/action" .Name | urlquery}}&redirect=/configure/firewall"
hx-swap="none"
hx-confirm="Reset action to its YANG default?"></button>
</td>
</tr>
<tr>
<th>Ingress zones{{template "field-info" (index $d "policy-ingress")}}</th>
<td colspan="2">
<details class="cfg-multi">
<summary class="cfg-multi-summary">{{if .IngressDisplay}}{{.IngressDisplay}}{{else}}(None){{end}}</summary>
<div class="cfg-multi-body">
{{range $.ZoneNames}}
<label class="cfg-multi-item">
<input type="checkbox" name="ingress" value="{{.}}" {{if index $p.IngressSet .}}checked{{end}}>
{{.}}
</label>
{{end}}
</div>
</details>
</td>
</tr>
<tr>
<th>Egress zones{{template "field-info" (index $d "policy-egress")}}</th>
<td colspan="2">
<details class="cfg-multi">
<summary class="cfg-multi-summary">{{if .EgressDisplay}}{{.EgressDisplay}}{{else}}(None){{end}}</summary>
<div class="cfg-multi-body">
{{range $.ZoneNames}}
<label class="cfg-multi-item">
<input type="checkbox" name="egress" value="{{.}}" {{if index $p.EgressSet .}}checked{{end}}>
{{.}}
</label>
{{end}}
</div>
</details>
</td>
</tr>
<tr>
<th>Masquerade{{template "field-info" (index $d "policy-masquerade")}}</th>
<td>
<div class="yt-bool-group">
<label><input type="radio" name="masquerade" value="true" {{if .Masquerade}}checked{{end}}> Yes</label>
<label><input type="radio" name="masquerade" value="false" {{if not .Masquerade}}checked{{end}}> No</label>
</div>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{printf "/infix-firewall:firewall/policy=%s/masquerade" .Name | urlquery}}&redirect=/configure/firewall"
hx-swap="none"
hx-confirm="Reset masquerade to its YANG default?"></button>
</td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<button type="button" class="btn btn-sm" data-close-detail="policy-{{$i}}">Cancel</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{end}}
</div>
</td>
</tr>
{{end}}
{{if not .Policies}}
<tr><td colspan="6" class="yt-table-empty">No policies configured</td></tr>
{{end}}
{{/* Hidden add-policy row */}}
<tr id="add-policy-row" hidden>
<td colspan="6" class="key-detail-cell">
<form hx-post="/configure/firewall/policies" hx-swap="none">
<table class="info-table">
<tr>
<th>Name{{template "field-info" (index $d "policy-name")}}</th>
<td colspan="2"><input class="cfg-input" type="text" name="name" required
pattern="[a-zA-Z0-9\-_]+" placeholder="e.g. LAN-to-WAN"></td>
</tr>
<tr>
<th>Action{{template "field-info" (index $d "policy-action")}}</th>
<td colspan="2">
<select class="cfg-input" name="action">
{{range .PolicyActionOptions}}
<option value="{{.Value}}" {{if .IsDefault}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</td>
</tr>
<tr>
<th>Ingress zones{{template "field-info" (index $d "policy-ingress")}}</th>
<td colspan="2">
<details class="cfg-multi">
<summary class="cfg-multi-summary">(None)</summary>
<div class="cfg-multi-body">
{{range .ZoneNames}}
<label class="cfg-multi-item">
<input type="checkbox" name="ingress" value="{{.}}"> {{.}}
</label>
{{end}}
</div>
</details>
</td>
</tr>
<tr>
<th>Egress zones{{template "field-info" (index $d "policy-egress")}}</th>
<td colspan="2">
<details class="cfg-multi">
<summary class="cfg-multi-summary">(None)</summary>
<div class="cfg-multi-body">
{{range .ZoneNames}}
<label class="cfg-multi-item">
<input type="checkbox" name="egress" value="{{.}}"> {{.}}
</label>
{{end}}
</div>
</details>
</td>
</tr>
<tr>
<th>Masquerade{{template "field-info" (index $d "policy-masquerade")}}</th>
<td colspan="2">
<div class="yt-bool-group">
<label><input type="radio" name="masquerade" value="true"> Yes</label>
<label><input type="radio" name="masquerade" value="false" checked> No</label>
</div>
</td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-policy-row">Cancel</button>
<span class="cfg-save-status"></span>
</div>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="add-policy-row">+ Add Policy</button>
</div>
</section>
{{/* ── Custom Services ──────────────────────────────────────────────── */}}
<section class="info-card info-grid-span-2">
<div class="card-header">Services</div>
<p class="text-muted" style="font-size:0.85em;margin:0 1rem 0.5rem">
Define port/protocol bundles by name, e.g. <code>myapp = tcp 8080</code>.
Custom services appear in the zone Services picker alongside the built-in ones.
</p>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Name{{template "field-info" (index $d "service-name")}}</th>
<th>Description{{template "field-info" (index $d "service-description")}}</th>
<th>Ports</th>
<th>Destination{{template "field-info" (index $d "service-destination")}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $s := .Services}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="service-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}
</button>
</td>
<td>{{if .Description}}{{.Description}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{if .PortsDisplay}}<code>{{.PortsDisplay}}</code>{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{if .Destination}}<code>{{.Destination}}</code>{{else}}<span class="text-muted">any</span>{{end}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/firewall/services/{{.Name}}"
hx-confirm="Delete service {{.Name}}?"
hx-swap="none"
title="Delete service">
{{template "icon-trash"}}
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-service-{{$i}}">
<td colspan="5" class="key-detail-cell">
<div class="key-detail-body">
<form hx-post="/configure/firewall/services/{{.Name}}" hx-swap="none">
<table class="info-table">
<tr>
<th>Description{{template "field-info" (index $d "service-description")}}</th>
<td colspan="2"><input class="cfg-input" type="text" name="description"
value="{{.Description}}" placeholder="Optional description"></td>
</tr>
<tr>
<th>Destination{{template "field-info" (index $d "service-destination")}}</th>
<td colspan="2"><input class="cfg-input" type="text" name="destination"
value="{{.Destination}}" placeholder="Optional IP address or prefix"></td>
</tr>
<tr>
<th>Ports</th>
<td colspan="2">
<table class="info-table info-table-sub" style="margin-bottom:0.25rem">
<thead>
<tr>
<th style="width:6em">Lower{{template "field-info" (index $d "service-port-lower")}}</th>
<th style="width:6em">Upper{{template "field-info" (index $d "service-port-upper")}}</th>
<th>Proto{{template "field-info" (index $d "service-port-proto")}}</th>
</tr>
</thead>
<tbody>
{{range .Port}}
<tr>
<td><input class="cfg-input cfg-input-sm" type="number"
name="port-lower" min="1" max="65535"
value="{{.Lower}}"></td>
<td><input class="cfg-input cfg-input-sm" type="number"
name="port-upper" min="1" max="65535"
value="{{if gt .Upper .Lower}}{{.Upper}}{{end}}"
placeholder="—"></td>
<td>
<select class="cfg-input cfg-input-sm" name="port-proto">
{{$cur := .Proto}}
{{range $.ProtoOptions}}
<option value="{{.Value}}" {{if eq $cur .Value}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</td>
</tr>
{{end}}
{{/* One blank row so the user can add another port without losing their session. */}}
<tr>
<td><input class="cfg-input cfg-input-sm" type="number" name="port-lower" min="1" max="65535"></td>
<td><input class="cfg-input cfg-input-sm" type="number" name="port-upper" min="1" max="65535" placeholder="—"></td>
<td>
<select class="cfg-input cfg-input-sm" name="port-proto">
{{range $.ProtoOptions}}
<option value="{{.Value}}">{{.Label}}</option>
{{end}}
</select>
</td>
</tr>
</tbody>
</table>
<span class="text-muted" style="font-size:0.8em">Leave "Upper" blank for a single port. Blank "Lower" rows are ignored.</span>
</td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<button type="button" class="btn btn-sm" data-close-detail="service-{{$i}}">Cancel</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
</td>
</tr>
{{end}}
{{if not .Services}}
<tr><td colspan="5" class="yt-table-empty">No custom services configured</td></tr>
{{end}}
{{/* Hidden add-service row */}}
<tr id="add-service-row" hidden>
<td colspan="5" class="key-detail-cell">
<form hx-post="/configure/firewall/services" hx-swap="none">
<table class="info-table">
<tr>
<th>Name{{template "field-info" (index $d "service-name")}}</th>
<td colspan="2"><input class="cfg-input" type="text" name="name" required
pattern="[a-zA-Z0-9\-_]+" placeholder="e.g. myapp"></td>
</tr>
<tr>
<th>Description{{template "field-info" (index $d "service-description")}}</th>
<td colspan="2"><input class="cfg-input" type="text" name="description"
placeholder="Optional description"></td>
</tr>
<tr>
<th>Destination{{template "field-info" (index $d "service-destination")}}</th>
<td colspan="2"><input class="cfg-input" type="text" name="destination"
placeholder="Optional IP address or prefix"></td>
</tr>
<tr>
<th>Port</th>
<td colspan="2">
<table class="info-table info-table-sub" style="margin-bottom:0.25rem">
<thead>
<tr>
<th style="width:6em">Lower{{template "field-info" (index $d "service-port-lower")}}</th>
<th style="width:6em">Upper{{template "field-info" (index $d "service-port-upper")}}</th>
<th>Proto{{template "field-info" (index $d "service-port-proto")}}</th>
</tr>
</thead>
<tbody>
<tr>
<td><input class="cfg-input cfg-input-sm" type="number" name="port-lower" min="1" max="65535" required></td>
<td><input class="cfg-input cfg-input-sm" type="number" name="port-upper" min="1" max="65535" placeholder="—"></td>
<td>
<select class="cfg-input cfg-input-sm" name="port-proto">
{{range .ProtoOptions}}
<option value="{{.Value}}" {{if .IsDefault}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</td>
</tr>
</tbody>
</table>
<span class="text-muted" style="font-size:0.8em">Add more ports after creating the service.</span>
</td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-service-row">Cancel</button>
<span class="cfg-save-status"></span>
</div>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="add-service-row">+ Add Service</button>
</div>
</section>
</section>
{{end}}
{{template "configure-toolbar" .}}
</div>
{{end}}{{/* end {{else}} — schema ready */}}
{{end}}
{{define "field-info"}}{{if .}} <span class="field-info" data-tip="{{.}}"></span>{{end}}{{end}}
@@ -4,6 +4,17 @@
{{define "content"}}
<div class="configure-page">
{{if .Loading}}
<div class="alert alert-info"
hx-get="/configure/keystore"
hx-trigger="every 3s"
hx-target="#content"
hx-swap="innerHTML">
Downloading YANG schema from device&hellip; this takes a moment on first login.
</div>
{{template "configure-toolbar" .}}
</div>
{{else}}
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
@@ -71,13 +82,6 @@
{{range $.SymKeyFormats}}
<option value="{{.Value}}"
{{if eq $fmt .Label}}selected{{end}}>{{.Label}}</option>
{{else}}
<option value="infix-crypto-types:passphrase-key-format"
{{if eq $fmt "passphrase"}}selected{{end}}>passphrase</option>
<option value="ietf-crypto-types:octet-string-key-format"
{{if eq $fmt "octet-string"}}selected{{end}}>octet-string</option>
<option value="ietf-crypto-types:one-symmetric-key-format"
{{if eq $fmt "one-symmetric"}}selected{{end}}>one-symmetric</option>
{{end}}
</select>
</td>
@@ -119,10 +123,6 @@
<select class="cfg-input" name="format">
{{range .SymKeyFormats}}
<option value="{{.Value}}">{{.Label}}</option>
{{else}}
<option value="infix-crypto-types:passphrase-key-format">passphrase</option>
<option value="ietf-crypto-types:octet-string-key-format">octet-string</option>
<option value="ietf-crypto-types:one-symmetric-key-format">one-symmetric</option>
{{end}}
</select>
<span class="cfg-secret-wrap">
@@ -346,4 +346,6 @@
{{template "configure-toolbar" .}}
</div>
{{end}}{{/* end {{else}} — schema ready */}}
{{end}}
@@ -48,14 +48,7 @@
class="user-shell-form">
<select class="cfg-input" name="shell">
{{$shell := .Shell}}
{{if $.ShellOptions}}
{{range $.ShellOptions}}<option value="{{.Value}}" {{if or (eq .Value $shell) (and (eq $shell "") .IsDefault)}}selected{{end}}>{{.Label}}{{if .IsDefault}} (default){{end}}</option>{{end}}
{{else}}
<option value="infix-system:clish" {{if eq $shell "infix-system:clish"}}selected{{end}}>clish</option>
<option value="infix-system:bash" {{if eq $shell "infix-system:bash" }}selected{{end}}>bash</option>
<option value="infix-system:sh" {{if eq $shell "infix-system:sh" }}selected{{end}}>sh</option>
<option value="infix-system:false" {{if eq $shell "infix-system:false"}}selected{{end}}>false</option>
{{end}}
</select>
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
@@ -168,14 +161,7 @@
<input class="cfg-input" type="password" name="password" required
autocomplete="new-password" placeholder="Password">
<select class="cfg-input" name="shell">
{{if .ShellOptions}}
{{range .ShellOptions}}<option value="{{.Value}}" {{if .IsDefault}}selected{{end}}>{{.Label}}{{if .IsDefault}} (default){{end}}</option>{{end}}
{{else}}
<option value="infix-system:clish">clish</option>
<option value="infix-system:bash">bash</option>
<option value="infix-system:sh">sh</option>
<option value="infix-system:false">false</option>
{{end}}
</select>
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-user-row">Cancel</button>
+8 -1
View File
@@ -10,7 +10,14 @@
<div class="info-grid">
<section class="info-card">
<div class="card-header">Interface Info</div>
<div class="card-header">Interface Info
{{$cfgPath := printf "/ietf-interfaces:interfaces/interface=%s" .Name}}
<a href="/configure/tree?path={{$cfgPath | urlquery}}"
hx-get="/configure/tree?path={{$cfgPath | urlquery}}"
hx-target="#content"
hx-push-url="true"
class="btn btn-sm btn-outline card-header-action">Configure &rarr;</a>
</div>
<table class="info-table">
<tr><th>Name</th><td>{{.Name}}</td></tr>
<tr><th>Type</th><td>{{.Type}}</td></tr>
+2 -2
View File
@@ -7,8 +7,8 @@
<div class="info-card">
<div class="card-header">Routing Table
<a href="/configure/tree?path=/ietf-routing:routing"
hx-get="/configure/tree?path=/ietf-routing:routing"
<a href="/configure/routes"
hx-get="/configure/routes"
hx-target="#content"
hx-push-url="true"
class="btn btn-sm btn-outline card-header-action">Configure &rarr;</a>
+4 -4
View File
@@ -6,7 +6,7 @@
<div class="configure-page">
{{if .Loading}}
<div class="alert alert-info"
hx-get="/configure/tree"
hx-get="{{.TreeBase}}"
hx-trigger="every 3s"
hx-target="#content"
hx-swap="innerHTML">
@@ -17,7 +17,7 @@
{{/* ── Left pane: navigation tree ───────────────────────── */}}
<div class="yang-tree-pane info-card">
<div class="card-header">Advanced</div>
<div class="card-header">{{if .ReadOnly}}Status{{else}}Advanced{{end}}</div>
<ul class="yang-tree">
<li>
<details class="yt-node" open>
@@ -39,7 +39,7 @@
{{/* ── Right pane: node detail ──────────────────────────── */}}
<div class="yang-detail-pane" id="yang-detail"
{{if .InitialPath}}
hx-get="/configure/tree/node?path={{.InitialPath | urlquery}}"
hx-get="{{.TreeBase}}/node?path={{.InitialPath | urlquery}}"
hx-trigger="load"
hx-swap="innerHTML"
{{end}}>
@@ -59,6 +59,6 @@
</div>
{{end}}
{{template "configure-toolbar" .}}
{{if not .ReadOnly}}{{template "configure-toolbar" .}}{{end}}
</div>
{{end}}