Bläddra i källkod

feat(ospf): comprehensive audit & remediation of OSPF engine, BIRD 2 mapping & CLI CRUD (v1.8.1)

Proxmox Router 2 dagar sedan
förälder
incheckning
252424dd62

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.8.0
+1.8.1

BIN
bin/debtik


BIN
bin/debtik-arm64


BIN
bin/debtik-cli


BIN
bin/debtik-cli-arm64


+ 1 - 0
internal/api/api.go

@@ -390,6 +390,7 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("GET /api/routing/ospf/static-neighbors", s.handleListOSPFStaticNeighbors)
 	s.mux.HandleFunc("POST /api/routing/ospf/static-neighbors", s.handleSaveOSPFStaticNeighbor)
 	s.mux.HandleFunc("DELETE /api/routing/ospf/static-neighbors/{id}", s.handleDeleteOSPFStaticNeighbor)
+	s.mux.HandleFunc("POST /api/routing/ospf/static-neighbors/{id}/state", s.handleSetOSPFStaticNeighborState)
 
 	s.mux.HandleFunc("GET /api/routing/ospf/neighbors", s.handleGetOSPFNeighbors)
 	s.mux.HandleFunc("GET /api/routing/ospf/lsa", s.handleGetOSPFLSA)

+ 20 - 0
internal/api/dynamic_routing_handlers.go

@@ -399,6 +399,26 @@ func (s *Server) handleDeleteOSPFStaticNeighbor(w http.ResponseWriter, r *http.R
 	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
 }
 
+func (s *Server) handleSetOSPFStaticNeighborState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing static neighbor id"})
+		return
+	}
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+	if err := s.dynRoutingMgr.SetOSPFStaticNeighborState(id, req.Disabled); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "disabled": req.Disabled})
+}
+
 // Live Neighbors
 func (s *Server) handleGetOSPFNeighbors(w http.ResponseWriter, r *http.Request) {
 	nbrs := s.dynRoutingMgr.GetOSPFNeighbors()

+ 156 - 0
internal/cli/command_test.go

@@ -8,6 +8,7 @@ import (
 
 	"debtik/internal/aerotun"
 	"debtik/internal/auth"
+	"debtik/internal/dynamicrouting"
 	"debtik/internal/sdwan"
 	"debtik/internal/vpn"
 )
@@ -218,3 +219,158 @@ func TestSDWANCLIConfigurationAndPermissions(t *testing.T) {
 		t.Fatalf("error removing group sdwan0: %s", output)
 	}
 }
+
+func TestRoutingOSPFCLI(t *testing.T) {
+	dynMgr := dynamicrouting.NewManager(filepath.Join(t.TempDir(), "dynamic.json"))
+	e := &Executor{dynRoutingMgr: dynMgr}
+	admin := &auth.PublicUser{Role: auth.RoleFull}
+	viewer := &auth.PublicUser{Role: auth.RoleRead}
+
+	// 1. Instance commands
+	out, _ := e.Execute("/routing ospf instance print", admin)
+	if !strings.Contains(out, "NAME") {
+		t.Fatalf("expected NAME table header in instance print: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf instance add name=ospf-test router-id=10.255.0.1 redistribute-connected=yes redistribute-static=no default-metric=30", admin)
+	if !strings.Contains(out, "saved") {
+		t.Fatalf("failed to add ospf instance: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf instance set ospf-test default-metric=45", admin)
+	if !strings.Contains(out, "updated") {
+		t.Fatalf("failed to set ospf instance: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf instance disable ospf-test", admin)
+	if !strings.Contains(out, "disabled") {
+		t.Fatalf("failed to disable ospf instance: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf instance enable ospf-test", admin)
+	if !strings.Contains(out, "enabled") {
+		t.Fatalf("failed to enable ospf instance: %s", out)
+	}
+
+	// Viewer permission check
+	out, _ = e.Execute("/routing ospf instance remove ospf-test", viewer)
+	if !strings.Contains(out, "Permission denied") {
+		t.Fatalf("expected permission denied for viewer: %s", out)
+	}
+
+	// 2. Area commands
+	out, _ = e.Execute("/routing ospf area print", admin)
+	if !strings.Contains(out, "AREA-ID") {
+		t.Fatalf("expected AREA-ID table header in area print: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf area add name=area1 area-id=0.0.0.1 type=stub default-cost=20 no-summaries=yes", admin)
+	if !strings.Contains(out, "saved") {
+		t.Fatalf("failed to add ospf area: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf area set area1 default-cost=35", admin)
+	if !strings.Contains(out, "updated") {
+		t.Fatalf("failed to set ospf area: %s", out)
+	}
+
+	// 3. Area range commands
+	out, _ = e.Execute("/routing ospf area range print", admin)
+	if !strings.Contains(out, "PREFIX") {
+		t.Fatalf("expected PREFIX in area range print: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf area range add prefix=10.10.0.0/16 cost=10 advertise=no", admin)
+	if !strings.Contains(out, "added") {
+		t.Fatalf("failed to add area range: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf area range set 10.10.0.0/16 cost=20 advertise=yes", admin)
+	if !strings.Contains(out, "updated") {
+		t.Fatalf("failed to set area range: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf area range remove 10.10.0.0/16", admin)
+	if !strings.Contains(out, "removed") {
+		t.Fatalf("failed to remove area range: %s", out)
+	}
+
+	// 4. Interface template commands
+	out, _ = e.Execute("/routing ospf interface print", admin)
+	if !strings.Contains(out, "INTERFACE") {
+		t.Fatalf("expected INTERFACE in interface print: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf interface add interface=eth0,eth1 area=Default cost=15 network-type=nonbroadcast auth=md5 auth-key=secret123", admin)
+	if !strings.Contains(out, "saved") {
+		t.Fatalf("failed to add ospf interface template: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf interface set eth0,eth1 cost=25", admin)
+	if !strings.Contains(out, "updated") {
+		t.Fatalf("failed to set ospf interface template: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf interface remove eth0,eth1", admin)
+	if !strings.Contains(out, "removed") {
+		t.Fatalf("failed to remove ospf interface template: %s", out)
+	}
+
+	// 5. Static neighbor commands
+	out, _ = e.Execute("/routing ospf static-neighbor print", admin)
+	if !strings.Contains(out, "ADDRESS") {
+		t.Fatalf("expected ADDRESS in static-neighbor print: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf static-neighbor add address=192.168.100.2 poll-interval=30 cost=5", admin)
+	if !strings.Contains(out, "saved") {
+		t.Fatalf("failed to add static neighbor: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf static-neighbor set 192.168.100.2 poll-interval=60", admin)
+	if !strings.Contains(out, "updated") {
+		t.Fatalf("failed to update static neighbor: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf static-neighbor disable 192.168.100.2", admin)
+	if !strings.Contains(out, "disabled") {
+		t.Fatalf("failed to disable static neighbor: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf static-neighbor enable 192.168.100.2", admin)
+	if !strings.Contains(out, "enabled") {
+		t.Fatalf("failed to enable static neighbor: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf static-neighbor remove 192.168.100.2", admin)
+	if !strings.Contains(out, "removed") {
+		t.Fatalf("failed to remove static neighbor: %s", out)
+	}
+
+	// 6. Live status commands
+	out, _ = e.Execute("/routing ospf neighbor print", admin)
+	if !strings.Contains(out, "ROUTER-ID") {
+		t.Fatalf("expected ROUTER-ID in neighbor print: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf lsa print", admin)
+	if !strings.Contains(out, "TYPE") {
+		t.Fatalf("expected TYPE in lsa print: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf route print", admin)
+	if !strings.Contains(out, "PREFIX") {
+		t.Fatalf("expected PREFIX in route print: %s", out)
+	}
+
+	// Cleanup
+	out, _ = e.Execute("/routing ospf area remove area1", admin)
+	if !strings.Contains(out, "removed") {
+		t.Fatalf("failed to remove area1: %s", out)
+	}
+
+	out, _ = e.Execute("/routing ospf instance remove ospf-test", admin)
+	if !strings.Contains(out, "removed") {
+		t.Fatalf("failed to remove ospf-test: %s", out)
+	}
+}

+ 50 - 6
internal/cli/completer.go

@@ -846,31 +846,75 @@ func (c *Completer) buildCommandTree() *Node {
 
 	// /routing ospf
 	ospfNode := routingNode.AddChild(&Node{Name: "ospf"})
-	ospfInst := ospfNode.AddChild(&Node{Name: "instance"})
+	ospfInst := ospfNode.AddChild(&Node{Name: "instance", Aliases: []string{"instances"}})
 	addPrint(ospfInst)
+	addRemove(ospfInst)
+	ospfInstAdd := ospfInst.AddChild(&Node{Name: "add"})
+	ospfInstAdd.AddChild(&Node{Name: "name="})
+	ospfInstAdd.AddChild(&Node{Name: "router-id="})
+	ospfInstAdd.AddChild(&Node{Name: "redistribute-connected=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	ospfInstAdd.AddChild(&Node{Name: "redistribute-static=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	ospfInstAdd.AddChild(&Node{Name: "redistribute-bgp=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	ospfInstAdd.AddChild(&Node{Name: "default-metric="})
 	ospfInstSet := ospfInst.AddChild(&Node{Name: "set"})
 	ospfInstSet.AddChild(&Node{Name: "router-id="})
-	ospfInstSet.AddChild(&Node{Name: "enabled=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	ospfInstSet.AddChild(&Node{Name: "redistribute-connected=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	ospfInstSet.AddChild(&Node{Name: "redistribute-static=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	ospfInstSet.AddChild(&Node{Name: "redistribute-bgp=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	ospfInstSet.AddChild(&Node{Name: "default-metric="})
+	ospfInstSet.AddChild(&Node{Name: "disabled=", Dynamic: func() []string { return []string{"yes", "no"} }})
 
-	ospfArea := ospfNode.AddChild(&Node{Name: "area"})
+	ospfArea := ospfNode.AddChild(&Node{Name: "area", Aliases: []string{"areas"}})
 	addPrint(ospfArea)
 	addRemove(ospfArea)
 	ospfAreaAdd := ospfArea.AddChild(&Node{Name: "add"})
 	ospfAreaAdd.AddChild(&Node{Name: "name="})
 	ospfAreaAdd.AddChild(&Node{Name: "area-id="})
-
-	ospfIface := ospfNode.AddChild(&Node{Name: "interface"})
+	ospfAreaAdd.AddChild(&Node{Name: "instance="})
+	ospfAreaAdd.AddChild(&Node{Name: "type=", Dynamic: func() []string { return []string{"default", "stub", "nssa"} }})
+	ospfAreaAdd.AddChild(&Node{Name: "default-cost="})
+	ospfAreaAdd.AddChild(&Node{Name: "no-summaries=", Dynamic: func() []string { return []string{"yes", "no"} }})
+
+	ospfAreaRange := ospfNode.AddChild(&Node{Name: "area-range", Aliases: []string{"range", "ranges"}})
+	addPrint(ospfAreaRange)
+	addRemove(ospfAreaRange)
+	ospfRangeAdd := ospfAreaRange.AddChild(&Node{Name: "add"})
+	ospfRangeAdd.AddChild(&Node{Name: "prefix="})
+	ospfRangeAdd.AddChild(&Node{Name: "area="})
+	ospfRangeAdd.AddChild(&Node{Name: "cost="})
+	ospfRangeAdd.AddChild(&Node{Name: "advertise=", Dynamic: func() []string { return []string{"yes", "no"} }})
+
+	ospfIface := ospfNode.AddChild(&Node{Name: "interface", Aliases: []string{"interfaces", "template", "templates"}})
 	addPrint(ospfIface)
 	addRemove(ospfIface)
 	ospfIfaceAdd := ospfIface.AddChild(&Node{Name: "add"})
 	ospfIfaceAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	ospfIfaceAdd.AddChild(&Node{Name: "area="})
+	ospfIfaceAdd.AddChild(&Node{Name: "networks="})
 	ospfIfaceAdd.AddChild(&Node{Name: "cost="})
 	ospfIfaceAdd.AddChild(&Node{Name: "priority="})
-	ospfIfaceAdd.AddChild(&Node{Name: "network-type=", Dynamic: func() []string { return []string{"broadcast", "point-to-point"} }})
+	ospfIfaceAdd.AddChild(&Node{Name: "network-type=", Dynamic: func() []string { return []string{"broadcast", "point-to-point", "nonbroadcast", "point-to-multipoint"} }})
+	ospfIfaceAdd.AddChild(&Node{Name: "passive=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	ospfIfaceAdd.AddChild(&Node{Name: "auth=", Dynamic: func() []string { return []string{"none", "simple", "md5"} }})
+
+	ospfSNbr := ospfNode.AddChild(&Node{Name: "static-neighbor", Aliases: []string{"static-neighbors"}})
+	addPrint(ospfSNbr)
+	addRemove(ospfSNbr)
+	ospfSNbrAdd := ospfSNbr.AddChild(&Node{Name: "add"})
+	ospfSNbrAdd.AddChild(&Node{Name: "address="})
+	ospfSNbrAdd.AddChild(&Node{Name: "area="})
+	ospfSNbrAdd.AddChild(&Node{Name: "poll-interval="})
+	ospfSNbrAdd.AddChild(&Node{Name: "cost="})
 
 	ospfNeighbor := ospfNode.AddChild(&Node{Name: "neighbor", Aliases: []string{"neighbors"}})
 	addPrint(ospfNeighbor)
 
+	ospfLSA := ospfNode.AddChild(&Node{Name: "lsa", Aliases: []string{"lsadb"}})
+	addPrint(ospfLSA)
+
+	ospfRoute := ospfNode.AddChild(&Node{Name: "route", Aliases: []string{"routes"}})
+	addPrint(ospfRoute)
+
 	// /routing filter
 	routingFilter := routingNode.AddChild(&Node{Name: "filter"})
 	addPrint(routingFilter)

+ 1059 - 18
internal/cli/executor.go

@@ -2234,49 +2234,1090 @@ func (e *Executor) handleRoutingBGP(parts []string, user *auth.PublicUser) strin
 	}
 }
 
+func parseCLIEnableDisable(val string, defaultVal bool) bool {
+	v := strings.ToLower(strings.TrimSpace(val))
+	if v == "yes" || v == "true" || v == "1" || v == "enable" || v == "enabled" {
+		return true
+	}
+	if v == "no" || v == "false" || v == "0" || v == "disable" || v == "disabled" {
+		return false
+	}
+	return defaultVal
+}
+
 func (e *Executor) handleRoutingOSPF(parts []string, user *auth.PublicUser) string {
-	if len(parts) == 0 || parts[0] == "instance" {
+	if len(parts) == 0 {
+		return e.handleOSPFInstance([]string{"print"}, user)
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "instance", "instances":
+		return e.handleOSPFInstance(parts[1:], user)
+	case "area", "areas":
+		if len(parts) > 1 && (strings.EqualFold(parts[1], "range") || strings.EqualFold(parts[1], "ranges")) {
+			return e.handleOSPFAreaRange(parts[2:], user)
+		}
+		return e.handleOSPFArea(parts[1:], user)
+	case "area-range", "area-ranges", "range", "ranges":
+		return e.handleOSPFAreaRange(parts[1:], user)
+	case "interface", "interfaces", "template", "templates", "interface-template", "interface-templates":
+		return e.handleOSPFInterface(parts[1:], user)
+	case "static-neighbor", "static-neighbors":
+		return e.handleOSPFStaticNeighbor(parts[1:], user)
+	case "neighbor", "neighbors":
+		return e.handleOSPFNeighborLive(parts[1:], user)
+	case "lsa", "lsadb":
+		return e.handleOSPFLSA(parts[1:], user)
+	case "route", "routes":
+		return e.handleOSPFRoute(parts[1:], user)
+	default:
+		return ColorRed + "Usage: /routing ospf [instance|area|area-range|interface|static-neighbor|neighbor|lsa|routes]\n" + ColorReset
+	}
+}
+
+func (e *Executor) handleOSPFInstance(parts []string, user *auth.PublicUser) string {
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		instances := e.dynRoutingMgr.ListOSPFInstances()
-		tbl := NewTable("NAME", "ROUTER-ID", "VERSION", "STATUS")
-		for _, inst := range instances {
+		tbl := NewTable("#", "NAME", "ROUTER-ID", "VERSION", "REDISTRIBUTE", "DEFAULT-METRIC", "STATUS")
+		for idx, inst := range instances {
 			st := "enabled"
 			if inst.Disabled {
 				st = "disabled"
 			}
-			tbl.AddRow(inst.Name, inst.RouterID, fmt.Sprintf("%d", inst.Version), st)
+			var redist []string
+			if inst.RedistributeConnected {
+				redist = append(redist, "connected")
+			}
+			if inst.RedistributeStatic {
+				redist = append(redist, "static")
+			}
+			if inst.RedistributeBGP {
+				redist = append(redist, "bgp")
+			}
+			redistStr := "-"
+			if len(redist) > 0 {
+				redistStr = strings.Join(redist, ",")
+			}
+			metricStr := "-"
+			if inst.DefaultMetric > 0 {
+				metricStr = fmt.Sprintf("%d", inst.DefaultMetric)
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), inst.Name, inst.RouterID, fmt.Sprintf("%d", inst.Version), redistStr, metricStr, st)
 		}
 		return tbl.String()
 	}
 
+	if user.Role == auth.RoleRead {
+		return ColorRed + "Permission denied: viewer role cannot modify OSPF\n" + ColorReset
+	}
+
 	sub := strings.ToLower(parts[0])
 	switch sub {
-	case "area", "areas":
+	case "add":
+		_, params := parseArgs(parts[1:])
+		name := params["name"]
+		if name == "" {
+			name = "Default"
+		}
+		routerID := params["router-id"]
+		version := 2
+		if vStr, ok := params["version"]; ok && vStr != "" {
+			if v, err := strconv.Atoi(vStr); err == nil && (v == 2 || v == 3) {
+				version = v
+			}
+		}
+		redistConn := parseCLIEnableDisable(params["redistribute-connected"], false)
+		redistStat := parseCLIEnableDisable(params["redistribute-static"], false)
+		redistBGP := parseCLIEnableDisable(params["redistribute-bgp"], false)
+		metric := 0
+		if mStr, ok := params["default-metric"]; ok && mStr != "" {
+			metric, _ = strconv.Atoi(mStr)
+		}
+		disabled := parseCLIEnableDisable(params["disabled"], false)
+		vrf := params["vrf"]
+		if vrf == "" {
+			vrf = "main"
+		}
+		rt := params["routing-table"]
+		if rt == "" {
+			rt = "main"
+		}
+
+		inst := dynamicrouting.OSPFInstance{
+			Name:                  name,
+			Version:               version,
+			VRF:                   vrf,
+			RouterID:              routerID,
+			RoutingTable:          rt,
+			Enabled:               !disabled,
+			Disabled:              disabled,
+			RedistributeConnected: redistConn,
+			RedistributeStatic:    redistStat,
+			RedistributeBGP:       redistBGP,
+			DefaultMetric:         metric,
+			Comment:               params["comment"],
+		}
+		if err := e.dynRoutingMgr.SaveOSPFInstance(&inst); err != nil {
+			return ColorRed + "Error saving OSPF instance: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF instance %s saved.\n", name) + ColorReset
+
+	case "set":
+		pos, params := parseArgs(parts[1:])
+		if len(pos) == 0 && params["numbers"] == "" {
+			return ColorRed + "Usage: /routing ospf instance set <number|name> [router-id=...] [redistribute-connected=yes/no] ...\n" + ColorReset
+		}
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else {
+			target = params["numbers"]
+		}
+
+		instances := e.dynRoutingMgr.ListOSPFInstances()
+		var targetInst *dynamicrouting.OSPFInstance
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(instances) {
+			targetInst = &instances[idx]
+		} else {
+			for i := range instances {
+				if strings.EqualFold(instances[i].Name, target) || instances[i].ID == target {
+					targetInst = &instances[i]
+					break
+				}
+			}
+		}
+		if targetInst == nil {
+			return ColorRed + fmt.Sprintf("OSPF instance %q not found\n", target) + ColorReset
+		}
+
+		if v, ok := params["name"]; ok && v != "" {
+			targetInst.Name = v
+		}
+		if v, ok := params["router-id"]; ok {
+			targetInst.RouterID = v
+		}
+		if v, ok := params["redistribute-connected"]; ok {
+			targetInst.RedistributeConnected = parseCLIEnableDisable(v, false)
+		}
+		if v, ok := params["redistribute-static"]; ok {
+			targetInst.RedistributeStatic = parseCLIEnableDisable(v, false)
+		}
+		if v, ok := params["redistribute-bgp"]; ok {
+			targetInst.RedistributeBGP = parseCLIEnableDisable(v, false)
+		}
+		if v, ok := params["default-metric"]; ok {
+			targetInst.DefaultMetric, _ = strconv.Atoi(v)
+		}
+		if v, ok := params["disabled"]; ok {
+			targetInst.Disabled = parseCLIEnableDisable(v, false)
+			targetInst.Enabled = !targetInst.Disabled
+		}
+		if v, ok := params["comment"]; ok {
+			targetInst.Comment = v
+		}
+
+		if err := e.dynRoutingMgr.SaveOSPFInstance(targetInst); err != nil {
+			return ColorRed + "Error updating OSPF instance: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF instance %s updated.\n", targetInst.Name) + ColorReset
+
+	case "remove", "rem", "delete":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing ospf instance remove <number|name>\n" + ColorReset
+		}
+
+		instances := e.dynRoutingMgr.ListOSPFInstances()
+		idToDelete := ""
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(instances) {
+			idToDelete = instances[idx].ID
+		} else {
+			for _, inst := range instances {
+				if strings.EqualFold(inst.Name, target) || inst.ID == target {
+					idToDelete = inst.ID
+					break
+				}
+			}
+		}
+		if idToDelete == "" {
+			return ColorRed + fmt.Sprintf("OSPF instance %q not found\n", target) + ColorReset
+		}
+		if err := e.dynRoutingMgr.DeleteOSPFInstance(idToDelete); err != nil {
+			return ColorRed + "Error deleting OSPF instance: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF instance %s removed.\n", target) + ColorReset
+
+	case "enable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing ospf instance enable <number|name>\n" + ColorReset
+		}
+		return e.setOSPFInstanceStateByTarget(pos[0], false)
+
+	case "disable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing ospf instance disable <number|name>\n" + ColorReset
+		}
+		return e.setOSPFInstanceStateByTarget(pos[0], true)
+
+	default:
+		return ColorRed + "Usage: /routing ospf instance [print|add|set|remove|enable|disable]\n" + ColorReset
+	}
+}
+
+func (e *Executor) handleOSPFArea(parts []string, user *auth.PublicUser) string {
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		areas := e.dynRoutingMgr.ListOSPFAreas()
-		tbl := NewTable("NAME", "AREA-ID", "INSTANCE", "TYPE")
-		for _, a := range areas {
-			tbl.AddRow(a.Name, a.AreaID, a.Instance, a.AreaType)
+		tbl := NewTable("#", "NAME", "AREA-ID", "INSTANCE", "TYPE", "DEFAULT-COST", "NO-SUMMARIES", "STATUS")
+		for idx, a := range areas {
+			st := "enabled"
+			if a.Disabled {
+				st = "disabled"
+			}
+			costStr := "-"
+			if a.DefaultCost > 0 {
+				costStr = fmt.Sprintf("%d", a.DefaultCost)
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), a.Name, a.AreaID, a.Instance, a.AreaType, costStr, fmt.Sprintf("%t", a.NoSummaries), st)
+		}
+		return tbl.String()
+	}
+
+	if user.Role == auth.RoleRead {
+		return ColorRed + "Permission denied: viewer role cannot modify OSPF\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add":
+		_, params := parseArgs(parts[1:])
+		name := params["name"]
+		if name == "" {
+			return ColorRed + "Usage: /routing ospf area add name=<name> [area-id=...] [instance=...] [type=default|stub|nssa]\n" + ColorReset
+		}
+		areaID := params["area-id"]
+		if areaID == "" {
+			areaID = "0.0.0.0"
+		}
+		instName := params["instance"]
+		if instName == "" {
+			instName = "Default"
+		}
+		areaType := params["type"]
+		if areaType == "" {
+			areaType = "default"
+		}
+		defaultCost := 0
+		if cStr, ok := params["default-cost"]; ok && cStr != "" {
+			defaultCost, _ = strconv.Atoi(cStr)
+		}
+		noSummaries := parseCLIEnableDisable(params["no-summaries"], false)
+		disabled := parseCLIEnableDisable(params["disabled"], false)
+
+		area := dynamicrouting.OSPFArea{
+			Name:        name,
+			AreaID:      areaID,
+			Instance:    instName,
+			AreaType:    areaType,
+			DefaultCost: defaultCost,
+			NoSummaries: noSummaries,
+			Disabled:    disabled,
+			Comment:     params["comment"],
+		}
+		if err := e.dynRoutingMgr.SaveOSPFArea(area); err != nil {
+			return ColorRed + "Error saving OSPF area: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF area %s saved.\n", name) + ColorReset
+
+	case "set":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing ospf area set <number|name> [area-id=...] [type=...] ...\n" + ColorReset
+		}
+
+		areas := e.dynRoutingMgr.ListOSPFAreas()
+		var targetArea *dynamicrouting.OSPFArea
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(areas) {
+			targetArea = &areas[idx]
+		} else {
+			for i := range areas {
+				if strings.EqualFold(areas[i].Name, target) || areas[i].ID == target {
+					targetArea = &areas[i]
+					break
+				}
+			}
+		}
+		if targetArea == nil {
+			return ColorRed + fmt.Sprintf("OSPF area %q not found\n", target) + ColorReset
+		}
+
+		if v, ok := params["name"]; ok && v != "" {
+			targetArea.Name = v
+		}
+		if v, ok := params["area-id"]; ok && v != "" {
+			targetArea.AreaID = v
+		}
+		if v, ok := params["instance"]; ok && v != "" {
+			targetArea.Instance = v
+		}
+		if v, ok := params["type"]; ok && v != "" {
+			targetArea.AreaType = v
+		}
+		if v, ok := params["default-cost"]; ok {
+			targetArea.DefaultCost, _ = strconv.Atoi(v)
+		}
+		if v, ok := params["no-summaries"]; ok {
+			targetArea.NoSummaries = parseCLIEnableDisable(v, false)
+		}
+		if v, ok := params["disabled"]; ok {
+			targetArea.Disabled = parseCLIEnableDisable(v, false)
+		}
+		if v, ok := params["comment"]; ok {
+			targetArea.Comment = v
+		}
+
+		if err := e.dynRoutingMgr.SaveOSPFArea(*targetArea); err != nil {
+			return ColorRed + "Error updating OSPF area: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF area %s updated.\n", targetArea.Name) + ColorReset
+
+	case "remove", "rem", "delete":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing ospf area remove <number|name>\n" + ColorReset
+		}
+
+		areas := e.dynRoutingMgr.ListOSPFAreas()
+		idToDelete := ""
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(areas) {
+			idToDelete = areas[idx].ID
+		} else {
+			for _, a := range areas {
+				if strings.EqualFold(a.Name, target) || a.ID == target {
+					idToDelete = a.ID
+					break
+				}
+			}
+		}
+		if idToDelete == "" {
+			return ColorRed + fmt.Sprintf("OSPF area %q not found\n", target) + ColorReset
+		}
+		if err := e.dynRoutingMgr.DeleteOSPFArea(idToDelete); err != nil {
+			return ColorRed + "Error deleting OSPF area: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF area %s removed.\n", target) + ColorReset
+
+	case "enable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing ospf area enable <number|name>\n" + ColorReset
+		}
+		return e.setOSPFAreaStateByTarget(pos[0], false)
+
+	case "disable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing ospf area disable <number|name>\n" + ColorReset
+		}
+		return e.setOSPFAreaStateByTarget(pos[0], true)
+
+	default:
+		return ColorRed + "Usage: /routing ospf area [print|add|set|remove|enable|disable]\n" + ColorReset
+	}
+}
+
+func (e *Executor) handleOSPFAreaRange(parts []string, user *auth.PublicUser) string {
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
+		ranges := e.dynRoutingMgr.ListOSPFAreaRanges()
+		tbl := NewTable("#", "PREFIX", "AREA", "COST", "ADVERTISE", "STATUS")
+		for idx, r := range ranges {
+			st := "enabled"
+			if r.Disabled {
+				st = "disabled"
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), r.Prefix, r.Area, fmt.Sprintf("%d", r.Cost), fmt.Sprintf("%t", r.Advertise), st)
 		}
 		return tbl.String()
+	}
+
+	if user.Role == auth.RoleRead {
+		return ColorRed + "Permission denied: viewer role cannot modify OSPF\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add":
+		_, params := parseArgs(parts[1:])
+		prefix := params["prefix"]
+		if prefix == "" {
+			return ColorRed + "Usage: /routing ospf area range add prefix=<cidr> [area=...] [cost=...] [advertise=yes/no]\n" + ColorReset
+		}
+		area := params["area"]
+		if area == "" {
+			area = "Default"
+		}
+		cost := 1
+		if cStr, ok := params["cost"]; ok && cStr != "" {
+			cost, _ = strconv.Atoi(cStr)
+		}
+		adv := true
+		if aStr, ok := params["advertise"]; ok && aStr != "" {
+			adv = parseCLIEnableDisable(aStr, true)
+		}
+		disabled := parseCLIEnableDisable(params["disabled"], false)
+
+		ar := dynamicrouting.OSPFAreaRange{
+			Prefix:    prefix,
+			Area:      area,
+			Cost:      cost,
+			Advertise: adv,
+			Disabled:  disabled,
+			Comment:   params["comment"],
+		}
+		if err := e.dynRoutingMgr.SaveOSPFAreaRange(ar); err != nil {
+			return ColorRed + "Error saving area range: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("Area range %s added.\n", prefix) + ColorReset
+
+	case "set":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing ospf area range set <number|prefix> [cost=...] [advertise=yes/no]\n" + ColorReset
+		}
+
+		ranges := e.dynRoutingMgr.ListOSPFAreaRanges()
+		var targetRange *dynamicrouting.OSPFAreaRange
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(ranges) {
+			targetRange = &ranges[idx]
+		} else {
+			for i := range ranges {
+				if strings.EqualFold(ranges[i].Prefix, target) || ranges[i].ID == target {
+					targetRange = &ranges[i]
+					break
+				}
+			}
+		}
+		if targetRange == nil {
+			return ColorRed + fmt.Sprintf("Area range %q not found\n", target) + ColorReset
+		}
+
+		if v, ok := params["prefix"]; ok && v != "" {
+			targetRange.Prefix = v
+		}
+		if v, ok := params["area"]; ok && v != "" {
+			targetRange.Area = v
+		}
+		if v, ok := params["cost"]; ok {
+			targetRange.Cost, _ = strconv.Atoi(v)
+		}
+		if v, ok := params["advertise"]; ok {
+			targetRange.Advertise = parseCLIEnableDisable(v, true)
+		}
+		if v, ok := params["disabled"]; ok {
+			targetRange.Disabled = parseCLIEnableDisable(v, false)
+		}
+		if v, ok := params["comment"]; ok {
+			targetRange.Comment = v
+		}
+
+		if err := e.dynRoutingMgr.SaveOSPFAreaRange(*targetRange); err != nil {
+			return ColorRed + "Error updating area range: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("Area range %s updated.\n", targetRange.Prefix) + ColorReset
+
+	case "remove", "rem", "delete":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing ospf area range remove <number|prefix>\n" + ColorReset
+		}
+
+		ranges := e.dynRoutingMgr.ListOSPFAreaRanges()
+		idToDelete := ""
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(ranges) {
+			idToDelete = ranges[idx].ID
+		} else {
+			for _, r := range ranges {
+				if strings.EqualFold(r.Prefix, target) || r.ID == target {
+					idToDelete = r.ID
+					break
+				}
+			}
+		}
+		if idToDelete == "" {
+			return ColorRed + fmt.Sprintf("Area range %q not found\n", target) + ColorReset
+		}
+		if err := e.dynRoutingMgr.DeleteOSPFAreaRange(idToDelete); err != nil {
+			return ColorRed + "Error deleting area range: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("Area range %s removed.\n", target) + ColorReset
 
-	case "interface", "interfaces":
+	default:
+		return ColorRed + "Usage: /routing ospf area range [print|add|set|remove]\n" + ColorReset
+	}
+}
+
+func (e *Executor) handleOSPFInterface(parts []string, user *auth.PublicUser) string {
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		ifaces := e.dynRoutingMgr.ListOSPFInterfaces()
-		tbl := NewTable("INTERFACE", "AREA", "COST", "PRIORITY", "NETWORK-TYPE")
-		for _, i := range ifaces {
-			tbl.AddRow(i.Interface, i.Area, fmt.Sprintf("%d", i.Cost), fmt.Sprintf("%d", i.Priority), i.NetworkType)
+		tbl := NewTable("#", "INTERFACE", "AREA", "NETWORKS", "TYPE", "COST", "PRIORITY", "PASSIVE", "AUTH", "STATUS")
+		for idx, i := range ifaces {
+			st := "enabled"
+			if i.Disabled {
+				st = "disabled"
+			}
+			ifaceName := i.Interfaces
+			if ifaceName == "" {
+				ifaceName = i.Interface
+			}
+			if ifaceName == "" {
+				ifaceName = "*"
+			}
+			nets := i.Networks
+			if nets == "" {
+				nets = i.Network
+			}
+			if nets == "" {
+				nets = "-"
+			}
+			authStr := i.Auth
+			if authStr == "" {
+				authStr = i.AuthType
+			}
+			if authStr == "" {
+				authStr = "none"
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), ifaceName, i.Area, nets, i.NetworkType, fmt.Sprintf("%d", i.Cost), fmt.Sprintf("%d", i.Priority), fmt.Sprintf("%t", i.Passive), authStr, st)
 		}
 		return tbl.String()
+	}
 
-	case "neighbor", "neighbors":
-		neighbors := e.dynRoutingMgr.GetOSPFNeighbors()
-		tbl := NewTable("ROUTER-ID", "ADDRESS", "INTERFACE", "STATE", "PRIORITY")
-		for _, n := range neighbors {
-			tbl.AddRow(n.NeighborID, n.Address, n.Interface, n.State, fmt.Sprintf("%d", n.Priority))
+	if user.Role == auth.RoleRead {
+		return ColorRed + "Permission denied: viewer role cannot modify OSPF\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add":
+		_, params := parseArgs(parts[1:])
+		ifaceName := params["interface"]
+		if ifaceName == "" {
+			ifaceName = params["interfaces"]
+		}
+		if ifaceName == "" {
+			ifaceName = "*"
+		}
+		area := params["area"]
+		if area == "" {
+			area = "Default"
+		}
+		nets := params["networks"]
+		if nets == "" {
+			nets = params["network"]
+		}
+		netType := params["network-type"]
+		if netType == "" {
+			netType = "broadcast"
+		}
+		cost := 1
+		if cStr, ok := params["cost"]; ok && cStr != "" {
+			cost, _ = strconv.Atoi(cStr)
+		}
+		pri := 128
+		if pStr, ok := params["priority"]; ok && pStr != "" {
+			pri, _ = strconv.Atoi(pStr)
+		}
+		hello := 10
+		if hStr, ok := params["hello-interval"]; ok && hStr != "" {
+			hello, _ = strconv.Atoi(hStr)
+		}
+		dead := 40
+		if dStr, ok := params["dead-interval"]; ok && dStr != "" {
+			dead, _ = strconv.Atoi(dStr)
+		}
+		passive := parseCLIEnableDisable(params["passive"], false)
+		authType := params["auth"]
+		if authType == "" {
+			authType = params["auth-type"]
+		}
+		authKey := params["auth-key"]
+		disabled := parseCLIEnableDisable(params["disabled"], false)
+
+		iface := dynamicrouting.OSPFInterface{
+			Interfaces:    ifaceName,
+			Interface:     ifaceName,
+			Area:          area,
+			Networks:      nets,
+			Network:       nets,
+			NetworkType:   netType,
+			Cost:          cost,
+			Priority:      pri,
+			HelloInterval: hello,
+			DeadInterval:  dead,
+			Passive:       passive,
+			Auth:          authType,
+			AuthType:      authType,
+			AuthKey:       authKey,
+			Disabled:      disabled,
+			Comment:       params["comment"],
+		}
+		if err := e.dynRoutingMgr.SaveOSPFInterface(iface); err != nil {
+			return ColorRed + "Error saving OSPF interface template: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF interface template %s saved.\n", ifaceName) + ColorReset
+
+	case "set":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing ospf interface set <number|id> [cost=...] [priority=...] ...\n" + ColorReset
+		}
+
+		ifaces := e.dynRoutingMgr.ListOSPFInterfaces()
+		var targetIface *dynamicrouting.OSPFInterface
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(ifaces) {
+			targetIface = &ifaces[idx]
+		} else {
+			for i := range ifaces {
+				if ifaces[i].ID == target || ifaces[i].Interfaces == target || ifaces[i].Interface == target {
+					targetIface = &ifaces[i]
+					break
+				}
+			}
+		}
+		if targetIface == nil {
+			return ColorRed + fmt.Sprintf("OSPF interface template %q not found\n", target) + ColorReset
+		}
+
+		if v, ok := params["interface"]; ok && v != "" {
+			targetIface.Interface = v
+			targetIface.Interfaces = v
+		} else if v, ok := params["interfaces"]; ok && v != "" {
+			targetIface.Interface = v
+			targetIface.Interfaces = v
+		}
+		if v, ok := params["area"]; ok && v != "" {
+			targetIface.Area = v
+		}
+		if v, ok := params["networks"]; ok {
+			targetIface.Networks = v
+			targetIface.Network = v
+		} else if v, ok := params["network"]; ok {
+			targetIface.Networks = v
+			targetIface.Network = v
+		}
+		if v, ok := params["network-type"]; ok && v != "" {
+			targetIface.NetworkType = v
+		}
+		if v, ok := params["cost"]; ok {
+			targetIface.Cost, _ = strconv.Atoi(v)
+		}
+		if v, ok := params["priority"]; ok {
+			targetIface.Priority, _ = strconv.Atoi(v)
+		}
+		if v, ok := params["hello-interval"]; ok {
+			targetIface.HelloInterval, _ = strconv.Atoi(v)
+		}
+		if v, ok := params["dead-interval"]; ok {
+			targetIface.DeadInterval, _ = strconv.Atoi(v)
+		}
+		if v, ok := params["passive"]; ok {
+			targetIface.Passive = parseCLIEnableDisable(v, false)
+		}
+		if v, ok := params["auth"]; ok {
+			targetIface.Auth = v
+			targetIface.AuthType = v
+		} else if v, ok := params["auth-type"]; ok {
+			targetIface.Auth = v
+			targetIface.AuthType = v
+		}
+		if v, ok := params["auth-key"]; ok {
+			targetIface.AuthKey = v
+		}
+		if v, ok := params["disabled"]; ok {
+			targetIface.Disabled = parseCLIEnableDisable(v, false)
+		}
+		if v, ok := params["comment"]; ok {
+			targetIface.Comment = v
+		}
+
+		if err := e.dynRoutingMgr.SaveOSPFInterface(*targetIface); err != nil {
+			return ColorRed + "Error updating OSPF interface template: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF interface template %s updated.\n", targetIface.ID) + ColorReset
+
+	case "remove", "rem", "delete":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing ospf interface remove <number|id>\n" + ColorReset
+		}
+
+		ifaces := e.dynRoutingMgr.ListOSPFInterfaces()
+		idToDelete := ""
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(ifaces) {
+			idToDelete = ifaces[idx].ID
+		} else {
+			for _, i := range ifaces {
+				if i.ID == target || i.Interfaces == target || i.Interface == target {
+					idToDelete = i.ID
+					break
+				}
+			}
+		}
+		if idToDelete == "" {
+			return ColorRed + fmt.Sprintf("OSPF interface template %q not found\n", target) + ColorReset
+		}
+		if err := e.dynRoutingMgr.DeleteOSPFInterface(idToDelete); err != nil {
+			return ColorRed + "Error deleting OSPF interface template: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF interface template %s removed.\n", target) + ColorReset
+
+	case "enable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing ospf interface enable <number|id>\n" + ColorReset
+		}
+		return e.setOSPFInterfaceStateByTarget(pos[0], false)
+
+	case "disable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing ospf interface disable <number|id>\n" + ColorReset
+		}
+		return e.setOSPFInterfaceStateByTarget(pos[0], true)
+
+	default:
+		return ColorRed + "Usage: /routing ospf interface [print|add|set|remove|enable|disable]\n" + ColorReset
+	}
+}
+
+func (e *Executor) handleOSPFStaticNeighbor(parts []string, user *auth.PublicUser) string {
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
+		nbrs := e.dynRoutingMgr.ListOSPFStaticNeighbors()
+		tbl := NewTable("#", "ADDRESS", "AREA", "POLL-INTERVAL", "COST", "STATUS")
+		for idx, n := range nbrs {
+			st := "enabled"
+			if n.Disabled {
+				st = "disabled"
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), n.Address, n.Area, fmt.Sprintf("%d", n.PollInterval), fmt.Sprintf("%d", n.Cost), st)
 		}
 		return tbl.String()
+	}
+
+	if user.Role == auth.RoleRead {
+		return ColorRed + "Permission denied: viewer role cannot modify OSPF\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add":
+		_, params := parseArgs(parts[1:])
+		addr := params["address"]
+		if addr == "" {
+			return ColorRed + "Usage: /routing ospf static-neighbor add address=<ip> [area=...] [poll-interval=...] [cost=...]\n" + ColorReset
+		}
+		area := params["area"]
+		if area == "" {
+			area = "Default"
+		}
+		poll := 120
+		if pStr, ok := params["poll-interval"]; ok && pStr != "" {
+			poll, _ = strconv.Atoi(pStr)
+		}
+		cost := 1
+		if cStr, ok := params["cost"]; ok && cStr != "" {
+			cost, _ = strconv.Atoi(cStr)
+		}
+		disabled := parseCLIEnableDisable(params["disabled"], false)
+
+		snbr := dynamicrouting.OSPFStaticNeighbor{
+			Address:      addr,
+			Area:         area,
+			PollInterval: poll,
+			Cost:         cost,
+			Disabled:     disabled,
+			Comment:      params["comment"],
+		}
+		if err := e.dynRoutingMgr.SaveOSPFStaticNeighbor(snbr); err != nil {
+			return ColorRed + "Error saving static neighbor: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF static neighbor %s saved.\n", addr) + ColorReset
+
+	case "set":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing ospf static-neighbor set <number|address> [poll-interval=...] [cost=...]\n" + ColorReset
+		}
+
+		nbrs := e.dynRoutingMgr.ListOSPFStaticNeighbors()
+		var targetNbr *dynamicrouting.OSPFStaticNeighbor
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
+			targetNbr = &nbrs[idx]
+		} else {
+			for i := range nbrs {
+				if nbrs[i].Address == target || nbrs[i].ID == target {
+					targetNbr = &nbrs[i]
+					break
+				}
+			}
+		}
+		if targetNbr == nil {
+			return ColorRed + fmt.Sprintf("Static neighbor %q not found\n", target) + ColorReset
+		}
+
+		if v, ok := params["address"]; ok && v != "" {
+			targetNbr.Address = v
+		}
+		if v, ok := params["area"]; ok && v != "" {
+			targetNbr.Area = v
+		}
+		if v, ok := params["poll-interval"]; ok {
+			targetNbr.PollInterval, _ = strconv.Atoi(v)
+		}
+		if v, ok := params["cost"]; ok {
+			targetNbr.Cost, _ = strconv.Atoi(v)
+		}
+		if v, ok := params["disabled"]; ok {
+			targetNbr.Disabled = parseCLIEnableDisable(v, false)
+		}
+		if v, ok := params["comment"]; ok {
+			targetNbr.Comment = v
+		}
+
+		if err := e.dynRoutingMgr.SaveOSPFStaticNeighbor(*targetNbr); err != nil {
+			return ColorRed + "Error updating static neighbor: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF static neighbor %s updated.\n", targetNbr.Address) + ColorReset
+
+	case "remove", "rem", "delete":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing ospf static-neighbor remove <number|address>\n" + ColorReset
+		}
+
+		nbrs := e.dynRoutingMgr.ListOSPFStaticNeighbors()
+		idToDelete := ""
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
+			idToDelete = nbrs[idx].ID
+		} else {
+			for _, n := range nbrs {
+				if n.Address == target || n.ID == target {
+					idToDelete = n.ID
+					break
+				}
+			}
+		}
+		if idToDelete == "" {
+			return ColorRed + fmt.Sprintf("Static neighbor %q not found\n", target) + ColorReset
+		}
+		if err := e.dynRoutingMgr.DeleteOSPFStaticNeighbor(idToDelete); err != nil {
+			return ColorRed + "Error deleting static neighbor: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("OSPF static neighbor %s removed.\n", target) + ColorReset
+
+	case "enable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing ospf static-neighbor enable <number|address>\n" + ColorReset
+		}
+		return e.setOSPFStaticNeighborStateByTarget(pos[0], false)
+
+	case "disable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /routing ospf static-neighbor disable <number|address>\n" + ColorReset
+		}
+		return e.setOSPFStaticNeighborStateByTarget(pos[0], true)
 
 	default:
-		return ColorRed + "Usage: /routing ospf [instance|area|interface|neighbor]\n" + ColorReset
+		return ColorRed + "Usage: /routing ospf static-neighbor [print|add|set|remove|enable|disable]\n" + ColorReset
+	}
+}
+
+func (e *Executor) handleOSPFNeighborLive(parts []string, user *auth.PublicUser) string {
+	neighbors := e.dynRoutingMgr.GetOSPFNeighbors()
+	tbl := NewTable("ROUTER-ID", "ADDRESS", "INTERFACE", "STATE", "PRIORITY", "DEAD-TIMER")
+	for _, n := range neighbors {
+		tbl.AddRow(n.NeighborID, n.Address, n.Interface, n.State, fmt.Sprintf("%d", n.Priority), n.DeadTimer)
+	}
+	return tbl.String()
+}
+
+func (e *Executor) handleOSPFLSA(parts []string, user *auth.PublicUser) string {
+	lsas := e.dynRoutingMgr.GetOSPFLSA()
+	tbl := NewTable("TYPE", "LS-ID", "ROUTER-ID", "AGE", "SEQUENCE")
+	for _, l := range lsas {
+		tbl.AddRow(l.Type, l.ID, l.Originator, fmt.Sprintf("%d", l.Age), l.Sequence)
+	}
+	return tbl.String()
+}
+
+func (e *Executor) handleOSPFRoute(parts []string, user *auth.PublicUser) string {
+	routes := e.dynRoutingMgr.GetOSPFRoutes()
+	tbl := NewTable("PREFIX", "NEXT-HOP", "INTERFACE", "COST", "TYPE")
+	for _, r := range routes {
+		tbl.AddRow(r.Prefix, r.NextHop, r.Interface, fmt.Sprintf("%d", r.Cost), r.RouteType)
+	}
+	return tbl.String()
+}
+
+func (e *Executor) setOSPFInstanceStateByTarget(target string, disabled bool) string {
+	instances := e.dynRoutingMgr.ListOSPFInstances()
+	id := ""
+	if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(instances) {
+		id = instances[idx].ID
+	} else {
+		for _, inst := range instances {
+			if strings.EqualFold(inst.Name, target) || inst.ID == target {
+				id = inst.ID
+				break
+			}
+		}
+	}
+	if id == "" {
+		return ColorRed + fmt.Sprintf("OSPF instance %q not found\n", target) + ColorReset
+	}
+	if err := e.dynRoutingMgr.SetOSPFInstanceState(id, disabled); err != nil {
+		return ColorRed + "Error: " + err.Error() + "\n" + ColorReset
+	}
+	action := "enabled"
+	if disabled {
+		action = "disabled"
+	}
+	return ColorGreen + fmt.Sprintf("OSPF instance %s %s.\n", target, action) + ColorReset
+}
+
+func (e *Executor) setOSPFAreaStateByTarget(target string, disabled bool) string {
+	areas := e.dynRoutingMgr.ListOSPFAreas()
+	id := ""
+	if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(areas) {
+		id = areas[idx].ID
+	} else {
+		for _, a := range areas {
+			if strings.EqualFold(a.Name, target) || a.ID == target {
+				id = a.ID
+				break
+			}
+		}
+	}
+	if id == "" {
+		return ColorRed + fmt.Sprintf("OSPF area %q not found\n", target) + ColorReset
+	}
+	if err := e.dynRoutingMgr.SetOSPFAreaState(id, disabled); err != nil {
+		return ColorRed + "Error: " + err.Error() + "\n" + ColorReset
+	}
+	action := "enabled"
+	if disabled {
+		action = "disabled"
+	}
+	return ColorGreen + fmt.Sprintf("OSPF area %s %s.\n", target, action) + ColorReset
+}
+
+func (e *Executor) setOSPFInterfaceStateByTarget(target string, disabled bool) string {
+	ifaces := e.dynRoutingMgr.ListOSPFInterfaces()
+	id := ""
+	if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(ifaces) {
+		id = ifaces[idx].ID
+	} else {
+		for _, i := range ifaces {
+			if i.ID == target || i.Interfaces == target || i.Interface == target {
+				id = i.ID
+				break
+			}
+		}
+	}
+	if id == "" {
+		return ColorRed + fmt.Sprintf("OSPF interface template %q not found\n", target) + ColorReset
+	}
+	if err := e.dynRoutingMgr.SetOSPFInterfaceState(id, disabled); err != nil {
+		return ColorRed + "Error: " + err.Error() + "\n" + ColorReset
+	}
+	action := "enabled"
+	if disabled {
+		action = "disabled"
+	}
+	return ColorGreen + fmt.Sprintf("OSPF interface template %s %s.\n", target, action) + ColorReset
+}
+
+func (e *Executor) setOSPFStaticNeighborStateByTarget(target string, disabled bool) string {
+	nbrs := e.dynRoutingMgr.ListOSPFStaticNeighbors()
+	id := ""
+	if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
+		id = nbrs[idx].ID
+	} else {
+		for _, n := range nbrs {
+			if n.Address == target || n.ID == target {
+				id = n.ID
+				break
+			}
+		}
+	}
+	if id == "" {
+		return ColorRed + fmt.Sprintf("Static neighbor %q not found\n", target) + ColorReset
+	}
+	if err := e.dynRoutingMgr.SetOSPFStaticNeighborState(id, disabled); err != nil {
+		return ColorRed + "Error: " + err.Error() + "\n" + ColorReset
+	}
+	action := "enabled"
+	if disabled {
+		action = "disabled"
 	}
+	return ColorGreen + fmt.Sprintf("Static neighbor %s %s.\n", target, action) + ColorReset
 }
 
 func (e *Executor) handleRoutingFilter(parts []string, user *auth.PublicUser) string {

+ 218 - 32
internal/dynamicrouting/manager.go

@@ -185,8 +185,58 @@ func sanitizeIdentifier(prefix, raw string) string {
 	return res
 }
 
-// applyBirdConfig generates /etc/bird/bird.conf and live reloads BIRD daemon
-func (m *Manager) applyBirdConfig() error {
+// formatInterfaceTargets formats interface names and network prefixes for BIRD 2 interface pattern
+func formatInterfaceTargets(ifaceNames, netPrefixes string) string {
+	var targets []string
+	if ifaceNames != "" {
+		for _, raw := range strings.Split(ifaceNames, ",") {
+			item := strings.TrimSpace(raw)
+			if item != "" {
+				if item == "*" {
+					targets = append(targets, "\"*\"")
+				} else if strings.HasPrefix(item, "\"") && strings.HasSuffix(item, "\"") {
+					targets = append(targets, item)
+				} else {
+					targets = append(targets, fmt.Sprintf("%q", item))
+				}
+			}
+		}
+	}
+	if netPrefixes != "" {
+		for _, raw := range strings.Split(netPrefixes, ",") {
+			item := strings.TrimSpace(raw)
+			if item != "" {
+				targets = append(targets, item)
+			}
+		}
+	}
+	if len(targets) == 0 {
+		return "\"*\""
+	}
+	return strings.Join(targets, ", ")
+}
+
+// ipInSubnets returns true if ipStr is contained in any comma-separated subnet in networksStr
+func ipInSubnets(ipStr, networksStr string) bool {
+	ip := net.ParseIP(strings.TrimSpace(ipStr))
+	if ip == nil {
+		return false
+	}
+	for _, raw := range strings.Split(networksStr, ",") {
+		cidr := strings.TrimSpace(raw)
+		if cidr == "" {
+			continue
+		}
+		_, ipNet, err := net.ParseCIDR(cidr)
+		if err == nil && ipNet.Contains(ip) {
+			return true
+		}
+	}
+	return false
+}
+
+// generateBirdConfig creates candidate bird.conf text from current configuration
+func (m *Manager) generateBirdConfig() string {
 	var sb strings.Builder
 
 	routerID := "192.168.131.56"
@@ -487,15 +537,25 @@ func (m *Manager) applyBirdConfig() error {
 	// OSPFv2 Protocols (RouterOS v7 Multi-Instance OSPF)
 	ospfInstances := m.config.OSPFInstances
 	if len(ospfInstances) == 0 {
+		legacy := m.config.OSPFInstance
+		instRouterID := legacy.RouterID
+		if instRouterID == "" {
+			instRouterID = routerID
+		}
 		ospfInstances = []OSPFInstance{
 			{
-				ID:           "ospf-inst-default",
-				Name:         "Default",
-				Version:      2,
-				VRF:          "main",
-				RouterID:     routerID,
-				RoutingTable: "main",
-				Enabled:      true,
+				ID:                    "ospf-inst-default",
+				Name:                  "Default",
+				Version:               2,
+				VRF:                   "main",
+				RouterID:              instRouterID,
+				RoutingTable:          "main",
+				Enabled:               legacy.Enabled,
+				Disabled:              legacy.Disabled,
+				RedistributeConnected: legacy.RedistributeConnected,
+				RedistributeStatic:    legacy.RedistributeStatic,
+				RedistributeBGP:       legacy.RedistributeBGP,
+				DefaultMetric:         legacy.DefaultMetric,
 			},
 		}
 	}
@@ -512,21 +572,33 @@ func (m *Manager) applyBirdConfig() error {
 
 		instProtoName := sanitizeIdentifier("ospf", inst.Name)
 		sb.WriteString(fmt.Sprintf("protocol ospf v%d %s {\n", inst.Version, instProtoName))
+		if inst.VRF != "" && inst.VRF != "main" && inst.VRF != "default" {
+			sb.WriteString(fmt.Sprintf("    vrf %s;\n", inst.VRF))
+		}
 		sb.WriteString(fmt.Sprintf("    router id %s;\n", instRouterID))
 		sb.WriteString("    ipv4 {\n")
+		if inst.RoutingTable != "" && inst.RoutingTable != "main" {
+			sb.WriteString(fmt.Sprintf("        table %s;\n", inst.RoutingTable))
+		}
 		sb.WriteString("        import filter { krt_metric = 110; accept; };\n")
 
 		var ospfExports []string
-		ospfExports = append(ospfExports, "RTS_STATIC", "RTS_INHERIT")
-		if inst.RedistributeConnected || m.config.OSPFInstance.RedistributeConnected {
+		if inst.RedistributeStatic {
+			ospfExports = append(ospfExports, "RTS_STATIC", "RTS_INHERIT")
+		}
+		if inst.RedistributeConnected {
 			ospfExports = append(ospfExports, "RTS_DEVICE")
 		}
-		if inst.RedistributeBGP || m.config.OSPFInstance.RedistributeBGP {
+		if inst.RedistributeBGP {
 			ospfExports = append(ospfExports, "RTS_BGP")
 		}
 
 		if len(ospfExports) > 0 {
-			sb.WriteString(fmt.Sprintf("        export where source ~ [ %s ];\n", strings.Join(ospfExports, ", ")))
+			if inst.DefaultMetric > 0 {
+				sb.WriteString(fmt.Sprintf("        export filter {\n            if source ~ [ %s ] then {\n                ospf_metric1 = %d;\n                accept;\n            }\n            reject;\n        };\n", strings.Join(ospfExports, ", "), inst.DefaultMetric))
+			} else {
+				sb.WriteString(fmt.Sprintf("        export where source ~ [ %s ];\n", strings.Join(ospfExports, ", ")))
+			}
 		} else {
 			sb.WriteString("        export none;\n")
 		}
@@ -564,6 +636,12 @@ func (m *Manager) applyBirdConfig() error {
 			} else if area.AreaType == "nssa" {
 				sb.WriteString("        nssa;\n")
 			}
+			if area.DefaultCost > 0 {
+				sb.WriteString(fmt.Sprintf("        default cost %d;\n", area.DefaultCost))
+			}
+			if area.NoSummaries {
+				sb.WriteString("        summary no;\n")
+			}
 
 			// Area ranges (stubnet)
 			for _, ar := range m.config.OSPFAreaRanges {
@@ -573,8 +651,15 @@ func (m *Manager) applyBirdConfig() error {
 				if strings.EqualFold(ar.Area, area.Name) || strings.EqualFold(ar.Area, area.AreaID) || ar.Area == "" {
 					prefix := strings.TrimSpace(ar.Prefix)
 					if prefix != "" {
+						var stubnetOpts []string
 						if ar.Cost > 0 {
-							sb.WriteString(fmt.Sprintf("        stubnet %s { cost %d; };\n", prefix, ar.Cost))
+							stubnetOpts = append(stubnetOpts, fmt.Sprintf("cost %d;", ar.Cost))
+						}
+						if !ar.Advertise {
+							stubnetOpts = append(stubnetOpts, "hidden;")
+						}
+						if len(stubnetOpts) > 0 {
+							sb.WriteString(fmt.Sprintf("        stubnet %s { %s };\n", prefix, strings.Join(stubnetOpts, " ")))
 						} else {
 							sb.WriteString(fmt.Sprintf("        stubnet %s;\n", prefix))
 						}
@@ -582,6 +667,18 @@ func (m *Manager) applyBirdConfig() error {
 				}
 			}
 
+			// Collect active static neighbors for this area
+			var areaNbrs []OSPFStaticNeighbor
+			for _, nbr := range m.config.OSPFStaticNbrs {
+				if nbr.Disabled {
+					continue
+				}
+				if strings.EqualFold(nbr.Area, area.Name) || strings.EqualFold(nbr.Area, area.AreaID) || (nbr.Area == "" && (strings.EqualFold(area.Name, "Default") || area.AreaID == "0.0.0.0")) {
+					areaNbrs = append(areaNbrs, nbr)
+				}
+			}
+			usedNbrIDs := make(map[string]bool)
+
 			// Interface templates matching this area
 			for _, iface := range m.config.OSPFInterfaces {
 				if iface.Disabled {
@@ -600,16 +697,7 @@ func (m *Manager) applyBirdConfig() error {
 					netName = strings.TrimSpace(iface.Network)
 				}
 
-				var target string
-				if ifaceName != "" && netName != "" {
-					target = fmt.Sprintf("\"%s\", %s", ifaceName, netName)
-				} else if ifaceName != "" {
-					target = fmt.Sprintf("\"%s\"", ifaceName)
-				} else if netName != "" {
-					target = netName
-				} else {
-					target = "\"*\""
-				}
+				target := formatInterfaceTargets(ifaceName, netName)
 
 				sb.WriteString(fmt.Sprintf("        interface %s {\n", target))
 				if iface.Cost > 0 {
@@ -625,14 +713,19 @@ func (m *Manager) applyBirdConfig() error {
 					sb.WriteString(fmt.Sprintf("            dead %d;\n", iface.DeadInterval))
 				}
 
+				netType := strings.ToLower(strings.TrimSpace(iface.NetworkType))
 				if iface.Passive {
 					sb.WriteString("            stub;\n")
 				} else {
-					netType := strings.ToLower(strings.TrimSpace(iface.NetworkType))
-					if netType == "ptp" || netType == "point-to-point" {
+					switch netType {
+					case "ptp", "point-to-point":
 						sb.WriteString("            type ptp;\n")
-					} else {
-						sb.WriteString("            type broadcast;\n")
+					case "nbma", "nonbroadcast":
+						sb.WriteString("            type nonbroadcast;\n")
+					case "ptmp", "point-to-multipoint":
+						sb.WriteString("            type ptmp;\n")
+					default:
+						sb.WriteString("            type bcast;\n")
 					}
 				}
 
@@ -640,7 +733,7 @@ func (m *Manager) applyBirdConfig() error {
 				if auth == "" {
 					auth = strings.ToLower(iface.AuthType)
 				}
-				if auth == "md5" && iface.AuthKey != "" {
+				if (auth == "md5" || auth == "cryptographic") && iface.AuthKey != "" {
 					sb.WriteString("            authentication cryptographic;\n")
 					sb.WriteString(fmt.Sprintf("            password \"%s\";\n", iface.AuthKey))
 				} else if auth == "simple" && iface.AuthKey != "" {
@@ -648,6 +741,70 @@ func (m *Manager) applyBirdConfig() error {
 					sb.WriteString(fmt.Sprintf("            password \"%s\";\n", iface.AuthKey))
 				}
 
+				// Find static neighbors matching this interface template
+				var templateNbrs []OSPFStaticNeighbor
+				for _, nbr := range areaNbrs {
+					if usedNbrIDs[nbr.ID] {
+						continue
+					}
+					if netName != "" && ipInSubnets(nbr.Address, netName) {
+						templateNbrs = append(templateNbrs, nbr)
+						usedNbrIDs[nbr.ID] = true
+					}
+				}
+				if len(templateNbrs) == 0 && (strings.Contains(netType, "nbma") || strings.Contains(netType, "nonbroadcast") || strings.Contains(netType, "ptmp")) {
+					for _, nbr := range areaNbrs {
+						if !usedNbrIDs[nbr.ID] {
+							templateNbrs = append(templateNbrs, nbr)
+							usedNbrIDs[nbr.ID] = true
+						}
+					}
+				}
+
+				if len(templateNbrs) > 0 {
+					for _, nbr := range templateNbrs {
+						if nbr.PollInterval > 0 {
+							sb.WriteString(fmt.Sprintf("            poll %d;\n", nbr.PollInterval))
+							break
+						}
+					}
+					sb.WriteString("            neighbors {\n")
+					for _, nbr := range templateNbrs {
+						addr := strings.TrimSpace(nbr.Address)
+						if addr != "" {
+							sb.WriteString(fmt.Sprintf("                %s;\n", addr))
+						}
+					}
+					sb.WriteString("            };\n")
+				}
+
+				sb.WriteString("        };\n")
+			}
+
+			// Any remaining static neighbors in this area not matched to a template
+			var remainingNbrs []OSPFStaticNeighbor
+			for _, nbr := range areaNbrs {
+				if !usedNbrIDs[nbr.ID] {
+					remainingNbrs = append(remainingNbrs, nbr)
+				}
+			}
+			if len(remainingNbrs) > 0 {
+				sb.WriteString("        interface \"*\" {\n")
+				sb.WriteString("            type nonbroadcast;\n")
+				for _, nbr := range remainingNbrs {
+					if nbr.PollInterval > 0 {
+						sb.WriteString(fmt.Sprintf("            poll %d;\n", nbr.PollInterval))
+						break
+					}
+				}
+				sb.WriteString("            neighbors {\n")
+				for _, nbr := range remainingNbrs {
+					addr := strings.TrimSpace(nbr.Address)
+					if addr != "" {
+						sb.WriteString(fmt.Sprintf("                %s;\n", addr))
+					}
+				}
+				sb.WriteString("            };\n")
 				sb.WriteString("        };\n")
 			}
 
@@ -657,6 +814,13 @@ func (m *Manager) applyBirdConfig() error {
 		sb.WriteString("}\n\n")
 	}
 
+	return sb.String()
+}
+
+// applyBirdConfig generates /etc/bird/bird.conf and live reloads BIRD daemon
+func (m *Manager) applyBirdConfig() error {
+	cfgText := m.generateBirdConfig()
+
 	// Ensure directory exists with proper permissions
 	dir := filepath.Dir(birdConfigPath)
 	if err := os.MkdirAll(dir, 0775); err == nil {
@@ -666,7 +830,7 @@ func (m *Manager) applyBirdConfig() error {
 
 	// Write candidate configuration to temporary file
 	tmpPath := birdConfigPath + ".new"
-	if err := os.WriteFile(tmpPath, []byte(sb.String()), 0664); err != nil {
+	if err := os.WriteFile(tmpPath, []byte(cfgText), 0664); err != nil {
 		return fmt.Errorf("failed to write %s: %w", tmpPath, err)
 	}
 	_ = exec.Command("chown", "root:bird", tmpPath).Run()
@@ -1574,7 +1738,10 @@ func (m *Manager) SaveOSPFStaticNeighbor(nbr OSPFStaticNeighbor) error {
 		m.config.OSPFStaticNbrs = append(m.config.OSPFStaticNbrs, nbr)
 	}
 
-	return m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyBirdConfig()
 }
 
 func (m *Manager) DeleteOSPFStaticNeighbor(id string) error {
@@ -1589,7 +1756,26 @@ func (m *Manager) DeleteOSPFStaticNeighbor(id string) error {
 	}
 	m.config.OSPFStaticNbrs = newNbrs
 
-	return m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyBirdConfig()
+}
+
+func (m *Manager) SetOSPFStaticNeighborState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for i := range m.config.OSPFStaticNbrs {
+		if m.config.OSPFStaticNbrs[i].ID == id {
+			m.config.OSPFStaticNbrs[i].Disabled = disabled
+			if err := m.save(); err != nil {
+				return err
+			}
+			return m.applyBirdConfig()
+		}
+	}
+	return fmt.Errorf("static neighbor %s not found", id)
 }
 
 func (m *Manager) GetOSPFNeighbors() []OSPFNeighbor {

+ 186 - 0
internal/dynamicrouting/ospf_test.go

@@ -3,6 +3,7 @@ package dynamicrouting
 import (
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 )
 
@@ -20,3 +21,188 @@ func TestOSPFStatusIsPerInstance(t *testing.T) {
 		t.Fatal("one OSPF instance marked other instances running", got)
 	}
 }
+
+func TestOSPFBirdConfigGeneration(t *testing.T) {
+	m := &Manager{}
+	m.config.OSPFInstances = []OSPFInstance{
+		{
+			ID:                    "inst-1",
+			Name:                  "core",
+			Version:               2,
+			VRF:                   "vrf-mgmt",
+			RoutingTable:          "t100",
+			RouterID:              "10.255.0.1",
+			Enabled:               true,
+			RedistributeConnected: true,
+			RedistributeStatic:    false, // Must NOT export RTS_STATIC
+			RedistributeBGP:       true,
+			DefaultMetric:         55,
+		},
+	}
+	m.config.OSPFAreas = []OSPFArea{
+		{
+			ID:          "area-1",
+			Name:        "stub-area",
+			Instance:    "core",
+			AreaID:      "0.0.0.10",
+			AreaType:    "stub",
+			DefaultCost: 25,
+			NoSummaries: true,
+		},
+	}
+	m.config.OSPFAreaRanges = []OSPFAreaRange{
+		{
+			ID:        "range-1",
+			Area:      "stub-area",
+			Prefix:    "10.10.0.0/16",
+			Cost:      10,
+			Advertise: false, // Must emit hidden;
+		},
+		{
+			ID:        "range-2",
+			Area:      "stub-area",
+			Prefix:    "10.20.0.0/16",
+			Cost:      5,
+			Advertise: true,
+		},
+	}
+	m.config.OSPFInterfaces = []OSPFInterface{
+		{
+			ID:          "iface-1",
+			Interfaces:  "eth0, eth1",
+			Area:        "stub-area",
+			Networks:    "10.10.1.0/24",
+			NetworkType: "nonbroadcast",
+			Cost:        15,
+			Priority:    64,
+			Auth:        "md5",
+			AuthKey:     "secretpass",
+		},
+	}
+	m.config.OSPFStaticNbrs = []OSPFStaticNeighbor{
+		{
+			ID:           "snbr-1",
+			Address:      "10.10.1.5",
+			Area:         "stub-area",
+			PollInterval: 30,
+			Cost:         15,
+		},
+		{
+			ID:           "snbr-2",
+			Address:      "192.168.99.1",
+			Area:         "stub-area",
+			PollInterval: 45,
+			Cost:         20,
+		},
+	}
+
+	conf := m.generateBirdConfig()
+
+	// 1. Verify instance settings
+	if !strings.Contains(conf, "protocol ospf v2 ospf_core {") {
+		t.Fatalf("missing protocol ospf v2 ospf_core in config:\n%s", conf)
+	}
+	if !strings.Contains(conf, "vrf vrf-mgmt;") {
+		t.Fatalf("missing vrf vrf-mgmt in config:\n%s", conf)
+	}
+	if !strings.Contains(conf, "router id 10.255.0.1;") {
+		t.Fatalf("missing router id 10.255.0.1 in config:\n%s", conf)
+	}
+	if !strings.Contains(conf, "table t100;") {
+		t.Fatalf("missing table t100 in config:\n%s", conf)
+	}
+
+	// 2. Verify redistribution & default metric
+	ospfBlock := conf[strings.Index(conf, "protocol ospf v2 ospf_core"):]
+	if strings.Contains(ospfBlock, "RTS_STATIC") {
+		t.Fatalf("RTS_STATIC exported even though RedistributeStatic is false:\n%s", ospfBlock)
+	}
+	if !strings.Contains(ospfBlock, "RTS_DEVICE, RTS_BGP") {
+		t.Fatalf("expected RTS_DEVICE, RTS_BGP in export filter:\n%s", ospfBlock)
+	}
+	if !strings.Contains(ospfBlock, "ospf_metric1 = 55;") {
+		t.Fatalf("missing ospf_metric1 = 55; in export filter:\n%s", ospfBlock)
+	}
+
+	// 3. Verify area options: stub, default cost, summary no
+	if !strings.Contains(conf, "area 0.0.0.10 {") {
+		t.Fatalf("missing area 0.0.0.10 in config:\n%s", conf)
+	}
+	if !strings.Contains(conf, "stub;") {
+		t.Fatalf("missing stub; in area:\n%s", conf)
+	}
+	if !strings.Contains(conf, "default cost 25;") {
+		t.Fatalf("missing default cost 25; in area:\n%s", conf)
+	}
+	if !strings.Contains(conf, "summary no;") {
+		t.Fatalf("missing summary no; in area:\n%s", conf)
+	}
+
+	// 4. Verify area range advertising: hidden; for advertise=false
+	if !strings.Contains(conf, "stubnet 10.10.0.0/16 { cost 10; hidden; };") {
+		t.Fatalf("missing stubnet 10.10.0.0/16 { cost 10; hidden; }; in config:\n%s", conf)
+	}
+	if !strings.Contains(conf, "stubnet 10.20.0.0/16 { cost 5; };") {
+		t.Fatalf("missing stubnet 10.20.0.0/16 { cost 5; }; in config:\n%s", conf)
+	}
+
+	// 5. Verify interface template formatting: comma separated interfaces
+	if !strings.Contains(conf, "interface \"eth0\", \"eth1\", 10.10.1.0/24 {") {
+		t.Fatalf("missing interface \"eth0\", \"eth1\", 10.10.1.0/24 { in config:\n%s", conf)
+	}
+	if !strings.Contains(conf, "type nonbroadcast;") {
+		t.Fatalf("missing type nonbroadcast; in config:\n%s", conf)
+	}
+	if !strings.Contains(conf, "cost 15;") {
+		t.Fatalf("missing cost 15; in config:\n%s", conf)
+	}
+	if !strings.Contains(conf, "priority 64;") {
+		t.Fatalf("missing priority 64; in config:\n%s", conf)
+	}
+	if !strings.Contains(conf, "authentication cryptographic;\n            password \"secretpass\";") {
+		t.Fatalf("missing authentication cryptographic and password in config:\n%s", conf)
+	}
+
+	// 6. Verify static neighbor attached to matching interface template
+	if !strings.Contains(conf, "10.10.1.5;") {
+		t.Fatalf("missing static neighbor 10.10.1.5 in config:\n%s", conf)
+	}
+
+	// 7. Verify remaining static neighbor in fallback interface "*"
+	if !strings.Contains(conf, "192.168.99.1;") {
+		t.Fatalf("missing static neighbor 192.168.99.1 in config:\n%s", conf)
+	}
+}
+
+func TestOSPFStaticNeighborManagement(t *testing.T) {
+	m := &Manager{}
+	snbr := OSPFStaticNeighbor{
+		Address:      "10.0.0.2",
+		Area:         "Default",
+		PollInterval: 60,
+		Cost:         5,
+	}
+
+	if err := m.SaveOSPFStaticNeighbor(snbr); err != nil {
+		// applyBirdConfig may fail if bird is not running in test env, but config must be in memory
+		t.Logf("Save returned error (expected in mock env): %v", err)
+	}
+
+	list := m.ListOSPFStaticNeighbors()
+	if len(list) != 1 || list[0].Address != "10.0.0.2" {
+		t.Fatalf("expected 1 static neighbor with address 10.0.0.2, got: %+v", list)
+	}
+
+	id := list[0].ID
+	_ = m.SetOSPFStaticNeighborState(id, true)
+	list2 := m.ListOSPFStaticNeighbors()
+	if !list2[0].Disabled {
+		t.Fatalf("expected neighbor to be disabled, got: %+v", list2[0])
+	}
+
+	_ = m.DeleteOSPFStaticNeighbor(id)
+	list3 := m.ListOSPFStaticNeighbors()
+	if len(list3) != 0 {
+		t.Fatalf("expected 0 static neighbors after delete, got: %+v", list3)
+	}
+}

+ 2 - 8
internal/web/static/app.js

@@ -11601,20 +11601,14 @@ async function deleteCurrentOspfStaticNbr() {
 async function enableSelectedOspfStaticNbr() {
   const id = state.selectedOspfRow?.['static-nbrs'];
   if (!id) return;
-  const n = (state.ospfStaticNbrs || []).find(it => it.id === id);
-  if (!n) return;
-  n.disabled = false;
-  await fetch(`${API_BASE}/routing/ospf/static-neighbors`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(n) });
+  await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
   loadDynamicRouting();
 }
 
 async function disableSelectedOspfStaticNbr() {
   const id = state.selectedOspfRow?.['static-nbrs'];
   if (!id) return;
-  const n = (state.ospfStaticNbrs || []).find(it => it.id === id);
-  if (!n) return;
-  n.disabled = true;
-  await fetch(`${API_BASE}/routing/ospf/static-neighbors`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(n) });
+  await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
   loadDynamicRouting();
 }