mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
webui: configure firewall — zone and policy management
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
@@ -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 1–65535"))
|
||||
return
|
||||
}
|
||||
proto := strings.TrimSpace(r.FormValue("proto"))
|
||||
if proto == "" {
|
||||
renderSaveError(w, fmt.Errorf("protocol is required"))
|
||||
return
|
||||
}
|
||||
pf := map[string]any{"lower": lower, "proto": proto}
|
||||
if up := strings.TrimSpace(r.FormValue("upper")); up != "" {
|
||||
if uv, err := strconv.Atoi(up); err == nil && uv >= lower && uv <= 65535 {
|
||||
pf["upper"] = uv
|
||||
}
|
||||
}
|
||||
to := map[string]any{}
|
||||
if addr := strings.TrimSpace(r.FormValue("to-addr")); addr != "" {
|
||||
to["addr"] = addr
|
||||
}
|
||||
if tp := strings.TrimSpace(r.FormValue("to-port")); tp != "" {
|
||||
if tpv, err := strconv.Atoi(tp); err == nil {
|
||||
to["port"] = tpv
|
||||
}
|
||||
}
|
||||
if len(to) > 0 {
|
||||
pf["to"] = to
|
||||
}
|
||||
body := map[string]any{"infix-firewall:port-forward": []map[string]any{pf}}
|
||||
keyPath := fmt.Sprintf("%s/zone=%s/port-forward=%d,%s", fwConfigPath, url.PathEscape(zone), lower, url.PathEscape(proto))
|
||||
if err := h.RC.Put(r.Context(), keyPath, body); err != nil {
|
||||
log.Printf("configure firewall port-forward add %s/%d/%s: %v", zone, lower, proto, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Port-forward added", "/configure/firewall")
|
||||
}
|
||||
|
||||
// DeletePortForward removes a DNAT rule from the zone.
|
||||
// DELETE /configure/firewall/zones/{name}/port-forwards/{lower}/{proto}
|
||||
func (h *ConfigureFirewallHandler) DeletePortForward(w http.ResponseWriter, r *http.Request) {
|
||||
zone := r.PathValue("name")
|
||||
lower := r.PathValue("lower")
|
||||
proto := r.PathValue("proto")
|
||||
keyPath := fmt.Sprintf("%s/zone=%s/port-forward=%s,%s", fwConfigPath, url.PathEscape(zone), url.PathEscape(lower), url.PathEscape(proto))
|
||||
if err := h.RC.Delete(r.Context(), keyPath); err != nil {
|
||||
log.Printf("configure firewall port-forward delete %s/%s/%s: %v", zone, lower, proto, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Port-forward removed", "/configure/firewall")
|
||||
}
|
||||
|
||||
// AddPolicy creates a new inter-zone forwarding policy.
|
||||
// POST /configure/firewall/policies
|
||||
func (h *ConfigureFirewallHandler) AddPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
ingress := r.Form["ingress"]
|
||||
egress := r.Form["egress"]
|
||||
if name == "" {
|
||||
renderSaveError(w, fmt.Errorf("policy name is required"))
|
||||
return
|
||||
}
|
||||
if len(ingress) == 0 || len(egress) == 0 {
|
||||
renderSaveError(w, fmt.Errorf("policy requires at least one ingress and one egress zone"))
|
||||
return
|
||||
}
|
||||
policy := map[string]any{
|
||||
"name": name,
|
||||
"action": r.FormValue("action"),
|
||||
"ingress": ingress,
|
||||
"egress": egress,
|
||||
}
|
||||
if r.FormValue("masquerade") == "on" {
|
||||
policy["masquerade"] = true
|
||||
}
|
||||
body := map[string]any{"infix-firewall:policy": []map[string]any{policy}}
|
||||
if err := h.RC.Put(r.Context(), fwConfigPath+"/policy="+url.PathEscape(name), body); err != nil {
|
||||
log.Printf("configure firewall policy add %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Policy added", "/configure/firewall")
|
||||
}
|
||||
|
||||
// DeletePolicy removes an inter-zone forwarding policy.
|
||||
// DELETE /configure/firewall/policies/{name}
|
||||
func (h *ConfigureFirewallHandler) DeletePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if err := h.RC.Delete(r.Context(), fwConfigPath+"/policy="+url.PathEscape(name)); err != nil {
|
||||
log.Printf("configure firewall policy delete %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Policy deleted", "/configure/firewall")
|
||||
}
|
||||
|
||||
// SavePolicy updates an existing policy's action, ingress, egress, masquerade.
|
||||
// POST /configure/firewall/policies/{name}
|
||||
func (h *ConfigureFirewallHandler) SavePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
ingress := r.Form["ingress"]
|
||||
egress := r.Form["egress"]
|
||||
if len(ingress) == 0 || len(egress) == 0 {
|
||||
renderSaveError(w, fmt.Errorf("policy requires at least one ingress and one egress zone"))
|
||||
return
|
||||
}
|
||||
policy := map[string]any{
|
||||
"name": name,
|
||||
"action": r.FormValue("action"),
|
||||
"ingress": ingress,
|
||||
"egress": egress,
|
||||
"masquerade": r.FormValue("masquerade") == "true",
|
||||
}
|
||||
body := map[string]any{"infix-firewall:policy": []map[string]any{policy}}
|
||||
if err := h.RC.Put(r.Context(), fwConfigPath+"/policy="+url.PathEscape(name), body); err != nil {
|
||||
log.Printf("configure firewall policy save %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Policy saved", "/configure/firewall")
|
||||
}
|
||||
|
||||
// ─── Services CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
// parseServicePorts converts the form's per-port arrays into the YANG body
|
||||
// shape. Empty rows (no lower port) are silently dropped, so the user can
|
||||
// leave trailing rows blank without invalidating the submission.
|
||||
func parseServicePorts(r *http.Request) []map[string]any {
|
||||
lower := r.Form["port-lower"]
|
||||
upper := r.Form["port-upper"]
|
||||
proto := r.Form["port-proto"]
|
||||
out := []map[string]any{}
|
||||
for i, lo := range lower {
|
||||
lo = strings.TrimSpace(lo)
|
||||
if lo == "" {
|
||||
continue
|
||||
}
|
||||
lower, err := strconv.Atoi(lo)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entry := map[string]any{"lower": lower}
|
||||
if i < len(proto) && proto[i] != "" {
|
||||
entry["proto"] = proto[i]
|
||||
}
|
||||
if i < len(upper) {
|
||||
up := strings.TrimSpace(upper[i])
|
||||
if up != "" {
|
||||
if uv, err := strconv.Atoi(up); err == nil {
|
||||
entry["upper"] = uv
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatServicePorts renders the port list as a one-line summary for the
|
||||
// services table — "tcp:80,443-445; udp:53".
|
||||
func formatServicePorts(ports []fwServicePortJSON) string {
|
||||
if len(ports) == 0 {
|
||||
return ""
|
||||
}
|
||||
byProto := map[string][]string{}
|
||||
order := []string{}
|
||||
for _, p := range ports {
|
||||
if _, ok := byProto[p.Proto]; !ok {
|
||||
order = append(order, p.Proto)
|
||||
}
|
||||
one := fmt.Sprintf("%d", int(p.Lower))
|
||||
if int(p.Upper) > int(p.Lower) {
|
||||
one = fmt.Sprintf("%d-%d", int(p.Lower), int(p.Upper))
|
||||
}
|
||||
byProto[p.Proto] = append(byProto[p.Proto], one)
|
||||
}
|
||||
parts := make([]string, 0, len(order))
|
||||
for _, proto := range order {
|
||||
parts = append(parts, fmt.Sprintf("%s:%s", proto, strings.Join(byProto[proto], ",")))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// AddService creates a new user-defined service.
|
||||
// POST /configure/firewall/services
|
||||
func (h *ConfigureFirewallHandler) AddService(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
renderSaveError(w, fmt.Errorf("service name is required"))
|
||||
return
|
||||
}
|
||||
svc := map[string]any{"name": name}
|
||||
if desc := strings.TrimSpace(r.FormValue("description")); desc != "" {
|
||||
svc["description"] = desc
|
||||
}
|
||||
if dest := strings.TrimSpace(r.FormValue("destination")); dest != "" {
|
||||
svc["destination"] = dest
|
||||
}
|
||||
if ports := parseServicePorts(r); len(ports) > 0 {
|
||||
svc["port"] = ports
|
||||
}
|
||||
body := map[string]any{"infix-firewall:service": []map[string]any{svc}}
|
||||
if err := h.RC.Put(r.Context(), fwConfigPath+"/service="+url.PathEscape(name), body); err != nil {
|
||||
log.Printf("configure firewall service add %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Service added", "/configure/firewall")
|
||||
}
|
||||
|
||||
// SaveService updates an existing user-defined service.
|
||||
// POST /configure/firewall/services/{name}
|
||||
func (h *ConfigureFirewallHandler) SaveService(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
svc := map[string]any{"name": name}
|
||||
if desc := strings.TrimSpace(r.FormValue("description")); desc != "" {
|
||||
svc["description"] = desc
|
||||
}
|
||||
if dest := strings.TrimSpace(r.FormValue("destination")); dest != "" {
|
||||
svc["destination"] = dest
|
||||
}
|
||||
if ports := parseServicePorts(r); len(ports) > 0 {
|
||||
svc["port"] = ports
|
||||
}
|
||||
body := map[string]any{"infix-firewall:service": []map[string]any{svc}}
|
||||
if err := h.RC.Put(r.Context(), fwConfigPath+"/service="+url.PathEscape(name), body); err != nil {
|
||||
log.Printf("configure firewall service save %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "Service saved")
|
||||
}
|
||||
|
||||
// DeleteService removes a user-defined service.
|
||||
// DELETE /configure/firewall/services/{name}
|
||||
func (h *ConfigureFirewallHandler) DeleteService(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if err := h.RC.Delete(r.Context(), fwConfigPath+"/service="+url.PathEscape(name)); err != nil {
|
||||
log.Printf("configure firewall service delete %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Service deleted", "/configure/firewall")
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// fetchInterfaceNames returns configured interface names from candidate (fallback running).
|
||||
func (h *ConfigureFirewallHandler) fetchInterfaceNames(ctx context.Context) []string {
|
||||
var wrap interfacesWrapper
|
||||
if err := h.RC.Get(ctx, candidatePath+"/ietf-interfaces:interfaces", &wrap); err != nil {
|
||||
h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &wrap) //nolint:errcheck
|
||||
}
|
||||
names := make([]string, 0, len(wrap.Interfaces.Interface))
|
||||
for _, iface := range wrap.Interfaces.Interface {
|
||||
names = append(names, iface.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// fetchFirewall reads the firewall presence container from candidate,
|
||||
// falling back to running. Returns (nil, false, nil) when absent everywhere.
|
||||
func (h *ConfigureFirewallHandler) fetchFirewall(ctx context.Context) (*firewallJSON, bool, error) {
|
||||
var wrap cfgFwWrapper
|
||||
err := h.RC.Get(ctx, fwConfigPath, &wrap)
|
||||
if err == nil {
|
||||
return wrap.Firewall, wrap.Firewall != nil, nil
|
||||
}
|
||||
var rcErr *restconf.Error
|
||||
if errors.As(err, &rcErr) && rcErr.StatusCode == http.StatusNotFound {
|
||||
runErr := h.RC.Get(ctx, "/data/infix-firewall:firewall", &wrap)
|
||||
if runErr == nil {
|
||||
return wrap.Firewall, wrap.Firewall != nil, nil
|
||||
}
|
||||
var rcRun *restconf.Error
|
||||
if errors.As(runErr, &rcRun) && rcRun.StatusCode == http.StatusNotFound {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, runErr
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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">○ 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}}● Enabled{{else}}○ 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"
|
||||
|
||||
@@ -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… 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… 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>
|
||||
|
||||
@@ -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 →</a>
|
||||
</div>
|
||||
<table class="info-table">
|
||||
<tr><th>Name</th><td>{{.Name}}</td></tr>
|
||||
<tr><th>Type</th><td>{{.Type}}</td></tr>
|
||||
|
||||
@@ -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 →</a>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user