Explorar el Código

fix(bfd): operational running detection, fallback interface, multihop local address, CLI enable/disable, and Web UI integration across all protocols

Proxmox Router hace 1 día
padre
commit
6ad06285eb

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.8.14
+1.8.15

BIN
bin/debtik


BIN
bin/debtik-arm64


BIN
bin/debtik-cli


BIN
bin/debtik-cli-arm64


+ 83 - 14
internal/cli/executor.go

@@ -3140,11 +3140,23 @@ func (e *Executor) handleRoutingBFD(parts []string, user *auth.PublicUser) strin
 func (e *Executor) handleBFDInstance(parts []string, user *auth.PublicUser) string {
 func (e *Executor) handleBFDInstance(parts []string, user *auth.PublicUser) string {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		inst := e.dynRoutingMgr.GetBFDInstance()
 		inst := e.dynRoutingMgr.GetBFDInstance()
-		out := "  /routing bfd instance\n"
-		out += fmt.Sprintf("       enabled: %t\n", inst.Enabled)
-		out += fmt.Sprintf("      disabled: %t\n", inst.Disabled)
-		out += fmt.Sprintf("       running: %t\n", inst.Running)
-		return out
+		flags := "I"
+		if inst.Disabled {
+			flags = "X"
+		} else if inst.Running {
+			flags = "R"
+		}
+		tbl := NewTable("FLAGS", "ENABLED", "DISABLED", "RUNNING", "STATUS")
+		st := "inactive"
+		if inst.Disabled {
+			st = "disabled"
+		} else if inst.Running {
+			st = "running"
+		} else if inst.Enabled {
+			st = "configured"
+		}
+		tbl.AddRow(flags, fmt.Sprintf("%t", inst.Enabled), fmt.Sprintf("%t", inst.Disabled), fmt.Sprintf("%t", inst.Running), st)
+		return tbl.String()
 	}
 	}
 
 
 	if user.Role == auth.RoleRead {
 	if user.Role == auth.RoleRead {
@@ -3177,17 +3189,19 @@ func (e *Executor) handleBFDInstance(parts []string, user *auth.PublicUser) stri
 func (e *Executor) handleBFDInterface(parts []string, user *auth.PublicUser) string {
 func (e *Executor) handleBFDInterface(parts []string, user *auth.PublicUser) string {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		ifaces := e.dynRoutingMgr.ListBFDInterfaces()
 		ifaces := e.dynRoutingMgr.ListBFDInterfaces()
-		tbl := NewTable("#", "INTERFACE", "MIN-RX", "MIN-TX", "MULTIPLIER", "PASSIVE", "STATUS")
+		tbl := NewTable("#", "FLAGS", "INTERFACE", "MIN-RX", "MIN-TX", "MULTIPLIER", "PASSIVE", "STATUS", "COMMENT")
 		for idx, iface := range ifaces {
 		for idx, iface := range ifaces {
 			st := "enabled"
 			st := "enabled"
+			flags := "B"
 			if iface.Disabled {
 			if iface.Disabled {
 				st = "disabled"
 				st = "disabled"
+				flags = "X"
 			}
 			}
 			passStr := "no"
 			passStr := "no"
 			if iface.Passive {
 			if iface.Passive {
 				passStr = "yes"
 				passStr = "yes"
 			}
 			}
-			tbl.AddRow(fmt.Sprintf("%d", idx), iface.Interface, fmt.Sprintf("%dms", iface.MinRxInterval), fmt.Sprintf("%dms", iface.MinTxInterval), fmt.Sprintf("%d", iface.Multiplier), passStr, st)
+			tbl.AddRow(fmt.Sprintf("%d", idx), flags, iface.Interface, fmt.Sprintf("%dms", iface.MinRxInterval), fmt.Sprintf("%dms", iface.MinTxInterval), fmt.Sprintf("%d", iface.Multiplier), passStr, st, iface.Comment)
 		}
 		}
 		return tbl.String()
 		return tbl.String()
 	}
 	}
@@ -3224,6 +3238,8 @@ func (e *Executor) handleBFDInterface(parts []string, user *auth.PublicUser) str
 			Passive:       parseCLIEnableDisable(params["passive"], false),
 			Passive:       parseCLIEnableDisable(params["passive"], false),
 			Auth:          params["auth"],
 			Auth:          params["auth"],
 			AuthKey:       params["auth-key"],
 			AuthKey:       params["auth-key"],
+			Comment:       params["comment"],
+			Disabled:      parseCLIEnableDisable(params["disabled"], false),
 		}
 		}
 		if _, err := e.dynRoutingMgr.SaveBFDInterface(iface); err != nil {
 		if _, err := e.dynRoutingMgr.SaveBFDInterface(iface); err != nil {
 			return ColorRed + "Error adding BFD interface: " + err.Error() + "\n" + ColorReset
 			return ColorRed + "Error adding BFD interface: " + err.Error() + "\n" + ColorReset
@@ -3286,13 +3302,19 @@ func (e *Executor) handleBFDInterface(parts []string, user *auth.PublicUser) str
 func (e *Executor) handleBFDNeighbor(parts []string, user *auth.PublicUser) string {
 func (e *Executor) handleBFDNeighbor(parts []string, user *auth.PublicUser) string {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		nbrs := e.dynRoutingMgr.ListBFDNeighbors()
 		nbrs := e.dynRoutingMgr.ListBFDNeighbors()
-		tbl := NewTable("#", "ADDRESS", "INTERFACE", "MULTIHOP")
+		tbl := NewTable("#", "FLAGS", "ADDRESS", "INTERFACE", "LOCAL-ADDRESS", "MULTIHOP", "STATUS", "COMMENT")
 		for idx, n := range nbrs {
 		for idx, n := range nbrs {
 			mhop := "no"
 			mhop := "no"
 			if n.Multihop {
 			if n.Multihop {
 				mhop = "yes"
 				mhop = "yes"
 			}
 			}
-			tbl.AddRow(fmt.Sprintf("%d", idx), n.Address, n.Interface, mhop)
+			st := "enabled"
+			flags := "B"
+			if n.Disabled {
+				st = "disabled"
+				flags = "X"
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), flags, n.Address, n.Interface, n.LocalAddress, mhop, st, n.Comment)
 		}
 		}
 		return tbl.String()
 		return tbl.String()
 	}
 	}
@@ -3307,12 +3329,15 @@ func (e *Executor) handleBFDNeighbor(parts []string, user *auth.PublicUser) stri
 		_, params := parseArgs(parts[1:])
 		_, params := parseArgs(parts[1:])
 		addr := params["address"]
 		addr := params["address"]
 		if addr == "" {
 		if addr == "" {
-			return ColorRed + "Usage: /routing bfd peer add address=<ip> [interface=<iface>] [multihop=yes/no]\n" + ColorReset
+			return ColorRed + "Usage: /routing bfd peer add address=<ip> [interface=<iface>] [local-address=<ip>] [multihop=yes/no]\n" + ColorReset
 		}
 		}
 		nbr := dynamicrouting.BFDNeighbor{
 		nbr := dynamicrouting.BFDNeighbor{
-			Address:   addr,
-			Interface: params["interface"],
-			Multihop:  parseCLIEnableDisable(params["multihop"], false),
+			Address:      addr,
+			Interface:    params["interface"],
+			LocalAddress: params["local-address"],
+			Multihop:     parseCLIEnableDisable(params["multihop"], false),
+			Comment:      params["comment"],
+			Disabled:     parseCLIEnableDisable(params["disabled"], false),
 		}
 		}
 		if _, err := e.dynRoutingMgr.SaveBFDNeighbor(nbr); err != nil {
 		if _, err := e.dynRoutingMgr.SaveBFDNeighbor(nbr); err != nil {
 			return ColorRed + "Error adding BFD peer: " + err.Error() + "\n" + ColorReset
 			return ColorRed + "Error adding BFD peer: " + err.Error() + "\n" + ColorReset
@@ -3345,8 +3370,52 @@ func (e *Executor) handleBFDNeighbor(parts []string, user *auth.PublicUser) stri
 		}
 		}
 		return ColorGreen + fmt.Sprintf("BFD peer %s removed.\n", target) + ColorReset
 		return ColorGreen + fmt.Sprintf("BFD peer %s removed.\n", target) + ColorReset
 
 
+	case "enable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing bfd peer enable <#|ip|id>\n" + ColorReset
+		}
+		target := pos[0]
+		nbrs := e.dynRoutingMgr.ListBFDNeighbors()
+		for _, n := range nbrs {
+			if n.Address == target || n.ID == target {
+				n.Disabled = false
+				_, _ = e.dynRoutingMgr.SaveBFDNeighbor(n)
+				return ColorGreen + fmt.Sprintf("BFD peer %s enabled.\n", target) + ColorReset
+			}
+		}
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
+			nbr := nbrs[idx]
+			nbr.Disabled = false
+			_, _ = e.dynRoutingMgr.SaveBFDNeighbor(nbr)
+			return ColorGreen + fmt.Sprintf("BFD peer %s enabled.\n", nbr.Address) + ColorReset
+		}
+		return ColorRed + "BFD peer not found: " + target + "\n" + ColorReset
+
+	case "disable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing bfd peer disable <#|ip|id>\n" + ColorReset
+		}
+		target := pos[0]
+		nbrs := e.dynRoutingMgr.ListBFDNeighbors()
+		for _, n := range nbrs {
+			if n.Address == target || n.ID == target {
+				n.Disabled = true
+				_, _ = e.dynRoutingMgr.SaveBFDNeighbor(n)
+				return ColorGreen + fmt.Sprintf("BFD peer %s disabled.\n", target) + ColorReset
+			}
+		}
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
+			nbr := nbrs[idx]
+			nbr.Disabled = true
+			_, _ = e.dynRoutingMgr.SaveBFDNeighbor(nbr)
+			return ColorGreen + fmt.Sprintf("BFD peer %s disabled.\n", nbr.Address) + ColorReset
+		}
+		return ColorRed + "BFD peer not found: " + target + "\n" + ColorReset
+
 	default:
 	default:
-		return ColorRed + "Usage: /routing bfd peer [print|add|remove]\n" + ColorReset
+		return ColorRed + "Usage: /routing bfd peer [print|add|remove|enable|disable]\n" + ColorReset
 	}
 	}
 }
 }
 
 

+ 60 - 12
internal/dynamicrouting/manager.go

@@ -959,7 +959,9 @@ func (m *Manager) generateBirdConfig() string {
 			if bn.Disabled || strings.TrimSpace(bn.Address) == "" {
 			if bn.Disabled || strings.TrimSpace(bn.Address) == "" {
 				continue
 				continue
 			}
 			}
-			if bn.Interface != "" {
+			if bn.LocalAddress != "" && bn.Multihop {
+				sb.WriteString(fmt.Sprintf("    neighbor %s local %s multihop;\n", strings.TrimSpace(bn.Address), strings.TrimSpace(bn.LocalAddress)))
+			} else if bn.Interface != "" {
 				sb.WriteString(fmt.Sprintf("    neighbor %s dev \"%s\";\n", strings.TrimSpace(bn.Address), strings.TrimSpace(bn.Interface)))
 				sb.WriteString(fmt.Sprintf("    neighbor %s dev \"%s\";\n", strings.TrimSpace(bn.Address), strings.TrimSpace(bn.Interface)))
 			} else if bn.Multihop {
 			} else if bn.Multihop {
 				sb.WriteString(fmt.Sprintf("    neighbor %s multihop;\n", strings.TrimSpace(bn.Address)))
 				sb.WriteString(fmt.Sprintf("    neighbor %s multihop;\n", strings.TrimSpace(bn.Address)))
@@ -967,6 +969,10 @@ func (m *Manager) generateBirdConfig() string {
 				sb.WriteString(fmt.Sprintf("    neighbor %s;\n", strings.TrimSpace(bn.Address)))
 				sb.WriteString(fmt.Sprintf("    neighbor %s;\n", strings.TrimSpace(bn.Address)))
 			}
 			}
 		}
 		}
+		// If no interface block was specified, add default wildcard interface
+		if len(m.config.BFDInterfaces) == 0 {
+			sb.WriteString("    interface \"*\" {\n        min rx interval 100 ms;\n        min tx interval 100 ms;\n    };\n")
+		}
 		sb.WriteString("}\n\n")
 		sb.WriteString("}\n\n")
 	}
 	}
 
 
@@ -1024,6 +1030,7 @@ func (m *Manager) generateBirdConfig() string {
 		sb.WriteString("    };\n\n")
 		sb.WriteString("    };\n\n")
 
 
 		// Interfaces for this RIP instance
 		// Interfaces for this RIP instance
+		hasRIPInterface := false
 		for _, iface := range m.config.RIPInterfaces {
 		for _, iface := range m.config.RIPInterfaces {
 			if iface.Disabled {
 			if iface.Disabled {
 				continue
 				continue
@@ -1031,6 +1038,7 @@ func (m *Manager) generateBirdConfig() string {
 			if iface.Instance != "" && !strings.EqualFold(iface.Instance, inst.Name) && !strings.EqualFold(iface.Instance, "Default") {
 			if iface.Instance != "" && !strings.EqualFold(iface.Instance, inst.Name) && !strings.EqualFold(iface.Instance, "Default") {
 				continue
 				continue
 			}
 			}
+			hasRIPInterface = true
 			ifName := strings.TrimSpace(iface.Interface)
 			ifName := strings.TrimSpace(iface.Interface)
 			if ifName == "" {
 			if ifName == "" {
 				ifName = "*"
 				ifName = "*"
@@ -1069,6 +1077,9 @@ func (m *Manager) generateBirdConfig() string {
 			}
 			}
 			sb.WriteString("    };\n")
 			sb.WriteString("    };\n")
 		}
 		}
+		if !hasRIPInterface {
+			sb.WriteString("    interface \"*\" {\n        mode multicast;\n    };\n")
+		}
 
 
 		sb.WriteString("}\n\n")
 		sb.WriteString("}\n\n")
 	}
 	}
@@ -1175,6 +1186,7 @@ func (m *Manager) generateBirdConfig() string {
 		}
 		}
 		instProtoName := sanitizeIdentifier("radv", fmt.Sprintf("%s_%d", inst.Name, idx))
 		instProtoName := sanitizeIdentifier("radv", fmt.Sprintf("%s_%d", inst.Name, idx))
 		sb.WriteString(fmt.Sprintf("protocol radv %s {\n", instProtoName))
 		sb.WriteString(fmt.Sprintf("protocol radv %s {\n", instProtoName))
+		hasRAdvInterface := false
 		for _, iface := range m.config.RAdvInterfaces {
 		for _, iface := range m.config.RAdvInterfaces {
 			if iface.Disabled {
 			if iface.Disabled {
 				continue
 				continue
@@ -1182,6 +1194,7 @@ func (m *Manager) generateBirdConfig() string {
 			if iface.Instance != "" && !strings.EqualFold(iface.Instance, inst.Name) && !strings.EqualFold(iface.Instance, "Default") {
 			if iface.Instance != "" && !strings.EqualFold(iface.Instance, inst.Name) && !strings.EqualFold(iface.Instance, "Default") {
 				continue
 				continue
 			}
 			}
+			hasRAdvInterface = true
 			ifName := strings.TrimSpace(iface.Interface)
 			ifName := strings.TrimSpace(iface.Interface)
 			if ifName == "" {
 			if ifName == "" {
 				ifName = "*"
 				ifName = "*"
@@ -1220,6 +1233,9 @@ func (m *Manager) generateBirdConfig() string {
 			}
 			}
 			sb.WriteString("    };\n")
 			sb.WriteString("    };\n")
 		}
 		}
+		if !hasRAdvInterface {
+			sb.WriteString("    interface \"*\" {\n    };\n")
+		}
 		sb.WriteString("}\n\n")
 		sb.WriteString("}\n\n")
 	}
 	}
 
 
@@ -2789,7 +2805,7 @@ func (m *Manager) ListRIPInstances() []RIPInstance {
 	res := make([]RIPInstance, len(m.config.RIPInstances))
 	res := make([]RIPInstance, len(m.config.RIPInstances))
 	copy(res, m.config.RIPInstances)
 	copy(res, m.config.RIPInstances)
 	for i := range res {
 	for i := range res {
-		res[i].Running = birdRunning && res[i].Enabled && !res[i].Disabled
+		res[i].Running = birdRunning && (res[i].Enabled || !res[i].Disabled) && !res[i].Disabled
 	}
 	}
 	return res
 	return res
 }
 }
@@ -2798,6 +2814,12 @@ func (m *Manager) SaveRIPInstance(inst RIPInstance) (*RIPInstance, error) {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
 
 
+	if !inst.Disabled {
+		inst.Enabled = true
+	} else {
+		inst.Enabled = false
+	}
+
 	if inst.ID == "" {
 	if inst.ID == "" {
 		inst.ID = fmt.Sprintf("rip-inst-%d", time.Now().UnixNano())
 		inst.ID = fmt.Sprintf("rip-inst-%d", time.Now().UnixNano())
 	}
 	}
@@ -3086,8 +3108,15 @@ func (m *Manager) GetBFDInstance() BFDInstance {
 	defer m.mu.RUnlock()
 	defer m.mu.RUnlock()
 
 
 	inst := m.config.BFDInstance
 	inst := m.config.BFDInstance
+	pOut, pErr := birdcExec("show protocols bfd_main")
+	bfdUp := pErr == nil && strings.Contains(string(pOut), "up")
 	out, err := birdcExec("show status")
 	out, err := birdcExec("show status")
-	inst.Running = err == nil && strings.Contains(string(out), "Daemon is up and running") && inst.Enabled && !inst.Disabled
+	birdRunning := err == nil && strings.Contains(string(out), "Daemon is up and running")
+
+	inst.Running = (bfdUp || (birdRunning && (inst.Enabled || len(m.config.BFDInterfaces) > 0 || len(m.config.BFDNeighbors) > 0))) && !inst.Disabled
+	if inst.Running && !inst.Disabled {
+		inst.Enabled = true
+	}
 	return inst
 	return inst
 }
 }
 
 
@@ -3095,6 +3124,11 @@ func (m *Manager) SaveBFDInstance(inst BFDInstance) error {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
 
 
+	if !inst.Disabled {
+		inst.Enabled = true
+	} else {
+		inst.Enabled = false
+	}
 	m.config.BFDInstance = inst
 	m.config.BFDInstance = inst
 	if err := m.save(); err != nil {
 	if err := m.save(); err != nil {
 		return err
 		return err
@@ -3142,6 +3176,10 @@ func (m *Manager) SaveBFDInterface(iface BFDInterface) (*BFDInterface, error) {
 		m.config.BFDInterfaces = append(m.config.BFDInterfaces, iface)
 		m.config.BFDInterfaces = append(m.config.BFDInterfaces, iface)
 	}
 	}
 
 
+	if !m.config.BFDInstance.Disabled {
+		m.config.BFDInstance.Enabled = true
+	}
+
 	if err := m.save(); err != nil {
 	if err := m.save(); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -3214,6 +3252,10 @@ func (m *Manager) SaveBFDNeighbor(nbr BFDNeighbor) (*BFDNeighbor, error) {
 		m.config.BFDNeighbors = append(m.config.BFDNeighbors, nbr)
 		m.config.BFDNeighbors = append(m.config.BFDNeighbors, nbr)
 	}
 	}
 
 
+	if !m.config.BFDInstance.Disabled {
+		m.config.BFDInstance.Enabled = true
+	}
+
 	if err := m.save(); err != nil {
 	if err := m.save(); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -3250,7 +3292,7 @@ func (m *Manager) GetBFDSessions() []BFDSession {
 	lines := strings.Split(string(out), "\n")
 	lines := strings.Split(string(out), "\n")
 	for _, line := range lines {
 	for _, line := range lines {
 		trimmed := strings.TrimSpace(line)
 		trimmed := strings.TrimSpace(line)
-		if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "IP address") || strings.Contains(trimmed, "There is no BFD") {
+		if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "IP address") || strings.Contains(trimmed, "There is no BFD") || strings.HasSuffix(trimmed, ":") {
 			continue
 			continue
 		}
 		}
 		fields := strings.Fields(trimmed)
 		fields := strings.Fields(trimmed)
@@ -3660,7 +3702,7 @@ func (m *Manager) ListRAdvInstances() []RAdvInstance {
 	res := make([]RAdvInstance, len(m.config.RAdvInstances))
 	res := make([]RAdvInstance, len(m.config.RAdvInstances))
 	copy(res, m.config.RAdvInstances)
 	copy(res, m.config.RAdvInstances)
 	for i := range res {
 	for i := range res {
-		res[i].Running = birdRunning && res[i].Enabled && !res[i].Disabled
+		res[i].Running = birdRunning && (res[i].Enabled || !res[i].Disabled) && !res[i].Disabled
 	}
 	}
 	return res
 	return res
 }
 }
@@ -3669,6 +3711,12 @@ func (m *Manager) SaveRAdvInstance(inst RAdvInstance) (*RAdvInstance, error) {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
 
 
+	if !inst.Disabled {
+		inst.Enabled = true
+	} else {
+		inst.Enabled = false
+	}
+
 	if inst.ID == "" {
 	if inst.ID == "" {
 		inst.ID = fmt.Sprintf("radv-inst-%d", time.Now().UnixNano())
 		inst.ID = fmt.Sprintf("radv-inst-%d", time.Now().UnixNano())
 	}
 	}
@@ -3861,7 +3909,7 @@ func (m *Manager) GetSummary() DynamicRoutingSummary {
 	}
 	}
 
 
 	for _, inst := range m.config.RIPInstances {
 	for _, inst := range m.config.RIPInstances {
-		if inst.Enabled && !inst.Disabled {
+		if (inst.Enabled || !inst.Disabled) && !inst.Disabled {
 			summary.RIPEnabled = true
 			summary.RIPEnabled = true
 			break
 			break
 		}
 		}
@@ -3872,11 +3920,11 @@ func (m *Manager) GetSummary() DynamicRoutingSummary {
 		summary.RIPNeighborCount = len(nbrs)
 		summary.RIPNeighborCount = len(nbrs)
 	}
 	}
 
 
-	if m.config.BFDInstance.Enabled && !m.config.BFDInstance.Disabled {
-		summary.BFDEnabled = true
-	}
-	if summary.BFDEnabled && birdRunning {
-		summary.BFDRunning = true
+	pOut, pErr := birdcExec("show protocols bfd_main")
+	bfdUp := pErr == nil && strings.Contains(string(pOut), "up")
+	summary.BFDEnabled = (m.config.BFDInstance.Enabled || len(m.config.BFDInterfaces) > 0 || len(m.config.BFDNeighbors) > 0) && !m.config.BFDInstance.Disabled
+	summary.BFDRunning = (bfdUp || (birdRunning && summary.BFDEnabled)) && !m.config.BFDInstance.Disabled
+	if summary.BFDRunning {
 		sessions := m.GetBFDSessions()
 		sessions := m.GetBFDSessions()
 		summary.BFDSessionCount = len(sessions)
 		summary.BFDSessionCount = len(sessions)
 	}
 	}
@@ -3894,7 +3942,7 @@ func (m *Manager) GetSummary() DynamicRoutingSummary {
 	}
 	}
 
 
 	for _, inst := range m.config.RAdvInstances {
 	for _, inst := range m.config.RAdvInstances {
-		if inst.Enabled && !inst.Disabled {
+		if (inst.Enabled || !inst.Disabled) && !inst.Disabled {
 			summary.RAdvEnabled = true
 			summary.RAdvEnabled = true
 			break
 			break
 		}
 		}

+ 94 - 0
internal/dynamicrouting/protocols_test.go

@@ -243,3 +243,97 @@ func TestBabelFallbackInterfaceAndInstanceManagement(t *testing.T) {
 		}
 		}
 	}
 	}
 }
 }
+
+func TestBFDAndRIPAndRAdvManagement(t *testing.T) {
+	tmpDir, err := os.MkdirTemp("", "debtik_protocols_test_*")
+	if err != nil {
+		t.Fatalf("Failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	mgr := &Manager{
+		configPath: tmpDir + "/dynamic.json",
+	}
+
+	// 1. Save BFD instance
+	err = mgr.SaveBFDInstance(BFDInstance{Enabled: true})
+	if err != nil {
+		t.Fatalf("SaveBFDInstance failed: %v", err)
+	}
+	bfdInst := mgr.GetBFDInstance()
+	if !bfdInst.Enabled {
+		t.Errorf("Expected bfdInst.Enabled to be true")
+	}
+
+	// 2. Check BFD config generation with 0 interfaces - should contain fallback interface "*"
+	cfg := mgr.generateBirdConfig()
+	if !strings.Contains(cfg, `protocol bfd bfd_main`) {
+		t.Errorf("Expected protocol bfd bfd_main in config, got:\n%s", cfg)
+	}
+	if !strings.Contains(cfg, `interface "*"`) {
+		t.Errorf("Expected fallback interface \"*\" in BFD config, got:\n%s", cfg)
+	}
+
+	// 3. Save BFD multihop neighbor with local address
+	_, err = mgr.SaveBFDNeighbor(BFDNeighbor{
+		Address:      "10.0.0.1",
+		LocalAddress: "10.0.0.2",
+		Multihop:     true,
+	})
+	if err != nil {
+		t.Fatalf("SaveBFDNeighbor failed: %v", err)
+	}
+
+	// 4. Save RIP instance without explicit enabled flag
+	ripInst, err := mgr.SaveRIPInstance(RIPInstance{
+		Name:    "rip1",
+		Version: 2,
+	})
+	if err != nil {
+		t.Fatalf("SaveRIPInstance failed: %v", err)
+	}
+	if !ripInst.Enabled {
+		t.Errorf("Expected ripInst.Enabled to be true, got false")
+	}
+
+	// 5. Save RAdv instance without explicit enabled flag
+	radvInst, err := mgr.SaveRAdvInstance(RAdvInstance{
+		Name: "radv1",
+	})
+	if err != nil {
+		t.Fatalf("SaveRAdvInstance failed: %v", err)
+	}
+	if !radvInst.Enabled {
+		t.Errorf("Expected radvInst.Enabled to be true, got false")
+	}
+
+	// 6. Verify config with BIRD compiler
+	cfgAll := mgr.generateBirdConfig()
+	if !strings.Contains(cfgAll, "neighbor 10.0.0.1 local 10.0.0.2 multihop;") {
+		t.Errorf("Expected multihop neighbor with local address, got:\n%s", cfgAll)
+	}
+	if !strings.Contains(cfgAll, `protocol rip rip_rip1_0`) {
+		t.Errorf("Expected protocol rip in config, got:\n%s", cfgAll)
+	}
+	if !strings.Contains(cfgAll, `protocol radv radv_radv1_0`) {
+		t.Errorf("Expected protocol radv in config, got:\n%s", cfgAll)
+	}
+
+	if _, err := exec.LookPath("bird"); err == nil {
+		tmpFile, err := os.CreateTemp("", "bird_all_val_*.conf")
+		if err != nil {
+			t.Fatalf("Failed to create temp file: %v", err)
+		}
+		defer os.Remove(tmpFile.Name())
+		if _, err := tmpFile.WriteString(cfgAll); err != nil {
+			t.Fatalf("Failed to write temp file: %v", err)
+		}
+		tmpFile.Close()
+
+		cmd := exec.Command("bird", "-p", "-c", tmpFile.Name())
+		out, err := cmd.CombinedOutput()
+		if err != nil {
+			t.Fatalf("bird -p validation failed on all protocols config: %s\nConfig:\n%s", string(out), cfgAll)
+		}
+	}
+}

+ 7 - 6
internal/dynamicrouting/types.go

@@ -378,12 +378,13 @@ type BFDInterface struct {
 
 
 // BFDNeighbor represents an explicit BFD neighbor / peer (/routing bfd peer)
 // BFDNeighbor represents an explicit BFD neighbor / peer (/routing bfd peer)
 type BFDNeighbor struct {
 type BFDNeighbor struct {
-	ID        string `json:"id"`
-	Address   string `json:"address"`
-	Interface string `json:"interface,omitempty"`
-	Multihop  bool   `json:"multihop"`
-	Disabled  bool   `json:"disabled"`
-	Comment   string `json:"comment,omitempty"`
+	ID           string `json:"id"`
+	Address      string `json:"address"`
+	Interface    string `json:"interface,omitempty"`
+	LocalAddress string `json:"local_address,omitempty"`
+	Multihop     bool   `json:"multihop"`
+	Disabled     bool   `json:"disabled"`
+	Comment      string `json:"comment,omitempty"`
 }
 }
 
 
 // BFDSession represents a live BFD session (/routing bfd session)
 // BFDSession represents a live BFD session (/routing bfd session)

+ 35 - 5
internal/web/static/app.js

@@ -10662,6 +10662,7 @@ function openAddBgpPeerModal() {
   if (document.getElementById('modal-bgp-peer-multihop')) document.getElementById('modal-bgp-peer-multihop').checked = false;
   if (document.getElementById('modal-bgp-peer-multihop')) document.getElementById('modal-bgp-peer-multihop').checked = false;
   if (document.getElementById('modal-bgp-peer-passive')) document.getElementById('modal-bgp-peer-passive').checked = false;
   if (document.getElementById('modal-bgp-peer-passive')) document.getElementById('modal-bgp-peer-passive').checked = false;
   if (document.getElementById('modal-bgp-peer-nexthop-self')) document.getElementById('modal-bgp-peer-nexthop-self').checked = false;
   if (document.getElementById('modal-bgp-peer-nexthop-self')) document.getElementById('modal-bgp-peer-nexthop-self').checked = false;
+  if (document.getElementById('modal-bgp-peer-use-bfd')) document.getElementById('modal-bgp-peer-use-bfd').checked = false;
   if (document.getElementById('modal-bgp-peer-holdtime')) document.getElementById('modal-bgp-peer-holdtime').value = '30';
   if (document.getElementById('modal-bgp-peer-holdtime')) document.getElementById('modal-bgp-peer-holdtime').value = '30';
   if (document.getElementById('modal-bgp-peer-keepalive')) document.getElementById('modal-bgp-peer-keepalive').value = '10';
   if (document.getElementById('modal-bgp-peer-keepalive')) document.getElementById('modal-bgp-peer-keepalive').value = '10';
 
 
@@ -10739,6 +10740,7 @@ function openEditBgpPeerModal(id) {
   // Next-hop self
   // Next-hop self
   const isNexthopSelf = !!p.nexthop_self || p.nexthop_choice === 'force-self';
   const isNexthopSelf = !!p.nexthop_self || p.nexthop_choice === 'force-self';
   if (document.getElementById('modal-bgp-peer-nexthop-self')) document.getElementById('modal-bgp-peer-nexthop-self').checked = isNexthopSelf;
   if (document.getElementById('modal-bgp-peer-nexthop-self')) document.getElementById('modal-bgp-peer-nexthop-self').checked = isNexthopSelf;
+  if (document.getElementById('modal-bgp-peer-use-bfd')) document.getElementById('modal-bgp-peer-use-bfd').checked = !!p.use_bfd;
 
 
   if (document.getElementById('modal-bgp-peer-holdtime')) document.getElementById('modal-bgp-peer-holdtime').value = p.hold_time ? String(p.hold_time) : '30';
   if (document.getElementById('modal-bgp-peer-holdtime')) document.getElementById('modal-bgp-peer-holdtime').value = p.hold_time ? String(p.hold_time) : '30';
   if (document.getElementById('modal-bgp-peer-keepalive')) document.getElementById('modal-bgp-peer-keepalive').value = p.keepalive_time ? String(p.keepalive_time) : '10';
   if (document.getElementById('modal-bgp-peer-keepalive')) document.getElementById('modal-bgp-peer-keepalive').value = p.keepalive_time ? String(p.keepalive_time) : '10';
@@ -10801,6 +10803,7 @@ function getBgpPeerFormData() {
   const enabled = document.getElementById('modal-bgp-peer-enabled').checked;
   const enabled = document.getElementById('modal-bgp-peer-enabled').checked;
   const isPassive = document.getElementById('modal-bgp-peer-passive')?.checked || false;
   const isPassive = document.getElementById('modal-bgp-peer-passive')?.checked || false;
   const isNexthopSelf = document.getElementById('modal-bgp-peer-nexthop-self')?.checked || false;
   const isNexthopSelf = document.getElementById('modal-bgp-peer-nexthop-self')?.checked || false;
+  const useBfd = document.getElementById('modal-bgp-peer-use-bfd')?.checked || false;
 
 
   return {
   return {
     id: document.getElementById('modal-bgp-peer-id').value,
     id: document.getElementById('modal-bgp-peer-id').value,
@@ -10817,6 +10820,7 @@ function getBgpPeerFormData() {
     local_role: document.getElementById('modal-bgp-peer-local-role')?.value || 'ebgp',
     local_role: document.getElementById('modal-bgp-peer-local-role')?.value || 'ebgp',
     password: document.getElementById('modal-bgp-peer-password')?.value || '',
     password: document.getElementById('modal-bgp-peer-password')?.value || '',
     multihop: document.getElementById('modal-bgp-peer-multihop')?.checked || false,
     multihop: document.getElementById('modal-bgp-peer-multihop')?.checked || false,
+    use_bfd: useBfd,
     passive: isPassive,
     passive: isPassive,
     connect: !isPassive,
     connect: !isPassive,
     listen: true,
     listen: true,
@@ -11503,6 +11507,7 @@ function openOspfTemplateModal(id = null) {
   document.getElementById('modal-ospf-tmpl-cost').value = tmpl.cost || 1;
   document.getElementById('modal-ospf-tmpl-cost').value = tmpl.cost || 1;
   document.getElementById('modal-ospf-tmpl-priority').value = tmpl.priority || 128;
   document.getElementById('modal-ospf-tmpl-priority').value = tmpl.priority || 128;
   document.getElementById('modal-ospf-tmpl-passive').checked = !!tmpl.passive;
   document.getElementById('modal-ospf-tmpl-passive').checked = !!tmpl.passive;
+  if (document.getElementById('modal-ospf-tmpl-bfd')) document.getElementById('modal-ospf-tmpl-bfd').checked = !!tmpl.use_bfd;
   document.getElementById('modal-ospf-tmpl-hello').value = tmpl.hello_interval || 10;
   document.getElementById('modal-ospf-tmpl-hello').value = tmpl.hello_interval || 10;
   document.getElementById('modal-ospf-tmpl-dead').value = tmpl.dead_interval || 40;
   document.getElementById('modal-ospf-tmpl-dead').value = tmpl.dead_interval || 40;
   document.getElementById('modal-ospf-tmpl-auth').value = tmpl.auth || tmpl.auth_type || 'none';
   document.getElementById('modal-ospf-tmpl-auth').value = tmpl.auth || tmpl.auth_type || 'none';
@@ -11535,6 +11540,7 @@ async function submitOspfTemplate(e) {
     cost: parseInt(document.getElementById('modal-ospf-tmpl-cost').value, 10) || 1,
     cost: parseInt(document.getElementById('modal-ospf-tmpl-cost').value, 10) || 1,
     priority: parseInt(document.getElementById('modal-ospf-tmpl-priority').value, 10) || 128,
     priority: parseInt(document.getElementById('modal-ospf-tmpl-priority').value, 10) || 128,
     passive: document.getElementById('modal-ospf-tmpl-passive').checked,
     passive: document.getElementById('modal-ospf-tmpl-passive').checked,
+    use_bfd: document.getElementById('modal-ospf-tmpl-bfd')?.checked || false,
     hello_interval: parseInt(document.getElementById('modal-ospf-tmpl-hello').value, 10) || 10,
     hello_interval: parseInt(document.getElementById('modal-ospf-tmpl-hello').value, 10) || 10,
     dead_interval: parseInt(document.getElementById('modal-ospf-tmpl-dead').value, 10) || 40,
     dead_interval: parseInt(document.getElementById('modal-ospf-tmpl-dead').value, 10) || 40,
     auth: document.getElementById('modal-ospf-tmpl-auth').value,
     auth: document.getElementById('modal-ospf-tmpl-auth').value,
@@ -19986,7 +19992,8 @@ function renderBfdConfig() {
   if (peerStat) peerStat.innerText = (state.bfdNeighbors || []).length;
   if (peerStat) peerStat.innerText = (state.bfdNeighbors || []).length;
   if (sessStat) sessStat.innerText = (state.bfdSessions || []).length;
   if (sessStat) sessStat.innerText = (state.bfdSessions || []).length;
 
 
-  if (!inst || inst.disabled) {
+  const isEnabled = inst && (inst.enabled || inst.running || (!inst.disabled && (state.bfdInterfaces || []).length > 0));
+  if (!inst || inst.disabled || !isEnabled) {
     if (pill) {
     if (pill) {
       pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
       pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
       pill.innerText = 'DISABLED';
       pill.innerText = 'DISABLED';
@@ -19997,8 +20004,13 @@ function renderBfdConfig() {
     }
     }
   } else {
   } else {
     if (pill) {
     if (pill) {
-      pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold';
-      pill.innerText = inst.running ? 'RUNNING (ACTIVE)' : 'CONFIGURED';
+      if (inst.running) {
+        pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-emerald-950 text-emerald-300 border border-emerald-800/60 font-semibold';
+        pill.innerText = 'RUNNING (ACTIVE)';
+      } else {
+        pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold';
+        pill.innerText = 'CONFIGURED';
+      }
     }
     }
     if (toggleBtn) {
     if (toggleBtn) {
       toggleBtn.innerText = 'Disable BFD';
       toggleBtn.innerText = 'Disable BFD';
@@ -20009,12 +20021,14 @@ function renderBfdConfig() {
 
 
 async function toggleBfdGlobalState() {
 async function toggleBfdGlobalState() {
   const current = state.bfdInstance;
   const current = state.bfdInstance;
-  const newDisabled = current ? !current.disabled : false;
+  const isCurrentlyEnabled = current && (current.enabled || current.running) && !current.disabled;
+  const newEnabled = !isCurrentlyEnabled;
+  const newDisabled = !newEnabled;
   try {
   try {
     const res = await fetch(`${API_BASE}/routing/bfd/instance`, {
     const res = await fetch(`${API_BASE}/routing/bfd/instance`, {
       method: 'POST',
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ enabled: !newDisabled, disabled: newDisabled })
+      body: JSON.stringify({ enabled: newEnabled, disabled: newDisabled })
     });
     });
     if (!res.ok) throw new Error(await res.text());
     if (!res.ok) throw new Error(await res.text());
     showToast('Success', `BFD protocol ${newDisabled ? 'disabled' : 'enabled'}`, 'success');
     showToast('Success', `BFD protocol ${newDisabled ? 'disabled' : 'enabled'}`, 'success');
@@ -20111,6 +20125,10 @@ function renderBfdSessions() {
 function openBfdInterfaceModal() {
 function openBfdInterfaceModal() {
   document.getElementById('bfd-interface-modal-title').innerText = 'New BFD Interface Profile';
   document.getElementById('bfd-interface-modal-title').innerText = 'New BFD Interface Profile';
   document.getElementById('modal-bfd-iface-id').value = '';
   document.getElementById('modal-bfd-iface-id').value = '';
+  const dl = document.getElementById('bfd-iface-datalist');
+  if (dl) {
+    dl.innerHTML = '<option value="*">' + (state.interfaces || []).map(i => `<option value="${escapeHtml(i.name)}">`).join('');
+  }
   document.getElementById('modal-bfd-iface-pattern').value = '*';
   document.getElementById('modal-bfd-iface-pattern').value = '*';
   document.getElementById('modal-bfd-iface-min-rx').value = '100';
   document.getElementById('modal-bfd-iface-min-rx').value = '100';
   document.getElementById('modal-bfd-iface-min-tx').value = '100';
   document.getElementById('modal-bfd-iface-min-tx').value = '100';
@@ -20127,6 +20145,10 @@ function openEditBfdInterfaceModal(id) {
   if (!iface) return;
   if (!iface) return;
   document.getElementById('bfd-interface-modal-title').innerText = `Edit BFD Interface: ${iface.interface}`;
   document.getElementById('bfd-interface-modal-title').innerText = `Edit BFD Interface: ${iface.interface}`;
   document.getElementById('modal-bfd-iface-id').value = iface.id;
   document.getElementById('modal-bfd-iface-id').value = iface.id;
+  const dl = document.getElementById('bfd-iface-datalist');
+  if (dl) {
+    dl.innerHTML = '<option value="*">' + (state.interfaces || []).map(i => `<option value="${escapeHtml(i.name)}">`).join('');
+  }
   document.getElementById('modal-bfd-iface-pattern').value = iface.interface || '*';
   document.getElementById('modal-bfd-iface-pattern').value = iface.interface || '*';
   document.getElementById('modal-bfd-iface-min-rx').value = iface.min_rx_interval || 100;
   document.getElementById('modal-bfd-iface-min-rx').value = iface.min_rx_interval || 100;
   document.getElementById('modal-bfd-iface-min-tx').value = iface.min_tx_interval || 100;
   document.getElementById('modal-bfd-iface-min-tx').value = iface.min_tx_interval || 100;
@@ -20196,6 +20218,10 @@ async function removeSelectedBfdInterface() {
 function openBfdPeerModal() {
 function openBfdPeerModal() {
   document.getElementById('bfd-peer-modal-title').innerText = 'New Static BFD Peer';
   document.getElementById('bfd-peer-modal-title').innerText = 'New Static BFD Peer';
   document.getElementById('modal-bfd-peer-id').value = '';
   document.getElementById('modal-bfd-peer-id').value = '';
+  const dlPeer = document.getElementById('bfd-peer-iface-datalist');
+  if (dlPeer) {
+    dlPeer.innerHTML = (state.interfaces || []).map(i => `<option value="${escapeHtml(i.name)}">`).join('');
+  }
   document.getElementById('modal-bfd-peer-address').value = '';
   document.getElementById('modal-bfd-peer-address').value = '';
   document.getElementById('modal-bfd-peer-interface').value = '';
   document.getElementById('modal-bfd-peer-interface').value = '';
   document.getElementById('modal-bfd-peer-comment').value = '';
   document.getElementById('modal-bfd-peer-comment').value = '';
@@ -20209,6 +20235,10 @@ function openEditBfdPeerModal(id) {
   if (!p) return;
   if (!p) return;
   document.getElementById('bfd-peer-modal-title').innerText = `Edit BFD Peer: ${p.address}`;
   document.getElementById('bfd-peer-modal-title').innerText = `Edit BFD Peer: ${p.address}`;
   document.getElementById('modal-bfd-peer-id').value = p.id;
   document.getElementById('modal-bfd-peer-id').value = p.id;
+  const dlPeer = document.getElementById('bfd-peer-iface-datalist');
+  if (dlPeer) {
+    dlPeer.innerHTML = (state.interfaces || []).map(i => `<option value="${escapeHtml(i.name)}">`).join('');
+  }
   document.getElementById('modal-bfd-peer-address').value = p.address || '';
   document.getElementById('modal-bfd-peer-address').value = p.address || '';
   document.getElementById('modal-bfd-peer-interface').value = p.interface || '';
   document.getElementById('modal-bfd-peer-interface').value = p.interface || '';
   document.getElementById('modal-bfd-peer-comment').value = p.comment || '';
   document.getElementById('modal-bfd-peer-comment').value = p.comment || '';

+ 17 - 3
internal/web/static/index.html

@@ -10912,6 +10912,10 @@
                     <input type="checkbox" id="modal-bgp-peer-nexthop-self" class="rounded text-cyan-500 bg-slate-800 border-slate-700">
                     <input type="checkbox" id="modal-bgp-peer-nexthop-self" class="rounded text-cyan-500 bg-slate-800 border-slate-700">
                     <span>Next-Hop Self (Rewrite next-hop to local router address)</span>
                     <span>Next-Hop Self (Rewrite next-hop to local router address)</span>
                   </label>
                   </label>
+                  <label class="flex items-center gap-2 cursor-pointer">
+                    <input type="checkbox" id="modal-bgp-peer-use-bfd" class="rounded text-amber-500 bg-slate-800 border-slate-700">
+                    <span class="text-amber-300 font-medium">Use BFD (Fast session fault detection via BFD)</span>
+                  </label>
                 </div>
                 </div>
               </div>
               </div>
 
 
@@ -11485,6 +11489,14 @@
                 </div>
                 </div>
               </div>
               </div>
 
 
+              <div class="grid grid-cols-3 items-center gap-3">
+                <label class="text-slate-400 font-medium">Use BFD</label>
+                <div class="col-span-2">
+                  <input type="checkbox" id="modal-ospf-tmpl-bfd" class="rounded text-amber-500 bg-slate-800 border-slate-700">
+                  <span class="text-[11px] text-amber-300 ml-1.5 font-medium">Enable fast link failure detection via BFD</span>
+                </div>
+              </div>
+
               <div class="grid grid-cols-3 items-center gap-3">
               <div class="grid grid-cols-3 items-center gap-3">
                 <label class="text-slate-400 font-medium">Timers (Hello / Dead)</label>
                 <label class="text-slate-400 font-medium">Timers (Hello / Dead)</label>
                 <div class="col-span-2 grid grid-cols-2 gap-2">
                 <div class="col-span-2 grid grid-cols-2 gap-2">
@@ -12004,7 +12016,8 @@
         <input type="hidden" id="modal-bfd-iface-id">
         <input type="hidden" id="modal-bfd-iface-id">
         <div>
         <div>
           <label class="block font-semibold text-slate-300 mb-1">Interface Pattern *</label>
           <label class="block font-semibold text-slate-300 mb-1">Interface Pattern *</label>
-          <input type="text" id="modal-bfd-iface-pattern" required placeholder="e.g. eth*, wg*, or *" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          <input type="text" id="modal-bfd-iface-pattern" list="bfd-iface-datalist" required placeholder="e.g. eth*, wg*, or *" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          <datalist id="bfd-iface-datalist"></datalist>
         </div>
         </div>
         <div class="grid grid-cols-2 gap-4">
         <div class="grid grid-cols-2 gap-4">
           <div>
           <div>
@@ -12065,7 +12078,8 @@
         </div>
         </div>
         <div>
         <div>
           <label class="block font-semibold text-slate-300 mb-1">Interface (Device)</label>
           <label class="block font-semibold text-slate-300 mb-1">Interface (Device)</label>
-          <input type="text" id="modal-bfd-peer-interface" placeholder="Optional e.g. eth0" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          <input type="text" id="modal-bfd-peer-interface" list="bfd-peer-iface-datalist" placeholder="Optional e.g. eth0" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          <datalist id="bfd-peer-iface-datalist"></datalist>
         </div>
         </div>
         <div>
         <div>
           <label class="block font-semibold text-slate-300 mb-1">Comment</label>
           <label class="block font-semibold text-slate-300 mb-1">Comment</label>
@@ -13664,6 +13678,6 @@
   </div>
   </div>
 
 
   <datalist id="fw-addrlist-datalist"></datalist>
   <datalist id="fw-addrlist-datalist"></datalist>
-  <script src="app.js?v=23.0"></script>
+  <script src="app.js?v=24.0"></script>
 </body>
 </body>
 </html>
 </html>