Prechádzať zdrojové kódy

feat(routing): implement enterprise MikroTik-style BGP and OSPF dynamic routing suite backed by FRRouting

Proxmox Router 1 týždeň pred
rodič
commit
966584aa73

+ 3 - 1
cmd/server/main.go

@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"proxmox-router/internal/api"
+	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
 	"proxmox-router/internal/routing"
@@ -31,9 +32,10 @@ func main() {
 	vpnMgr := vpn.NewManager()
 	fwMgr := firewall.NewFirewallManager()
 	routingMgr := routing.NewRoutingManager()
+	dynRoutingMgr := dynamicrouting.NewManager("")
 	webHandler := web.Handler()
 
-	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, webHandler)
+	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, webHandler)
 
 	httpServer := &http.Server{
 		Addr:         *addr,

+ 48 - 17
internal/api/api.go

@@ -5,6 +5,7 @@ import (
 	"net/http"
 	"strings"
 
+	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
 	"proxmox-router/internal/routing"
@@ -13,26 +14,28 @@ import (
 )
 
 type Server struct {
-	netMgr     *network.Manager
-	persistMgr *network.PersistenceManager
-	metaMgr    *network.MetadataManager
-	vpnMgr     *vpn.Manager
-	fwMgr      *firewall.FirewallManager
-	routingMgr *routing.RoutingManager
-	mux        *http.ServeMux
-	fsHandler  http.Handler
+	netMgr        *network.Manager
+	persistMgr    *network.PersistenceManager
+	metaMgr       *network.MetadataManager
+	vpnMgr        *vpn.Manager
+	fwMgr         *firewall.FirewallManager
+	routingMgr    *routing.RoutingManager
+	dynRoutingMgr *dynamicrouting.Manager
+	mux           *http.ServeMux
+	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, fsHandler http.Handler) *Server {
 	s := &Server{
-		netMgr:     netMgr,
-		persistMgr: persistMgr,
-		metaMgr:    metaMgr,
-		vpnMgr:     vpnMgr,
-		fwMgr:      fwMgr,
-		routingMgr: routingMgr,
-		mux:        http.NewServeMux(),
-		fsHandler:  fsHandler,
+		netMgr:        netMgr,
+		persistMgr:    persistMgr,
+		metaMgr:       metaMgr,
+		vpnMgr:        vpnMgr,
+		fwMgr:         fwMgr,
+		routingMgr:    routingMgr,
+		dynRoutingMgr: dynRoutingMgr,
+		mux:           http.NewServeMux(),
+		fsHandler:     fsHandler,
 	}
 	s.registerRoutes()
 	return s
@@ -185,6 +188,34 @@ func (s *Server) registerRoutes() {
 	// WireGuard Client Config Generator
 	s.mux.HandleFunc("POST /api/vpn/wireguard/client-config", s.handleGenerateWGClientConfig)
 
+	// Dynamic Routing (BGP & OSPF)
+	s.mux.HandleFunc("GET /api/routing/dynamic/summary", s.handleDynamicRoutingSummary)
+
+	// BGP
+	s.mux.HandleFunc("GET /api/routing/bgp/instance", s.handleGetBGPInstance)
+	s.mux.HandleFunc("POST /api/routing/bgp/instance", s.handleUpdateBGPInstance)
+	s.mux.HandleFunc("GET /api/routing/bgp/peers", s.handleListBGPPeers)
+	s.mux.HandleFunc("POST /api/routing/bgp/peers", s.handleSaveBGPPeer)
+	s.mux.HandleFunc("DELETE /api/routing/bgp/peers/{id}", s.handleDeleteBGPPeer)
+	s.mux.HandleFunc("POST /api/routing/bgp/peers/{id}/state", s.handleSetBGPPeerState)
+	s.mux.HandleFunc("GET /api/routing/bgp/networks", s.handleListBGPNetworks)
+	s.mux.HandleFunc("POST /api/routing/bgp/networks", s.handleSaveBGPNetwork)
+	s.mux.HandleFunc("DELETE /api/routing/bgp/networks/{id}", s.handleDeleteBGPNetwork)
+	s.mux.HandleFunc("GET /api/routing/bgp/routes", s.handleGetBGPRoutes)
+
+	// OSPF
+	s.mux.HandleFunc("GET /api/routing/ospf/instance", s.handleGetOSPFInstance)
+	s.mux.HandleFunc("POST /api/routing/ospf/instance", s.handleUpdateOSPFInstance)
+	s.mux.HandleFunc("GET /api/routing/ospf/areas", s.handleListOSPFAreas)
+	s.mux.HandleFunc("POST /api/routing/ospf/areas", s.handleSaveOSPFArea)
+	s.mux.HandleFunc("DELETE /api/routing/ospf/areas/{id}", s.handleDeleteOSPFArea)
+	s.mux.HandleFunc("GET /api/routing/ospf/interfaces", s.handleListOSPFInterfaces)
+	s.mux.HandleFunc("POST /api/routing/ospf/interfaces", s.handleSaveOSPFInterface)
+	s.mux.HandleFunc("DELETE /api/routing/ospf/interfaces/{id}", s.handleDeleteOSPFInterface)
+	s.mux.HandleFunc("POST /api/routing/ospf/interfaces/{id}/state", s.handleSetOSPFInterfaceState)
+	s.mux.HandleFunc("GET /api/routing/ospf/neighbors", s.handleGetOSPFNeighbors)
+	s.mux.HandleFunc("GET /api/routing/ospf/routes", s.handleGetOSPFRoutes)
+
 	// Web UI Static Files
 	if s.fsHandler != nil {
 		s.mux.Handle("/", s.fsHandler)

+ 266 - 0
internal/api/dynamic_routing_handlers.go

@@ -0,0 +1,266 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"proxmox-router/internal/dynamicrouting"
+)
+
+// Summary
+func (s *Server) handleDynamicRoutingSummary(w http.ResponseWriter, r *http.Request) {
+	summary := s.dynRoutingMgr.GetSummary()
+	writeJSON(w, http.StatusOK, summary)
+}
+
+// -------------------------------------------------------------
+// BGP Handlers
+// -------------------------------------------------------------
+
+func (s *Server) handleGetBGPInstance(w http.ResponseWriter, r *http.Request) {
+	inst := s.dynRoutingMgr.GetBGPInstance()
+	writeJSON(w, http.StatusOK, inst)
+}
+
+func (s *Server) handleUpdateBGPInstance(w http.ResponseWriter, r *http.Request) {
+	var inst dynamicrouting.BGPInstance
+	if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+	if err := s.dynRoutingMgr.UpdateBGPInstance(inst); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, s.dynRoutingMgr.GetBGPInstance())
+}
+
+func (s *Server) handleListBGPPeers(w http.ResponseWriter, r *http.Request) {
+	peers := s.dynRoutingMgr.ListBGPPeers()
+	if peers == nil {
+		peers = []dynamicrouting.BGPPeer{}
+	}
+	writeJSON(w, http.StatusOK, peers)
+}
+
+func (s *Server) handleSaveBGPPeer(w http.ResponseWriter, r *http.Request) {
+	var peer dynamicrouting.BGPPeer
+	if err := json.NewDecoder(r.Body).Decode(&peer); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+	if peer.RemoteAddress == "" || peer.RemoteAS == 0 {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "remote_address and remote_as are required"})
+		return
+	}
+	if err := s.dynRoutingMgr.SaveBGPPeer(peer); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "peer": peer})
+}
+
+func (s *Server) handleDeleteBGPPeer(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing peer id"})
+		return
+	}
+	if err := s.dynRoutingMgr.DeleteBGPPeer(id); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
+
+func (s *Server) handleSetBGPPeerState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	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"})
+		return
+	}
+	if err := s.dynRoutingMgr.SetBGPPeerState(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})
+}
+
+func (s *Server) handleListBGPNetworks(w http.ResponseWriter, r *http.Request) {
+	nets := s.dynRoutingMgr.ListBGPNetworks()
+	if nets == nil {
+		nets = []dynamicrouting.BGPNetwork{}
+	}
+	writeJSON(w, http.StatusOK, nets)
+}
+
+func (s *Server) handleSaveBGPNetwork(w http.ResponseWriter, r *http.Request) {
+	var net dynamicrouting.BGPNetwork
+	if err := json.NewDecoder(r.Body).Decode(&net); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+	if net.Network == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "network (CIDR) is required"})
+		return
+	}
+	if err := s.dynRoutingMgr.SaveBGPNetwork(net); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "network": net})
+}
+
+func (s *Server) handleDeleteBGPNetwork(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing network id"})
+		return
+	}
+	if err := s.dynRoutingMgr.DeleteBGPNetwork(id); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
+
+func (s *Server) handleGetBGPRoutes(w http.ResponseWriter, r *http.Request) {
+	routes := s.dynRoutingMgr.GetBGPRoutes()
+	if routes == nil {
+		routes = []dynamicrouting.BGPRoute{}
+	}
+	writeJSON(w, http.StatusOK, routes)
+}
+
+// -------------------------------------------------------------
+// OSPF Handlers
+// -------------------------------------------------------------
+
+func (s *Server) handleGetOSPFInstance(w http.ResponseWriter, r *http.Request) {
+	inst := s.dynRoutingMgr.GetOSPFInstance()
+	writeJSON(w, http.StatusOK, inst)
+}
+
+func (s *Server) handleUpdateOSPFInstance(w http.ResponseWriter, r *http.Request) {
+	var inst dynamicrouting.OSPFInstance
+	if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+	if err := s.dynRoutingMgr.UpdateOSPFInstance(inst); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, s.dynRoutingMgr.GetOSPFInstance())
+}
+
+func (s *Server) handleListOSPFAreas(w http.ResponseWriter, r *http.Request) {
+	areas := s.dynRoutingMgr.ListOSPFAreas()
+	if areas == nil {
+		areas = []dynamicrouting.OSPFArea{}
+	}
+	writeJSON(w, http.StatusOK, areas)
+}
+
+func (s *Server) handleSaveOSPFArea(w http.ResponseWriter, r *http.Request) {
+	var area dynamicrouting.OSPFArea
+	if err := json.NewDecoder(r.Body).Decode(&area); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+	if area.AreaID == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "area_id is required"})
+		return
+	}
+	if err := s.dynRoutingMgr.SaveOSPFArea(area); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "area": area})
+}
+
+func (s *Server) handleDeleteOSPFArea(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing area id"})
+		return
+	}
+	if err := s.dynRoutingMgr.DeleteOSPFArea(id); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
+
+func (s *Server) handleListOSPFInterfaces(w http.ResponseWriter, r *http.Request) {
+	ifaces := s.dynRoutingMgr.ListOSPFInterfaces()
+	if ifaces == nil {
+		ifaces = []dynamicrouting.OSPFInterface{}
+	}
+	writeJSON(w, http.StatusOK, ifaces)
+}
+
+func (s *Server) handleSaveOSPFInterface(w http.ResponseWriter, r *http.Request) {
+	var iface dynamicrouting.OSPFInterface
+	if err := json.NewDecoder(r.Body).Decode(&iface); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+	if iface.Network == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "network is required"})
+		return
+	}
+	if err := s.dynRoutingMgr.SaveOSPFInterface(iface); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "interface": iface})
+}
+
+func (s *Server) handleDeleteOSPFInterface(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing interface id"})
+		return
+	}
+	if err := s.dynRoutingMgr.DeleteOSPFInterface(id); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
+
+func (s *Server) handleSetOSPFInterfaceState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	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"})
+		return
+	}
+	if err := s.dynRoutingMgr.SetOSPFInterfaceState(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})
+}
+
+func (s *Server) handleGetOSPFNeighbors(w http.ResponseWriter, r *http.Request) {
+	nbrs := s.dynRoutingMgr.GetOSPFNeighbors()
+	if nbrs == nil {
+		nbrs = []dynamicrouting.OSPFNeighbor{}
+	}
+	writeJSON(w, http.StatusOK, nbrs)
+}
+
+func (s *Server) handleGetOSPFRoutes(w http.ResponseWriter, r *http.Request) {
+	routes := s.dynRoutingMgr.GetOSPFRoutes()
+	if routes == nil {
+		routes = []dynamicrouting.OSPFRoute{}
+	}
+	writeJSON(w, http.StatusOK, routes)
+}

+ 789 - 0
internal/dynamicrouting/manager.go

@@ -0,0 +1,789 @@
+package dynamicrouting
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+)
+
+const (
+	defaultConfigPath = "/etc/proxmox-router/routing/dynamic.json"
+	frrConfigPath     = "/etc/frr/frr.conf"
+)
+
+type Manager struct {
+	mu         sync.RWMutex
+	configPath string
+	config     DynamicConfig
+}
+
+func NewManager(configPath string) *Manager {
+	if configPath == "" {
+		configPath = defaultConfigPath
+	}
+
+	m := &Manager{
+		configPath: configPath,
+	}
+
+	if err := m.load(); err != nil {
+		log.Printf("[DynamicRouting] Config load warning: %v, initializing defaults", err)
+		m.initDefaults()
+		_ = m.save()
+	}
+
+	// Apply initial configuration to FRR
+	if err := m.applyFRRConfig(); err != nil {
+		log.Printf("[DynamicRouting] Initial FRR apply warning: %v", err)
+	}
+
+	return m
+}
+
+func (m *Manager) initDefaults() {
+	m.config = DynamicConfig{
+		BGPInstance: BGPInstance{
+			Enabled:               false,
+			AS:                    65001,
+			RouterID:              "192.168.131.56",
+			HoldTime:              180,
+			KeepaliveTime:         60,
+			RedistributeConnected: false,
+			RedistributeStatic:    false,
+			RedistributeOSPF:      false,
+		},
+		BGPPeers:    []BGPPeer{},
+		BGPNetworks: []BGPNetwork{},
+		OSPFInstance: OSPFInstance{
+			Enabled:               false,
+			RouterID:              "192.168.131.56",
+			RedistributeConnected: false,
+			RedistributeStatic:    false,
+			RedistributeBGP:       false,
+			DefaultMetric:         10,
+		},
+		OSPFAreas: []OSPFArea{
+			{
+				ID:       "area-backbone",
+				Name:     "backbone",
+				AreaID:   "0.0.0.0",
+				AreaType: "default",
+				Comment:  "Default OSPF Backbone Area",
+			},
+		},
+		OSPFInterfaces: []OSPFInterface{},
+	}
+}
+
+func (m *Manager) load() error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	data, err := os.ReadFile(m.configPath)
+	if err != nil {
+		return err
+	}
+	return json.Unmarshal(data, &m.config)
+}
+
+func (m *Manager) save() error {
+	dir := filepath.Dir(m.configPath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(m.config, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(m.configPath, data, 0644)
+}
+
+// applyFRRConfig generates /etc/frr/frr.conf and reloads FRR daemons
+func (m *Manager) applyFRRConfig() error {
+	var sb strings.Builder
+
+	sb.WriteString("frr version 10.3\n")
+	sb.WriteString("frr defaults traditional\n")
+	sb.WriteString("hostname WAN-Balancer\n")
+	sb.WriteString("log syslog informational\n")
+	sb.WriteString("service integrated-vtysh-config\n!\n")
+
+	// 1. Interfaces section for OSPF (cost, timers, authentication)
+	interfaceConfigs := make(map[string]bool)
+	if m.config.OSPFInstance.Enabled {
+		for _, iface := range m.config.OSPFInterfaces {
+			if iface.Disabled || iface.Interface == "" || interfaceConfigs[iface.Interface] {
+				continue
+			}
+			interfaceConfigs[iface.Interface] = true
+			sb.WriteString(fmt.Sprintf("interface %s\n", iface.Interface))
+			if iface.Cost > 0 {
+				sb.WriteString(fmt.Sprintf(" ip ospf cost %d\n", iface.Cost))
+			}
+			if iface.Priority > 0 {
+				sb.WriteString(fmt.Sprintf(" ip ospf priority %d\n", iface.Priority))
+			}
+			if iface.HelloInterval > 0 {
+				sb.WriteString(fmt.Sprintf(" ip ospf hello-interval %d\n", iface.HelloInterval))
+			}
+			if iface.DeadInterval > 0 {
+				sb.WriteString(fmt.Sprintf(" ip ospf dead-interval %d\n", iface.DeadInterval))
+			}
+			if iface.AuthType == "md5" && iface.AuthKey != "" {
+				sb.WriteString(" ip ospf authentication message-digest\n")
+				sb.WriteString(fmt.Sprintf(" ip ospf message-digest-key 1 md5 %s\n", iface.AuthKey))
+			} else if iface.AuthType == "simple" && iface.AuthKey != "" {
+				sb.WriteString(" ip ospf authentication\n")
+				sb.WriteString(fmt.Sprintf(" ip ospf authentication-key %s\n", iface.AuthKey))
+			}
+			sb.WriteString("!\n")
+		}
+	}
+
+	// 2. BGP Router Section
+	if m.config.BGPInstance.Enabled && m.config.BGPInstance.AS > 0 {
+		inst := m.config.BGPInstance
+		sb.WriteString(fmt.Sprintf("router bgp %d\n", inst.AS))
+		if inst.RouterID != "" {
+			sb.WriteString(fmt.Sprintf(" bgp router-id %s\n", inst.RouterID))
+		}
+		keepalive := 60
+		hold := 180
+		if inst.KeepaliveTime > 0 {
+			keepalive = inst.KeepaliveTime
+		}
+		if inst.HoldTime > 0 {
+			hold = inst.HoldTime
+		}
+		sb.WriteString(fmt.Sprintf(" timers bgp %d %d\n", keepalive, hold))
+
+		// Peers definition
+		for _, peer := range m.config.BGPPeers {
+			if peer.Disabled || peer.RemoteAddress == "" || peer.RemoteAS == 0 {
+				continue
+			}
+			sb.WriteString(fmt.Sprintf(" neighbor %s remote-as %d\n", peer.RemoteAddress, peer.RemoteAS))
+			if peer.Name != "" {
+				sb.WriteString(fmt.Sprintf(" neighbor %s description \"%s\"\n", peer.RemoteAddress, peer.Name))
+			}
+			if peer.Multihop {
+				sb.WriteString(fmt.Sprintf(" neighbor %s ebgp-multihop 255\n", peer.RemoteAddress))
+			}
+			if peer.Passive {
+				sb.WriteString(fmt.Sprintf(" neighbor %s passive\n", peer.RemoteAddress))
+			}
+			if peer.Password != "" {
+				sb.WriteString(fmt.Sprintf(" neighbor %s password %s\n", peer.RemoteAddress, peer.Password))
+			}
+			if peer.LocalAddress != "" {
+				sb.WriteString(fmt.Sprintf(" neighbor %s update-source %s\n", peer.RemoteAddress, peer.LocalAddress))
+			}
+		}
+
+		// Address Family IPv4 Unicast
+		sb.WriteString(" address-family ipv4 unicast\n")
+		// Advertised Networks
+		for _, net := range m.config.BGPNetworks {
+			if !net.Disabled && net.Network != "" {
+				sb.WriteString(fmt.Sprintf("  network %s\n", net.Network))
+			}
+		}
+
+		// Activate peers in IPv4 unicast
+		for _, peer := range m.config.BGPPeers {
+			if peer.Disabled || peer.RemoteAddress == "" || peer.RemoteAS == 0 {
+				continue
+			}
+			sb.WriteString(fmt.Sprintf("  neighbor %s activate\n", peer.RemoteAddress))
+			if peer.NexthopSelf {
+				sb.WriteString(fmt.Sprintf("  neighbor %s next-hop-self\n", peer.RemoteAddress))
+			}
+		}
+
+		// Redistribute
+		if inst.RedistributeConnected {
+			sb.WriteString("  redistribute connected\n")
+		}
+		if inst.RedistributeStatic {
+			sb.WriteString("  redistribute static\n")
+		}
+		if inst.RedistributeOSPF {
+			sb.WriteString("  redistribute ospf\n")
+		}
+		sb.WriteString(" exit-address-family\n!\n")
+	}
+
+	// 3. OSPF Router Section
+	if m.config.OSPFInstance.Enabled {
+		inst := m.config.OSPFInstance
+		sb.WriteString("router ospf\n")
+		if inst.RouterID != "" {
+			sb.WriteString(fmt.Sprintf(" ospf router-id %s\n", inst.RouterID))
+		}
+		if inst.DefaultMetric > 0 {
+			sb.WriteString(fmt.Sprintf(" default-metric %d\n", inst.DefaultMetric))
+		}
+		if inst.RedistributeConnected {
+			sb.WriteString(" redistribute connected\n")
+		}
+		if inst.RedistributeStatic {
+			sb.WriteString(" redistribute static\n")
+		}
+		if inst.RedistributeBGP {
+			sb.WriteString(" redistribute bgp\n")
+		}
+
+		for _, iface := range m.config.OSPFInterfaces {
+			if iface.Disabled || iface.Network == "" {
+				continue
+			}
+			area := iface.Area
+			if area == "" {
+				area = "0.0.0.0"
+			}
+			sb.WriteString(fmt.Sprintf(" network %s area %s\n", iface.Network, area))
+			if iface.Passive && iface.Interface != "" {
+				sb.WriteString(fmt.Sprintf(" passive-interface %s\n", iface.Interface))
+			}
+		}
+		sb.WriteString("!\n")
+	}
+
+	sb.WriteString("end\n")
+
+	// Write frr.conf
+	if err := os.WriteFile(frrConfigPath, []byte(sb.String()), 0640); err != nil {
+		return fmt.Errorf("failed to write %s: %w", frrConfigPath, err)
+	}
+
+	// Change ownership to frr:frr
+	_ = exec.Command("chown", "frr:frr", frrConfigPath).Run()
+
+	// Reload FRR using vtysh or systemctl
+	out, err := exec.Command("vtysh", "-f", frrConfigPath).CombinedOutput()
+	if err != nil {
+		log.Printf("[DynamicRouting] vtysh -f reload warning: %v, output: %s", err, string(out))
+		// fallback to systemctl reload frr
+		_ = exec.Command("systemctl", "reload", "frr").Run()
+	}
+
+	return nil
+}
+
+func vtyshExec(cmd string) ([]byte, error) {
+	return exec.Command("vtysh", "-c", cmd).Output()
+}
+
+// -------------------------------------------------------------
+// BGP API & Operations
+// -------------------------------------------------------------
+
+func (m *Manager) GetBGPInstance() BGPInstance {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	inst := m.config.BGPInstance
+	// Check if BGP is currently running in FRR
+	out, err := vtyshExec("show ip bgp summary json")
+	if err == nil && len(out) > 5 && !strings.Contains(string(out), "warning") {
+		inst.Running = true
+	}
+	return inst
+}
+
+func (m *Manager) UpdateBGPInstance(inst BGPInstance) error {
+	m.mu.Lock()
+	m.config.BGPInstance = inst
+	_ = m.save()
+	m.mu.Unlock()
+
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) ListBGPPeers() []BGPPeer {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	// Make a copy of configured peers
+	peers := make([]BGPPeer, len(m.config.BGPPeers))
+	copy(peers, m.config.BGPPeers)
+
+	// Fetch live peer telemetry from vtysh
+	out, err := vtyshExec("show ip bgp summary json")
+	if err == nil {
+		var summary struct {
+			IPv4Unicast struct {
+				Peers map[string]struct {
+					RemoteAs   uint32 `json:"remoteAs"`
+					State      string `json:"state"`
+					PeerUptime string `json:"peerUptime"`
+					PfxRcd     int    `json:"pfxRcd"`
+					PfxSnt     int    `json:"pfxSnt"`
+				} `json:"peers"`
+			} `json:"ipv4Unicast"`
+		}
+		if err := json.Unmarshal(out, &summary); err == nil && summary.IPv4Unicast.Peers != nil {
+			for i := range peers {
+				if live, ok := summary.IPv4Unicast.Peers[peers[i].RemoteAddress]; ok {
+					peers[i].State = live.State
+					peers[i].Uptime = live.PeerUptime
+					peers[i].PrefixReceived = live.PfxRcd
+					peers[i].PrefixSent = live.PfxSnt
+				} else if peers[i].Disabled {
+					peers[i].State = "Disabled"
+				} else {
+					peers[i].State = "Idle"
+				}
+			}
+		}
+	}
+
+	return peers
+}
+
+func (m *Manager) SaveBGPPeer(peer BGPPeer) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if peer.ID == "" {
+		peer.ID = fmt.Sprintf("bgp-peer-%d", time.Now().UnixNano())
+	}
+
+	found := false
+	for i, p := range m.config.BGPPeers {
+		if p.ID == peer.ID {
+			m.config.BGPPeers[i] = peer
+			found = true
+			break
+		}
+	}
+	if !found {
+		m.config.BGPPeers = append(m.config.BGPPeers, peer)
+	}
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) DeleteBGPPeer(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	newPeers := make([]BGPPeer, 0, len(m.config.BGPPeers))
+	for _, p := range m.config.BGPPeers {
+		if p.ID != id {
+			newPeers = append(newPeers, p)
+		}
+	}
+	m.config.BGPPeers = newPeers
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) SetBGPPeerState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for i := range m.config.BGPPeers {
+		if m.config.BGPPeers[i].ID == id {
+			m.config.BGPPeers[i].Disabled = disabled
+			break
+		}
+	}
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) ListBGPNetworks() []BGPNetwork {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return m.config.BGPNetworks
+}
+
+func (m *Manager) SaveBGPNetwork(net BGPNetwork) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if net.ID == "" {
+		net.ID = fmt.Sprintf("bgp-net-%d", time.Now().UnixNano())
+	}
+
+	found := false
+	for i, n := range m.config.BGPNetworks {
+		if n.ID == net.ID {
+			m.config.BGPNetworks[i] = net
+			found = true
+			break
+		}
+	}
+	if !found {
+		m.config.BGPNetworks = append(m.config.BGPNetworks, net)
+	}
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) DeleteBGPNetwork(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	newNets := make([]BGPNetwork, 0, len(m.config.BGPNetworks))
+	for _, n := range m.config.BGPNetworks {
+		if n.ID != id {
+			newNets = append(newNets, n)
+		}
+	}
+	m.config.BGPNetworks = newNets
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) GetBGPRoutes() []BGPRoute {
+	var routes []BGPRoute
+
+	out, err := vtyshExec("show ip bgp json")
+	if err != nil {
+		return routes
+	}
+
+	var parsed struct {
+		Routes map[string][]struct {
+			Metric   int    `json:"metric"`
+			Weight   int    `json:"weight"`
+			Path     string `json:"path"`
+			Origin   string `json:"origin"`
+			Nexthops []struct {
+				IP   string `json:"ip"`
+				Used bool   `json:"used"`
+			} `json:"nexthops"`
+		} `json:"routes"`
+	}
+
+	if err := json.Unmarshal(out, &parsed); err == nil && parsed.Routes != nil {
+		for prefix, items := range parsed.Routes {
+			for _, item := range items {
+				nh := ""
+				if len(item.Nexthops) > 0 {
+					nh = item.Nexthops[0].IP
+				}
+				routes = append(routes, BGPRoute{
+					Prefix:  prefix,
+					NextHop: nh,
+					Metric:  item.Metric,
+					Weight:  item.Weight,
+					Path:    item.Path,
+					Origin:  item.Origin,
+					Best:    true,
+				})
+			}
+		}
+	}
+
+	return routes
+}
+
+// -------------------------------------------------------------
+// OSPF API & Operations
+// -------------------------------------------------------------
+
+func (m *Manager) GetOSPFInstance() OSPFInstance {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	inst := m.config.OSPFInstance
+	out, err := vtyshExec("show ip ospf json")
+	if err == nil && len(out) > 5 && !strings.Contains(string(out), "warning") {
+		inst.Running = true
+	}
+	return inst
+}
+
+func (m *Manager) UpdateOSPFInstance(inst OSPFInstance) error {
+	m.mu.Lock()
+	m.config.OSPFInstance = inst
+	_ = m.save()
+	m.mu.Unlock()
+
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) ListOSPFAreas() []OSPFArea {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return m.config.OSPFAreas
+}
+
+func (m *Manager) SaveOSPFArea(area OSPFArea) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if area.ID == "" {
+		area.ID = fmt.Sprintf("area-%d", time.Now().UnixNano())
+	}
+
+	found := false
+	for i, a := range m.config.OSPFAreas {
+		if a.ID == area.ID {
+			m.config.OSPFAreas[i] = area
+			found = true
+			break
+		}
+	}
+	if !found {
+		m.config.OSPFAreas = append(m.config.OSPFAreas, area)
+	}
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) DeleteOSPFArea(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	newAreas := make([]OSPFArea, 0, len(m.config.OSPFAreas))
+	for _, a := range m.config.OSPFAreas {
+		if a.ID != id {
+			newAreas = append(newAreas, a)
+		}
+	}
+	m.config.OSPFAreas = newAreas
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) ListOSPFInterfaces() []OSPFInterface {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return m.config.OSPFInterfaces
+}
+
+func (m *Manager) SaveOSPFInterface(iface OSPFInterface) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if iface.ID == "" {
+		iface.ID = fmt.Sprintf("ospf-iface-%d", time.Now().UnixNano())
+	}
+	if iface.Cost <= 0 {
+		iface.Cost = 10
+	}
+	if iface.Priority <= 0 {
+		iface.Priority = 1
+	}
+	if iface.HelloInterval <= 0 {
+		iface.HelloInterval = 10
+	}
+	if iface.DeadInterval <= 0 {
+		iface.DeadInterval = 40
+	}
+
+	found := false
+	for i, item := range m.config.OSPFInterfaces {
+		if item.ID == iface.ID {
+			m.config.OSPFInterfaces[i] = iface
+			found = true
+			break
+		}
+	}
+	if !found {
+		m.config.OSPFInterfaces = append(m.config.OSPFInterfaces, iface)
+	}
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) DeleteOSPFInterface(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	newInterfaces := make([]OSPFInterface, 0, len(m.config.OSPFInterfaces))
+	for _, item := range m.config.OSPFInterfaces {
+		if item.ID != id {
+			newInterfaces = append(newInterfaces, item)
+		}
+	}
+	m.config.OSPFInterfaces = newInterfaces
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) SetOSPFInterfaceState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for i := range m.config.OSPFInterfaces {
+		if m.config.OSPFInterfaces[i].ID == id {
+			m.config.OSPFInterfaces[i].Disabled = disabled
+			break
+		}
+	}
+
+	if err := m.save(); err != nil {
+		return err
+	}
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) GetOSPFNeighbors() []OSPFNeighbor {
+	var neighbors []OSPFNeighbor
+
+	out, err := vtyshExec("show ip ospf neighbor json")
+	if err != nil {
+		return neighbors
+	}
+
+	var parsed struct {
+		Neighbors map[string][]struct {
+			NbrState  string `json:"nbrState"`
+			Address   string `json:"address"`
+			IfaceName string `json:"ifaceName"`
+			Area      string `json:"area"`
+			DeadTime  string `json:"deadTime"`
+		} `json:"neighbors"`
+	}
+
+	if err := json.Unmarshal(out, &parsed); err == nil && parsed.Neighbors != nil {
+		for nbrID, entries := range parsed.Neighbors {
+			for _, entry := range entries {
+				neighbors = append(neighbors, OSPFNeighbor{
+					NeighborID: nbrID,
+					Address:    entry.Address,
+					Interface:  entry.IfaceName,
+					Area:       entry.Area,
+					State:      entry.NbrState,
+					DeadTimer:  entry.DeadTime,
+				})
+			}
+		}
+	}
+
+	return neighbors
+}
+
+func (m *Manager) GetOSPFRoutes() []OSPFRoute {
+	var routes []OSPFRoute
+
+	out, err := vtyshExec("show ip ospf route json")
+	if err != nil {
+		return routes
+	}
+
+	var parsed map[string]struct {
+		RouteType string `json:"routeType"`
+		Cost      int    `json:"cost"`
+		Area      string `json:"area"`
+		Nexthops  []struct {
+			IP                 string `json:"ip"`
+			DirectlyAttachedTo string `json:"directlyAttachedTo"`
+		} `json:"nexthops"`
+	}
+
+	if err := json.Unmarshal(out, &parsed); err == nil {
+		for prefix, r := range parsed {
+			nh := ""
+			dev := ""
+			if len(r.Nexthops) > 0 {
+				nh = strings.TrimSpace(r.Nexthops[0].IP)
+				dev = r.Nexthops[0].DirectlyAttachedTo
+			}
+			routes = append(routes, OSPFRoute{
+				Prefix:    prefix,
+				RouteType: r.RouteType,
+				Cost:      r.Cost,
+				Area:      r.Area,
+				NextHop:   nh,
+				Interface: dev,
+			})
+		}
+	}
+
+	return routes
+}
+
+// -------------------------------------------------------------
+// Summary Telemetry
+// -------------------------------------------------------------
+
+func (m *Manager) GetSummary() DynamicRoutingSummary {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	summary := DynamicRoutingSummary{
+		BGPEnabled:  m.config.BGPInstance.Enabled,
+		OSPFEnabled: m.config.OSPFInstance.Enabled,
+	}
+
+	// Check BGP status
+	if summary.BGPEnabled {
+		out, err := vtyshExec("show ip bgp summary json")
+		if err == nil && len(out) > 5 && !strings.Contains(string(out), "warning") {
+			summary.BGPRunning = true
+			var bgpParsed struct {
+				IPv4Unicast struct {
+					PeerCount int `json:"peerCount"`
+					Peers     map[string]struct {
+						State string `json:"state"`
+					} `json:"peers"`
+				} `json:"ipv4Unicast"`
+			}
+			if err := json.Unmarshal(out, &bgpParsed); err == nil {
+				summary.BGPPeerCount = len(m.config.BGPPeers)
+				for _, p := range bgpParsed.IPv4Unicast.Peers {
+					if p.State == "Established" {
+						summary.BGPEstablishedCount++
+					}
+				}
+			}
+		}
+	}
+
+	// Check OSPF status
+	if summary.OSPFEnabled {
+		out, err := vtyshExec("show ip ospf json")
+		if err == nil && len(out) > 5 && !strings.Contains(string(out), "warning") {
+			summary.OSPFRunning = true
+		}
+		nbrs := m.GetOSPFNeighbors()
+		summary.OSPFNeighborCount = len(nbrs)
+		for _, n := range nbrs {
+			if strings.HasPrefix(n.State, "Full") {
+				summary.OSPFAdjacentCount++
+			}
+		}
+	}
+
+	return summary
+}

+ 132 - 0
internal/dynamicrouting/types.go

@@ -0,0 +1,132 @@
+package dynamicrouting
+
+// BGPInstance represents global BGP settings (/routing bgp instance)
+type BGPInstance struct {
+	Enabled               bool   `json:"enabled"`
+	AS                    uint32 `json:"as"`
+	RouterID              string `json:"router_id"`
+	HoldTime              int    `json:"hold_time"`      // Default 180
+	KeepaliveTime         int    `json:"keepalive_time"` // Default 60
+	RedistributeConnected bool   `json:"redistribute_connected"`
+	RedistributeStatic    bool   `json:"redistribute_static"`
+	RedistributeOSPF      bool   `json:"redistribute_ospf"`
+	Running               bool   `json:"running"`
+}
+
+// BGPPeer represents a BGP peer connection (/routing bgp connection / peer)
+type BGPPeer struct {
+	ID             string `json:"id"`
+	Name           string `json:"name"`
+	RemoteAddress  string `json:"remote_address"`
+	RemoteAS       uint32 `json:"remote_as"`
+	LocalAddress   string `json:"local_address,omitempty"`
+	Multihop       bool   `json:"multihop"`
+	NexthopSelf    bool   `json:"nexthop_self"`
+	Passive        bool   `json:"passive"`
+	Password       string `json:"password,omitempty"`
+	Disabled       bool   `json:"disabled"`
+	Comment        string `json:"comment,omitempty"`
+	// Runtime telemetry
+	State          string `json:"state,omitempty"` // Established, Active, Connect, Idle
+	Uptime         string `json:"uptime,omitempty"`
+	PrefixReceived int    `json:"prefix_received"`
+	PrefixSent     int    `json:"prefix_sent"`
+}
+
+// BGPNetwork represents an advertised network (/routing bgp network)
+type BGPNetwork struct {
+	ID       string `json:"id"`
+	Network  string `json:"network"` // CIDR, e.g. 10.200.0.0/24
+	Disabled bool   `json:"disabled"`
+	Comment  string `json:"comment,omitempty"`
+}
+
+// BGPRoute represents an active BGP route
+type BGPRoute struct {
+	Prefix  string `json:"prefix"`
+	NextHop string `json:"next_hop"`
+	Metric  int    `json:"metric"`
+	Weight  int    `json:"weight"`
+	Path    string `json:"path"`
+	Origin  string `json:"origin"`
+	Best    bool   `json:"best"`
+}
+
+// OSPFInstance represents global OSPF settings (/routing ospf instance)
+type OSPFInstance struct {
+	Enabled               bool   `json:"enabled"`
+	RouterID              string `json:"router_id"`
+	RedistributeConnected bool   `json:"redistribute_connected"`
+	RedistributeStatic    bool   `json:"redistribute_static"`
+	RedistributeBGP       bool   `json:"redistribute_bgp"`
+	DefaultMetric         int    `json:"default_metric"`
+	Running               bool   `json:"running"`
+}
+
+// OSPFArea represents an OSPF area (/routing ospf area)
+type OSPFArea struct {
+	ID       string `json:"id"`
+	Name     string `json:"name"`
+	AreaID   string `json:"area_id"`   // e.g. "0.0.0.0" or "0"
+	AreaType string `json:"area_type"` // default, stub, nssa
+	Comment  string `json:"comment,omitempty"`
+}
+
+// OSPFInterface represents an OSPF network/interface template (/routing ospf interface-template)
+type OSPFInterface struct {
+	ID            string `json:"id"`
+	Network       string `json:"network"`             // CIDR, e.g. "192.168.131.0/24"
+	Area          string `json:"area"`                // Area ID e.g. "0.0.0.0"
+	Interface     string `json:"interface,omitempty"` // eth0, vlan10, wg0...
+	Cost          int    `json:"cost"`                // Metric, default 10
+	Priority      int    `json:"priority"`            // Default 1
+	HelloInterval int    `json:"hello_interval"`      // Default 10
+	DeadInterval  int    `json:"dead_interval"`       // Default 40
+	Passive       bool   `json:"passive"`
+	AuthType      string `json:"auth_type"` // none, simple, md5
+	AuthKey       string `json:"auth_key,omitempty"`
+	Disabled      bool   `json:"disabled"`
+	Comment       string `json:"comment,omitempty"`
+}
+
+// OSPFNeighbor represents a live OSPF neighbor
+type OSPFNeighbor struct {
+	NeighborID string `json:"neighbor_id"`
+	Address    string `json:"address"`
+	Interface  string `json:"interface"`
+	Area       string `json:"area"`
+	State      string `json:"state"` // Full/DR, Full/Backup, 2-Way...
+	DeadTimer  string `json:"dead_timer"`
+}
+
+// OSPFRoute represents an active OSPF route
+type OSPFRoute struct {
+	Prefix    string `json:"prefix"`
+	RouteType string `json:"route_type"` // Intra-Area, Inter-Area, External
+	Cost      int    `json:"cost"`
+	Area      string `json:"area"`
+	NextHop   string `json:"next_hop"`
+	Interface string `json:"interface"`
+}
+
+// DynamicRoutingSummary aggregates status for dashboard & badges
+type DynamicRoutingSummary struct {
+	BGPEnabled          bool `json:"bgp_enabled"`
+	BGPRunning          bool `json:"bgp_running"`
+	BGPPeerCount        int  `json:"bgp_peer_count"`
+	BGPEstablishedCount int  `json:"bgp_established_count"`
+	OSPFEnabled         bool `json:"ospf_enabled"`
+	OSPFRunning         bool `json:"ospf_running"`
+	OSPFNeighborCount   int  `json:"ospf_neighbor_count"`
+	OSPFAdjacentCount   int  `json:"ospf_adjacent_count"`
+}
+
+// DynamicConfig stores all dynamic routing data on disk
+type DynamicConfig struct {
+	BGPInstance    BGPInstance     `json:"bgp_instance"`
+	BGPPeers       []BGPPeer       `json:"bgp_peers"`
+	BGPNetworks    []BGPNetwork    `json:"bgp_networks"`
+	OSPFInstance   OSPFInstance    `json:"ospf_instance"`
+	OSPFAreas      []OSPFArea      `json:"ospf_areas"`
+	OSPFInterfaces []OSPFInterface `json:"ospf_interfaces"`
+}

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

@@ -74,6 +74,7 @@ function navigate(tabName) {
     vlans: { title: 'VLAN Virtual Networks', sub: 'IEEE 802.1Q sub-interface segmentation and routing' },
     firewall: { title: 'MikroTik Firewall', sub: 'Filter rules, NAT, Mangle, Address Lists & Active Connections tracking' },
     routes: { title: 'Routing & Policy', sub: 'Routing tables, static routes (DAS/DAC), and policy routing rules' },
+    'dynamic-routing': { title: 'Dynamic Routing (BGP & OSPF)', sub: 'FRRouting BGP peering sessions, advertised networks, and OSPFv2 link-state routing' },
     ppp: { title: 'PPP Servers & Secrets', sub: 'MikroTik RouterOS user accounts, IP pools and VPN server concentrators' },
     eoip: { title: 'EoIP Tunnels', sub: 'Ethernet over IP Layer 2 tunneling (MikroTik RouterOS style)' },
     pppoe: { title: 'PPPoE Client WAN', sub: 'Point-to-Point Protocol over Ethernet WAN access' },
@@ -101,6 +102,7 @@ function navigate(tabName) {
 
   if (tabName === 'firewall') loadFirewall();
   if (tabName === 'routes') loadRoutes();
+  if (tabName === 'dynamic-routing') loadDynamicRouting();
   if (tabName === 'ppp') loadPPP();
   if (tabName === 'eoip') loadEoIP();
   if (tabName === 'persistence') loadDebianConfig();
@@ -117,12 +119,13 @@ async function fetchData(isManual = false) {
   if (isManual && spinner) spinner.classList.add('animate-spin');
 
   try {
-    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes] = await Promise.all([
+    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes] = await Promise.all([
       fetch(`${API_BASE}/system/status`),
       fetch(`${API_BASE}/interfaces`),
       fetch(`${API_BASE}/vlans`),
       fetch(`${API_BASE}/vpn/status`),
-      fetch(`${API_BASE}/firewall/summary`)
+      fetch(`${API_BASE}/firewall/summary`),
+      fetch(`${API_BASE}/routing/dynamic/summary`)
     ]);
 
     if (statusRes.ok) state.systemStatus = await statusRes.json();
@@ -136,6 +139,7 @@ async function fetchData(isManual = false) {
     }
     if (vpnRes.ok) state.vpnStatus = await vpnRes.json();
     if (fwRes && fwRes.ok) state.fwSummary = await fwRes.json();
+    if (dynRes && dynRes.ok) state.dynSummary = await dynRes.json();
 
     updateSystemMetrics();
     updateBadges();
@@ -145,6 +149,7 @@ async function fetchData(isManual = false) {
     if (state.activeTab === 'vlans') renderVlansList();
     if (state.activeTab === 'firewall') loadFirewall(false);
     if (state.activeTab === 'routes') loadRoutes(false);
+    if (state.activeTab === 'dynamic-routing') loadDynamicRouting(false);
     if (state.activeTab === 'ppp') loadPPP(false);
     if (state.activeTab === 'eoip') loadEoIP(false);
     if (state.activeTab === 'pppoe') loadPPPoE(false);
@@ -266,6 +271,11 @@ function updateBadges() {
     setDotBadge('badge-l2tp-status', v.active_l2tp > 0 || v.l2tp_server_running);
     setDotBadge('badge-pptp-status', v.active_pptp > 0 || v.pptp_server_running);
   }
+  if (state.dynSummary) {
+    const d = state.dynSummary;
+    setDotBadge('badge-bgp-dot', d.bgp_running);
+    setDotBadge('badge-ospf-dot', d.ospf_running);
+  }
 }
 
 // Render Dashboard View
@@ -4286,3 +4296,783 @@ function downloadWGClientConf() {
   document.body.removeChild(a);
   showToast('Downloaded', 'wireguard-client.conf saved', 'success');
 }
+
+// =========================================================================
+// DYNAMIC ROUTING (FRRouting BGP & OSPFv2)
+// =========================================================================
+
+state.activeDynSubTab = 'bgp-peers';
+state.bgpInstance = null;
+state.bgpPeers = [];
+state.bgpNets = [];
+state.bgpRoutes = [];
+state.ospfInstance = null;
+state.ospfInterfaces = [];
+state.ospfAreas = [];
+state.ospfNeighbors = [];
+state.ospfRoutes = [];
+
+function switchDynSubTab(subTab) {
+  state.activeDynSubTab = subTab;
+  const tabs = ['bgp-peers', 'bgp-nets', 'bgp-routes', 'ospf-ifaces', 'ospf-areas', 'ospf-nbrs', 'ospf-routes'];
+  tabs.forEach(t => {
+    const el = document.getElementById(`dyn-subtab-${t}`);
+    const btn = document.getElementById(`dyn-subtab-btn-${t}`);
+    if (el) el.classList.toggle('hidden', t !== subTab);
+    if (btn) {
+      if (t === subTab) {
+        btn.className = `px-4 py-2.5 text-xs font-bold border-b-2 ${t.startsWith('bgp') ? 'border-purple-500' : 'border-indigo-500'} text-white transition flex items-center gap-2 whitespace-nowrap`;
+      } else {
+        btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap';
+      }
+    }
+  });
+}
+
+async function loadDynamicRouting(showToastMsg = true) {
+  try {
+    const [sumRes, bgpInstRes, bgpPeersRes, bgpNetsRes, bgpRoutesRes, ospfInstRes, ospfIfacesRes, ospfAreasRes, ospfNbrsRes, ospfRoutesRes] = await Promise.all([
+      fetch(`${API_BASE}/routing/dynamic/summary`),
+      fetch(`${API_BASE}/routing/bgp/instance`),
+      fetch(`${API_BASE}/routing/bgp/peers`),
+      fetch(`${API_BASE}/routing/bgp/networks`),
+      fetch(`${API_BASE}/routing/bgp/routes`),
+      fetch(`${API_BASE}/routing/ospf/instance`),
+      fetch(`${API_BASE}/routing/ospf/interfaces`),
+      fetch(`${API_BASE}/routing/ospf/areas`),
+      fetch(`${API_BASE}/routing/ospf/neighbors`),
+      fetch(`${API_BASE}/routing/ospf/routes`)
+    ]);
+
+    if (sumRes.ok) state.dynSummary = await sumRes.json();
+    if (bgpInstRes.ok) state.bgpInstance = await bgpInstRes.json();
+    if (bgpPeersRes.ok) state.bgpPeers = await bgpPeersRes.json();
+    if (bgpNetsRes.ok) state.bgpNets = await bgpNetsRes.json();
+    if (bgpRoutesRes.ok) state.bgpRoutes = await bgpRoutesRes.json();
+    if (ospfInstRes.ok) state.ospfInstance = await ospfInstRes.json();
+    if (ospfIfacesRes.ok) state.ospfInterfaces = await ospfIfacesRes.json();
+    if (ospfAreasRes.ok) state.ospfAreas = await ospfAreasRes.json();
+    if (ospfNbrsRes.ok) state.ospfNeighbors = await ospfNbrsRes.json();
+    if (ospfRoutesRes.ok) state.ospfRoutes = await ospfRoutesRes.json();
+
+    renderBgpStatusPill();
+    renderBgpPeers();
+    renderBgpNetworks();
+    renderBgpRoutes();
+    renderOspfStatusPill();
+    renderOspfInterfaces();
+    renderOspfAreas();
+    renderOspfNeighbors();
+    renderOspfRoutes();
+    updateBadges();
+  } catch (err) {
+    console.error('Failed to load dynamic routing:', err);
+    if (showToastMsg) showToast('Error', 'Failed to load dynamic routing telemetry', 'error');
+  }
+}
+
+function renderBgpStatusPill() {
+  const pill = document.getElementById('bgp-live-status-pill');
+  if (!pill) return;
+  const inst = state.bgpInstance;
+  if (!inst || !inst.enabled) {
+    pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
+    pill.innerText = 'BGP DISABLED';
+  } else if (inst.running) {
+    pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-purple-950 text-purple-300 border border-purple-800/60 font-semibold';
+    pill.innerText = `BGP ACTIVE (AS ${inst.as})`;
+  } 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 = 'BGP STARTING...';
+  }
+}
+
+function renderBgpPeers() {
+  const tbody = document.getElementById('bgp-peers-table-body');
+  const empty = document.getElementById('bgp-peers-empty');
+  const countBadge = document.getElementById('bgp-peers-count-badge');
+  const navBadge = document.getElementById('dyn-badge-bgp-peers');
+
+  const peers = state.bgpPeers || [];
+  if (countBadge) countBadge.innerText = `${peers.length} Peers`;
+  if (navBadge) navBadge.innerText = peers.length;
+
+  if (peers.length === 0) {
+    tbody.innerHTML = '';
+    empty.classList.remove('hidden');
+    return;
+  }
+  empty.classList.add('hidden');
+
+  tbody.innerHTML = peers.map(p => {
+    let flagA = !p.disabled ? '<span class="text-cyan-400 font-bold" title="Active">A</span>' : '';
+    let flagE = p.state === 'Established' ? '<span class="text-emerald-400 font-bold ml-1" title="Established">E</span>' : '';
+    let flagX = p.disabled ? '<span class="text-rose-400 font-bold ml-1" title="Disabled">X</span>' : '';
+
+    let stateBadge = '';
+    if (p.disabled) {
+      stateBadge = '<span class="px-2 py-0.5 rounded-full text-[10px] font-sans font-medium bg-slate-800 text-slate-400 border border-slate-700">Disabled</span>';
+    } else if (p.state === 'Established') {
+      stateBadge = '<span class="px-2 py-0.5 rounded-full text-[10px] font-sans font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">Established</span>';
+    } else if (p.state === 'Connect' || p.state === 'Active') {
+      stateBadge = `<span class="px-2 py-0.5 rounded-full text-[10px] font-sans font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">${p.state}</span>`;
+    } else {
+      stateBadge = `<span class="px-2 py-0.5 rounded-full text-[10px] font-sans font-medium bg-slate-800 text-slate-400 border border-slate-700">${p.state || 'Idle'}</span>`;
+    }
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-2.5 px-3 text-center tracking-wider">${flagA}${flagE}${flagX}</td>
+        <td class="py-2.5 px-3 font-semibold text-white">${escapeHtml(p.name)}</td>
+        <td class="py-2.5 px-3 text-purple-300 font-bold">${escapeHtml(p.remote_address)}</td>
+        <td class="py-2.5 px-3 font-bold text-slate-200">AS ${p.remote_as}</td>
+        <td class="py-2.5 px-3">${stateBadge}</td>
+        <td class="py-2.5 px-3 text-slate-400">${escapeHtml(p.uptime || '-')}</td>
+        <td class="py-2.5 px-3 text-slate-300">
+          <span class="text-emerald-400 font-bold">↓ ${p.prefix_received || 0}</span> / 
+          <span class="text-blue-400 font-bold">↑ ${p.prefix_sent || 0}</span>
+        </td>
+        <td class="py-2.5 px-3 text-slate-400 font-sans truncate max-w-xs">${escapeHtml(p.comment || '')}</td>
+        <td class="py-2.5 px-3 text-right space-x-1.5 whitespace-nowrap font-sans">
+          <button onclick="toggleBgpPeerState('${p.id}', ${!p.disabled})" class="px-2 py-1 rounded text-[11px] ${p.disabled ? 'bg-emerald-950 hover:bg-emerald-900 text-emerald-400 border border-emerald-800/50' : 'bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700'}">
+            ${p.disabled ? 'Enable' : 'Disable'}
+          </button>
+          <button onclick="openEditBgpPeerModal('${p.id}')" class="px-2 py-1 rounded text-[11px] bg-slate-800 hover:bg-slate-700 text-purple-300 border border-slate-700">
+            Edit
+          </button>
+          <button onclick="deleteBgpPeer('${p.id}')" class="px-2 py-1 rounded text-[11px] bg-rose-950/60 hover:bg-rose-900 text-rose-300 border border-rose-900/50">
+            Delete
+          </button>
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+function renderBgpNetworks() {
+  const tbody = document.getElementById('bgp-nets-table-body');
+  const empty = document.getElementById('bgp-nets-empty');
+  const countBadge = document.getElementById('bgp-nets-count-badge');
+  const navBadge = document.getElementById('dyn-badge-bgp-nets');
+
+  const nets = state.bgpNets || [];
+  if (countBadge) countBadge.innerText = `${nets.length} Networks`;
+  if (navBadge) navBadge.innerText = nets.length;
+
+  if (nets.length === 0) {
+    tbody.innerHTML = '';
+    empty.classList.remove('hidden');
+    return;
+  }
+  empty.classList.add('hidden');
+
+  tbody.innerHTML = nets.map(n => `
+    <tr class="hover:bg-slate-800/40 transition">
+      <td class="py-2.5 px-3 font-semibold text-purple-300 font-mono">${escapeHtml(n.network)}</td>
+      <td class="py-2.5 px-3">
+        ${n.disabled 
+          ? '<span class="px-2 py-0.5 rounded-full text-[10px] bg-slate-800 text-slate-400 border border-slate-700">Disabled</span>'
+          : '<span class="px-2 py-0.5 rounded-full text-[10px] bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">Announcing</span>'}
+      </td>
+      <td class="py-2.5 px-3 text-slate-400 font-sans truncate max-w-xs">${escapeHtml(n.comment || '')}</td>
+      <td class="py-2.5 px-3 text-right space-x-1 font-sans">
+        <button onclick="deleteBgpNetwork('${n.id}')" class="px-2 py-1 rounded text-[11px] bg-rose-950/60 hover:bg-rose-900 text-rose-300 border border-rose-900/50">
+          Delete
+        </button>
+      </td>
+    </tr>
+  `).join('');
+}
+
+function renderBgpRoutes() {
+  const tbody = document.getElementById('bgp-routes-table-body');
+  const empty = document.getElementById('bgp-routes-empty');
+  const countBadge = document.getElementById('bgp-routes-count-badge');
+  const navBadge = document.getElementById('dyn-badge-bgp-routes');
+
+  const routes = state.bgpRoutes || [];
+  if (countBadge) countBadge.innerText = `${routes.length} Routes`;
+  if (navBadge) navBadge.innerText = routes.length;
+
+  if (routes.length === 0) {
+    tbody.innerHTML = '';
+    empty.classList.remove('hidden');
+    return;
+  }
+  empty.classList.add('hidden');
+
+  tbody.innerHTML = routes.map(r => `
+    <tr class="hover:bg-slate-800/40 transition">
+      <td class="py-2.5 px-3 font-semibold text-white">${escapeHtml(r.prefix)}</td>
+      <td class="py-2.5 px-3 text-purple-300 font-bold">${escapeHtml(r.next_hop || '-')}</td>
+      <td class="py-2.5 px-3 text-slate-300">${r.metric}</td>
+      <td class="py-2.5 px-3 text-slate-300">${r.weight}</td>
+      <td class="py-2.5 px-3 text-amber-300">${escapeHtml(r.path || 'i')}</td>
+      <td class="py-2.5 px-3 text-slate-400">${escapeHtml(r.origin || 'IGP')}</td>
+    </tr>
+  `).join('');
+}
+
+function renderOspfStatusPill() {
+  const pill = document.getElementById('ospf-live-status-pill');
+  if (!pill) return;
+  const inst = state.ospfInstance;
+  if (!inst || !inst.enabled) {
+    pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
+    pill.innerText = 'OSPF DISABLED';
+  } else if (inst.running) {
+    pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-indigo-950 text-indigo-300 border border-indigo-800/60 font-semibold';
+    pill.innerText = `OSPF ACTIVE (ID: ${inst.router_id || 'Auto'})`;
+  } 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 = 'OSPF STARTING...';
+  }
+}
+
+function renderOspfInterfaces() {
+  const tbody = document.getElementById('ospf-ifaces-table-body');
+  const empty = document.getElementById('ospf-ifaces-empty');
+  const countBadge = document.getElementById('ospf-ifaces-count-badge');
+  const navBadge = document.getElementById('dyn-badge-ospf-ifaces');
+
+  const ifaces = state.ospfInterfaces || [];
+  if (countBadge) countBadge.innerText = `${ifaces.length} Interfaces`;
+  if (navBadge) navBadge.innerText = ifaces.length;
+
+  if (ifaces.length === 0) {
+    tbody.innerHTML = '';
+    empty.classList.remove('hidden');
+    return;
+  }
+  empty.classList.add('hidden');
+
+  tbody.innerHTML = ifaces.map(i => {
+    let flagA = !i.disabled ? '<span class="text-cyan-400 font-bold" title="Active">A</span>' : '';
+    let flagP = i.passive ? '<span class="text-amber-400 font-bold ml-1" title="Passive">P</span>' : '';
+    let flagX = i.disabled ? '<span class="text-rose-400 font-bold ml-1" title="Disabled">X</span>' : '';
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-2.5 px-3 text-center tracking-wider">${flagA}${flagP}${flagX}</td>
+        <td class="py-2.5 px-3 font-semibold text-white font-mono">${escapeHtml(i.network)}</td>
+        <td class="py-2.5 px-3 text-indigo-300 font-bold">Area ${escapeHtml(i.area)}</td>
+        <td class="py-2.5 px-3 text-slate-300 font-mono">${escapeHtml(i.interface || 'Any')}</td>
+        <td class="py-2.5 px-3 text-slate-300">${i.cost}</td>
+        <td class="py-2.5 px-3 text-slate-300">${i.priority}</td>
+        <td class="py-2.5 px-3 text-slate-400">${i.hello_interval}s / ${i.dead_interval}s</td>
+        <td class="py-2.5 px-3 text-slate-300 uppercase">${escapeHtml(i.auth_type || 'none')}</td>
+        <td class="py-2.5 px-3 text-slate-400 font-sans truncate max-w-xs">${escapeHtml(i.comment || '')}</td>
+        <td class="py-2.5 px-3 text-right space-x-1.5 whitespace-nowrap font-sans">
+          <button onclick="toggleOspfInterfaceState('${i.id}', ${!i.disabled})" class="px-2 py-1 rounded text-[11px] ${i.disabled ? 'bg-emerald-950 hover:bg-emerald-900 text-emerald-400 border border-emerald-800/50' : 'bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700'}">
+            ${i.disabled ? 'Enable' : 'Disable'}
+          </button>
+          <button onclick="openEditOspfInterfaceModal('${i.id}')" class="px-2 py-1 rounded text-[11px] bg-slate-800 hover:bg-slate-700 text-indigo-300 border border-slate-700">
+            Edit
+          </button>
+          <button onclick="deleteOspfInterface('${i.id}')" class="px-2 py-1 rounded text-[11px] bg-rose-950/60 hover:bg-rose-900 text-rose-300 border border-rose-900/50">
+            Delete
+          </button>
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+function renderOspfAreas() {
+  const tbody = document.getElementById('ospf-areas-table-body');
+  const countBadge = document.getElementById('ospf-areas-count-badge');
+  const navBadge = document.getElementById('dyn-badge-ospf-areas');
+
+  const areas = state.ospfAreas || [];
+  if (countBadge) countBadge.innerText = `${areas.length} Areas`;
+  if (navBadge) navBadge.innerText = areas.length;
+
+  tbody.innerHTML = areas.map(a => `
+    <tr class="hover:bg-slate-800/40 transition">
+      <td class="py-2.5 px-3 font-semibold text-white font-mono">${escapeHtml(a.name)}</td>
+      <td class="py-2.5 px-3 font-bold text-indigo-300">${escapeHtml(a.area_id)}</td>
+      <td class="py-2.5 px-3 text-slate-300 uppercase">${escapeHtml(a.area_type || 'default')}</td>
+      <td class="py-2.5 px-3 text-slate-400 font-sans">${escapeHtml(a.comment || '')}</td>
+      <td class="py-2.5 px-3 text-right font-sans">
+        <button onclick="deleteOspfArea('${a.id}')" class="px-2 py-1 rounded text-[11px] bg-rose-950/60 hover:bg-rose-900 text-rose-300 border border-rose-900/50">
+          Delete
+        </button>
+      </td>
+    </tr>
+  `).join('');
+}
+
+function renderOspfNeighbors() {
+  const tbody = document.getElementById('ospf-nbrs-table-body');
+  const empty = document.getElementById('ospf-nbrs-empty');
+  const countBadge = document.getElementById('ospf-nbrs-count-badge');
+  const navBadge = document.getElementById('dyn-badge-ospf-nbrs');
+
+  const nbrs = state.ospfNeighbors || [];
+  if (countBadge) countBadge.innerText = `${nbrs.length} Neighbors`;
+  if (navBadge) navBadge.innerText = nbrs.length;
+
+  if (nbrs.length === 0) {
+    tbody.innerHTML = '';
+    empty.classList.remove('hidden');
+    return;
+  }
+  empty.classList.add('hidden');
+
+  tbody.innerHTML = nbrs.map(n => {
+    let stateBadge = '';
+    if (n.state && (n.state.startsWith('Full') || n.state.includes('DR') || n.state.includes('Backup'))) {
+      stateBadge = `<span class="px-2 py-0.5 rounded-full text-[10px] font-sans font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">${escapeHtml(n.state)}</span>`;
+    } else {
+      stateBadge = `<span class="px-2 py-0.5 rounded-full text-[10px] font-sans font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">${escapeHtml(n.state || 'Unknown')}</span>`;
+    }
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-2.5 px-3 font-semibold text-white">${escapeHtml(n.neighbor_id)}</td>
+        <td class="py-2.5 px-3 text-indigo-300 font-bold">${escapeHtml(n.address)}</td>
+        <td class="py-2.5 px-3 text-slate-300 font-mono">${escapeHtml(n.interface)}</td>
+        <td class="py-2.5 px-3 text-slate-400">${escapeHtml(n.area || '0.0.0.0')}</td>
+        <td class="py-2.5 px-3">${stateBadge}</td>
+        <td class="py-2.5 px-3 text-slate-400">${escapeHtml(n.dead_timer || '-')}</td>
+      </tr>
+    `;
+  }).join('');
+}
+
+function renderOspfRoutes() {
+  const tbody = document.getElementById('ospf-routes-table-body');
+  const empty = document.getElementById('ospf-routes-empty');
+  const countBadge = document.getElementById('ospf-routes-count-badge');
+  const navBadge = document.getElementById('dyn-badge-ospf-routes');
+
+  const routes = state.ospfRoutes || [];
+  if (countBadge) countBadge.innerText = `${routes.length} Routes`;
+  if (navBadge) navBadge.innerText = routes.length;
+
+  if (routes.length === 0) {
+    tbody.innerHTML = '';
+    empty.classList.remove('hidden');
+    return;
+  }
+  empty.classList.add('hidden');
+
+  tbody.innerHTML = routes.map(r => `
+    <tr class="hover:bg-slate-800/40 transition">
+      <td class="py-2.5 px-3 font-semibold text-white font-mono">${escapeHtml(r.prefix)}</td>
+      <td class="py-2.5 px-3 text-slate-300">${escapeHtml(r.route_type || 'Network')}</td>
+      <td class="py-2.5 px-3 text-slate-300">${r.cost}</td>
+      <td class="py-2.5 px-3 text-indigo-300 font-bold">Area ${escapeHtml(r.area || '0.0.0.0')}</td>
+      <td class="py-2.5 px-3 text-slate-300 font-mono">${escapeHtml(r.next_hop || r.interface || '-')}</td>
+    </tr>
+  `).join('');
+}
+
+// BGP Modals & Actions
+function openBgpInstanceModal() {
+  const inst = state.bgpInstance || { enabled: false, as: 65001, router_id: '192.168.131.56', hold_time: 180, keepalive_time: 60 };
+  document.getElementById('modal-bgp-enabled').checked = !!inst.enabled;
+  document.getElementById('modal-bgp-as').value = inst.as || 65001;
+  document.getElementById('modal-bgp-router-id').value = inst.router_id || '';
+  document.getElementById('modal-bgp-keepalive').value = inst.keepalive_time || 60;
+  document.getElementById('modal-bgp-holdtime').value = inst.hold_time || 180;
+  document.getElementById('modal-bgp-redist-conn').checked = !!inst.redistribute_connected;
+  document.getElementById('modal-bgp-redist-static').checked = !!inst.redistribute_static;
+  document.getElementById('modal-bgp-redist-ospf').checked = !!inst.redistribute_ospf;
+  document.getElementById('bgp-instance-modal').classList.remove('hidden');
+}
+
+function closeBgpInstanceModal() {
+  document.getElementById('bgp-instance-modal').classList.add('hidden');
+}
+
+async function submitBgpInstance(e) {
+  e.preventDefault();
+  const payload = {
+    enabled: document.getElementById('modal-bgp-enabled').checked,
+    as: parseInt(document.getElementById('modal-bgp-as').value, 10),
+    router_id: document.getElementById('modal-bgp-router-id').value.trim(),
+    keepalive_time: parseInt(document.getElementById('modal-bgp-keepalive').value, 10),
+    hold_time: parseInt(document.getElementById('modal-bgp-holdtime').value, 10),
+    redistribute_connected: document.getElementById('modal-bgp-redist-conn').checked,
+    redistribute_static: document.getElementById('modal-bgp-redist-static').checked,
+    redistribute_ospf: document.getElementById('modal-bgp-redist-ospf').checked
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/routing/bgp/instance`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (res.ok) {
+      closeBgpInstanceModal();
+      showToast('Success', 'BGP instance configuration updated', 'success');
+      loadDynamicRouting();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to update BGP instance', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function openAddBgpPeerModal() {
+  document.getElementById('bgp-peer-form').reset();
+  document.getElementById('modal-bgp-peer-id').value = '';
+  document.getElementById('bgp-peer-modal-title').innerText = 'Add BGP Peer Connection';
+  document.getElementById('modal-bgp-peer-nexthop-self').checked = true;
+  document.getElementById('bgp-peer-modal').classList.remove('hidden');
+}
+
+function openEditBgpPeerModal(id) {
+  const p = (state.bgpPeers || []).find(item => item.id === id);
+  if (!p) return;
+  document.getElementById('modal-bgp-peer-id').value = p.id;
+  document.getElementById('modal-bgp-peer-name').value = p.name || '';
+  document.getElementById('modal-bgp-peer-remote-addr').value = p.remote_address || '';
+  document.getElementById('modal-bgp-peer-remote-as').value = p.remote_as || '';
+  document.getElementById('modal-bgp-peer-local-addr').value = p.local_address || '';
+  document.getElementById('modal-bgp-peer-password').value = p.password || '';
+  document.getElementById('modal-bgp-peer-multihop').checked = !!p.multihop;
+  document.getElementById('modal-bgp-peer-nexthop-self').checked = !!p.nexthop_self;
+  document.getElementById('modal-bgp-peer-passive').checked = !!p.passive;
+  document.getElementById('modal-bgp-peer-disabled').checked = !!p.disabled;
+  document.getElementById('modal-bgp-peer-comment').value = p.comment || '';
+  document.getElementById('bgp-peer-modal-title').innerText = 'Edit BGP Peer Connection';
+  document.getElementById('bgp-peer-modal').classList.remove('hidden');
+}
+
+function closeBgpPeerModal() {
+  document.getElementById('bgp-peer-modal').classList.add('hidden');
+}
+
+async function submitBgpPeer(e) {
+  e.preventDefault();
+  const payload = {
+    id: document.getElementById('modal-bgp-peer-id').value,
+    name: document.getElementById('modal-bgp-peer-name').value.trim(),
+    remote_address: document.getElementById('modal-bgp-peer-remote-addr').value.trim(),
+    remote_as: parseInt(document.getElementById('modal-bgp-peer-remote-as').value, 10),
+    local_address: document.getElementById('modal-bgp-peer-local-addr').value.trim(),
+    password: document.getElementById('modal-bgp-peer-password').value,
+    multihop: document.getElementById('modal-bgp-peer-multihop').checked,
+    nexthop_self: document.getElementById('modal-bgp-peer-nexthop-self').checked,
+    passive: document.getElementById('modal-bgp-peer-passive').checked,
+    disabled: document.getElementById('modal-bgp-peer-disabled').checked,
+    comment: document.getElementById('modal-bgp-peer-comment').value.trim()
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/routing/bgp/peers`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (res.ok) {
+      closeBgpPeerModal();
+      showToast('Success', 'BGP Peer saved', 'success');
+      loadDynamicRouting();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to save peer', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteBgpPeer(id) {
+  if (!confirm('Are you sure you want to delete this BGP peer connection?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/routing/bgp/peers/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'BGP peer removed', 'success');
+      loadDynamicRouting();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleBgpPeerState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/routing/bgp/peers/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) {
+      showToast('Updated', `Peer ${disabled ? 'disabled' : 'enabled'}`, 'success');
+      loadDynamicRouting();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function openAddBgpNetworkModal() {
+  document.getElementById('bgp-network-form').reset();
+  document.getElementById('modal-bgp-net-id').value = '';
+  document.getElementById('bgp-network-modal').classList.remove('hidden');
+}
+
+function closeBgpNetworkModal() {
+  document.getElementById('bgp-network-modal').classList.add('hidden');
+}
+
+async function submitBgpNetwork(e) {
+  e.preventDefault();
+  const payload = {
+    id: document.getElementById('modal-bgp-net-id').value,
+    network: document.getElementById('modal-bgp-net-cidr').value.trim(),
+    comment: document.getElementById('modal-bgp-net-comment').value.trim(),
+    disabled: document.getElementById('modal-bgp-net-disabled').checked
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/routing/bgp/networks`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (res.ok) {
+      closeBgpNetworkModal();
+      showToast('Success', 'BGP network advertisement saved', 'success');
+      loadDynamicRouting();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to save network', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteBgpNetwork(id) {
+  if (!confirm('Are you sure you want to stop announcing this network?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/routing/bgp/networks/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'Network advertisement removed', 'success');
+      loadDynamicRouting();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// OSPF Modals & Actions
+function openOspfInstanceModal() {
+  const inst = state.ospfInstance || { enabled: false, router_id: '192.168.131.56', default_metric: 10 };
+  document.getElementById('modal-ospf-enabled').checked = !!inst.enabled;
+  document.getElementById('modal-ospf-router-id').value = inst.router_id || '';
+  document.getElementById('modal-ospf-metric').value = inst.default_metric || 10;
+  document.getElementById('modal-ospf-redist-conn').checked = !!inst.redistribute_connected;
+  document.getElementById('modal-ospf-redist-static').checked = !!inst.redistribute_static;
+  document.getElementById('modal-ospf-redist-bgp').checked = !!inst.redistribute_bgp;
+  document.getElementById('ospf-instance-modal').classList.remove('hidden');
+}
+
+function closeOspfInstanceModal() {
+  document.getElementById('ospf-instance-modal').classList.add('hidden');
+}
+
+async function submitOspfInstance(e) {
+  e.preventDefault();
+  const payload = {
+    enabled: document.getElementById('modal-ospf-enabled').checked,
+    router_id: document.getElementById('modal-ospf-router-id').value.trim(),
+    default_metric: parseInt(document.getElementById('modal-ospf-metric').value, 10),
+    redistribute_connected: document.getElementById('modal-ospf-redist-conn').checked,
+    redistribute_static: document.getElementById('modal-ospf-redist-static').checked,
+    redistribute_bgp: document.getElementById('modal-ospf-redist-bgp').checked
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/routing/ospf/instance`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (res.ok) {
+      closeOspfInstanceModal();
+      showToast('Success', 'OSPF instance configuration updated', 'success');
+      loadDynamicRouting();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to update OSPF instance', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function openAddOspfAreaModal() {
+  document.getElementById('ospf-area-form').reset();
+  document.getElementById('modal-ospf-area-id').value = '';
+  document.getElementById('modal-ospf-area-areaid').value = '0.0.0.0';
+  document.getElementById('ospf-area-modal').classList.remove('hidden');
+}
+
+function closeOspfAreaModal() {
+  document.getElementById('ospf-area-modal').classList.add('hidden');
+}
+
+async function submitOspfArea(e) {
+  e.preventDefault();
+  const payload = {
+    id: document.getElementById('modal-ospf-area-id').value,
+    name: document.getElementById('modal-ospf-area-name').value.trim(),
+    area_id: document.getElementById('modal-ospf-area-areaid').value.trim(),
+    area_type: document.getElementById('modal-ospf-area-type').value,
+    comment: document.getElementById('modal-ospf-area-comment').value.trim()
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/routing/ospf/areas`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (res.ok) {
+      closeOspfAreaModal();
+      showToast('Success', 'OSPF area saved', 'success');
+      loadDynamicRouting();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to save area', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteOspfArea(id) {
+  if (!confirm('Are you sure you want to delete this OSPF area?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'OSPF area removed', 'success');
+      loadDynamicRouting();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function populateOspfInterfaceDevices() {
+  const select = document.getElementById('modal-ospf-iface-device');
+  if (!select) return;
+  const current = select.value;
+  select.innerHTML = '<option value="">Any matching interface</option>';
+  (state.interfaces || []).forEach(iface => {
+    select.innerHTML += `<option value="${escapeHtml(iface.name)}">${escapeHtml(iface.name)} (${iface.type})</option>`;
+  });
+  if (current) select.value = current;
+}
+
+function openAddOspfInterfaceModal() {
+  document.getElementById('ospf-interface-form').reset();
+  document.getElementById('modal-ospf-iface-id').value = '';
+  document.getElementById('modal-ospf-iface-area').value = '0.0.0.0';
+  document.getElementById('modal-ospf-iface-cost').value = '10';
+  document.getElementById('modal-ospf-iface-priority').value = '1';
+  document.getElementById('modal-ospf-iface-hello').value = '10';
+  document.getElementById('modal-ospf-iface-dead').value = '40';
+  document.getElementById('ospf-interface-modal-title').innerText = 'Add OSPF Interface';
+  populateOspfInterfaceDevices();
+  document.getElementById('ospf-interface-modal').classList.remove('hidden');
+}
+
+function openEditOspfInterfaceModal(id) {
+  const item = (state.ospfInterfaces || []).find(i => i.id === id);
+  if (!item) return;
+  populateOspfInterfaceDevices();
+  document.getElementById('modal-ospf-iface-id').value = item.id;
+  document.getElementById('modal-ospf-iface-net').value = item.network || '';
+  document.getElementById('modal-ospf-iface-area').value = item.area || '0.0.0.0';
+  document.getElementById('modal-ospf-iface-device').value = item.interface || '';
+  document.getElementById('modal-ospf-iface-cost').value = item.cost || 10;
+  document.getElementById('modal-ospf-iface-priority').value = item.priority || 1;
+  document.getElementById('modal-ospf-iface-hello').value = item.hello_interval || 10;
+  document.getElementById('modal-ospf-iface-dead').value = item.dead_interval || 40;
+  document.getElementById('modal-ospf-iface-auth-type').value = item.auth_type || 'none';
+  document.getElementById('modal-ospf-iface-auth-key').value = item.auth_key || '';
+  document.getElementById('modal-ospf-iface-passive').checked = !!item.passive;
+  document.getElementById('modal-ospf-iface-disabled').checked = !!item.disabled;
+  document.getElementById('modal-ospf-iface-comment').value = item.comment || '';
+  document.getElementById('ospf-interface-modal-title').innerText = 'Edit OSPF Interface';
+  document.getElementById('ospf-interface-modal').classList.remove('hidden');
+}
+
+function closeOspfInterfaceModal() {
+  document.getElementById('ospf-interface-modal').classList.add('hidden');
+}
+
+async function submitOspfInterface(e) {
+  e.preventDefault();
+  const payload = {
+    id: document.getElementById('modal-ospf-iface-id').value,
+    network: document.getElementById('modal-ospf-iface-net').value.trim(),
+    area: document.getElementById('modal-ospf-iface-area').value.trim(),
+    interface: document.getElementById('modal-ospf-iface-device').value,
+    cost: parseInt(document.getElementById('modal-ospf-iface-cost').value, 10),
+    priority: parseInt(document.getElementById('modal-ospf-iface-priority').value, 10),
+    hello_interval: parseInt(document.getElementById('modal-ospf-iface-hello').value, 10),
+    dead_interval: parseInt(document.getElementById('modal-ospf-iface-dead').value, 10),
+    auth_type: document.getElementById('modal-ospf-iface-auth-type').value,
+    auth_key: document.getElementById('modal-ospf-iface-auth-key').value,
+    passive: document.getElementById('modal-ospf-iface-passive').checked,
+    disabled: document.getElementById('modal-ospf-iface-disabled').checked,
+    comment: document.getElementById('modal-ospf-iface-comment').value.trim()
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/routing/ospf/interfaces`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (res.ok) {
+      closeOspfInterfaceModal();
+      showToast('Success', 'OSPF interface template saved', 'success');
+      loadDynamicRouting();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to save interface', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteOspfInterface(id) {
+  if (!confirm('Are you sure you want to delete this OSPF interface?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/routing/ospf/interfaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'OSPF interface removed', 'success');
+      loadDynamicRouting();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleOspfInterfaceState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/routing/ospf/interfaces/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) {
+      showToast('Updated', `Interface ${disabled ? 'disabled' : 'enabled'}`, 'success');
+      loadDynamicRouting();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}

+ 639 - 1
internal/web/static/index.html

@@ -135,6 +135,20 @@
           <span id="badge-routes-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
         </a>
 
+        <a href="#dynamic-routing" onclick="navigate('dynamic-routing')" id="nav-dynamic-routing"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
+            </svg>
+            <span>BGP & OSPF</span>
+          </div>
+          <div class="flex items-center gap-1.5">
+            <span id="badge-bgp-dot" class="w-2 h-2 rounded-full bg-slate-600" title="BGP Status"></span>
+            <span id="badge-ospf-dot" class="w-2 h-2 rounded-full bg-slate-600" title="OSPF Status"></span>
+          </div>
+        </a>
+
         <!-- CATEGORY: WAN & ACCESS -->
         <div class="px-3 pt-3 pb-1 text-[10px] font-bold text-slate-500 uppercase tracking-wider">WAN & Access</div>
 
@@ -1534,6 +1548,278 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: DYNAMIC ROUTING (BGP & OSPF) ================= -->
+        <section id="section-dynamic-routing" class="hidden space-y-6">
+          <div class="bg-gradient-to-r from-purple-950/40 via-slate-900 to-slate-900 border border-purple-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <div class="flex items-center gap-2 mb-1">
+                <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-purple-500/20 text-purple-300 border border-purple-500/30 font-semibold">/routing bgp</span>
+                <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 font-semibold">/routing ospf</span>
+                <h2 class="text-base font-bold text-white">Dynamic Routing Engine (BGP & OSPF)</h2>
+              </div>
+              <p class="text-xs text-slate-400">Enterprise FRRouting stack providing BGP peering, network advertisement, and OSPFv2 link-state routing.</p>
+            </div>
+
+            <div class="flex items-center space-x-2 flex-wrap gap-y-2">
+              <button onclick="openBgpInstanceModal()" class="text-xs font-semibold px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-purple-300 border border-purple-900/50 transition flex items-center gap-1.5">
+                <span>⚙ BGP Settings</span>
+              </button>
+              <button onclick="openOspfInstanceModal()" class="text-xs font-semibold px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-indigo-300 border border-indigo-900/50 transition flex items-center gap-1.5">
+                <span>⚙ OSPF Settings</span>
+              </button>
+              <button onclick="openAddBgpPeerModal()" class="text-xs font-semibold px-3 py-2 rounded-lg bg-purple-600 hover:bg-purple-500 text-white shadow-lg shadow-purple-500/20 transition">
+                + Add BGP Peer
+              </button>
+              <button onclick="openAddOspfInterfaceModal()" class="text-xs font-semibold px-3 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/20 transition">
+                + Add OSPF Interface
+              </button>
+            </div>
+          </div>
+
+          <!-- Dynamic Routing Sub-Tabs -->
+          <div class="flex border-b border-slate-800 space-x-1 overflow-x-auto">
+            <button id="dyn-subtab-btn-bgp-peers" onclick="switchDynSubTab('bgp-peers')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-purple-500 text-white transition flex items-center gap-2 whitespace-nowrap">
+              <span>BGP Peers (/routing bgp connection)</span>
+              <span id="dyn-badge-bgp-peers" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="dyn-subtab-btn-bgp-nets" onclick="switchDynSubTab('bgp-nets')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>BGP Networks</span>
+              <span id="dyn-badge-bgp-nets" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="dyn-subtab-btn-bgp-routes" onclick="switchDynSubTab('bgp-routes')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>BGP Routes (RIB)</span>
+              <span id="dyn-badge-bgp-routes" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="dyn-subtab-btn-ospf-ifaces" onclick="switchDynSubTab('ospf-ifaces')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>OSPF Interfaces</span>
+              <span id="dyn-badge-ospf-ifaces" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="dyn-subtab-btn-ospf-areas" onclick="switchDynSubTab('ospf-areas')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>OSPF Areas</span>
+              <span id="dyn-badge-ospf-areas" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="dyn-subtab-btn-ospf-nbrs" onclick="switchDynSubTab('ospf-nbrs')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>OSPF Neighbors</span>
+              <span id="dyn-badge-ospf-nbrs" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="dyn-subtab-btn-ospf-routes" onclick="switchDynSubTab('ospf-routes')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>OSPF Routes</span>
+              <span id="dyn-badge-ospf-routes" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+          </div>
+
+          <!-- Sub-Tab 1: BGP Peers -->
+          <div id="dyn-subtab-bgp-peers" class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">Configured BGP Peers</h3>
+                <span id="bgp-peers-count-badge" class="text-xs font-mono text-slate-400">0 Peers</span>
+                <span id="bgp-live-status-pill" class="text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700">Checking...</span>
+              </div>
+              <div class="flex items-center gap-2">
+                <button onclick="loadDynamicRouting()" class="text-xs text-purple-400 hover:text-purple-300 font-semibold px-2.5 py-1 bg-slate-800 rounded border border-slate-700">Refresh</button>
+              </div>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 w-16 text-center">Flags</th>
+                    <th class="py-2.5 px-3">Name</th>
+                    <th class="py-2.5 px-3">Remote Address</th>
+                    <th class="py-2.5 px-3">Remote AS</th>
+                    <th class="py-2.5 px-3">State</th>
+                    <th class="py-2.5 px-3">Uptime</th>
+                    <th class="py-2.5 px-3">Prefixes (RX/TX)</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="bgp-peers-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+            <div id="bgp-peers-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No BGP peers configured. Click "+ Add BGP Peer" to establish an eBGP or iBGP peering session.
+            </div>
+          </div>
+
+          <!-- Sub-Tab 2: BGP Networks -->
+          <div id="dyn-subtab-bgp-nets" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">Advertised BGP Networks (/routing bgp network)</h3>
+                <span id="bgp-nets-count-badge" class="text-xs font-mono text-slate-400">0 Networks</span>
+              </div>
+              <button onclick="openAddBgpNetworkModal()" class="text-xs font-semibold px-3 py-1.5 rounded-lg bg-purple-600 hover:bg-purple-500 text-white transition">
+                + Add Network
+              </button>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3">Network (CIDR)</th>
+                    <th class="py-2.5 px-3">Status</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="bgp-nets-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+            <div id="bgp-nets-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No networks advertised. Click "+ Add Network" to announce your IP prefixes to BGP peers.
+            </div>
+          </div>
+
+          <!-- Sub-Tab 3: BGP Routes -->
+          <div id="dyn-subtab-bgp-routes" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <h3 class="text-sm font-bold text-white">BGP Routing Information Base (show ip bgp)</h3>
+              <span id="bgp-routes-count-badge" class="text-xs font-mono text-slate-400">0 Routes</span>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3">Prefix</th>
+                    <th class="py-2.5 px-3">Next Hop</th>
+                    <th class="py-2.5 px-3">Metric</th>
+                    <th class="py-2.5 px-3">Weight</th>
+                    <th class="py-2.5 px-3">AS Path</th>
+                    <th class="py-2.5 px-3">Origin</th>
+                  </tr>
+                </thead>
+                <tbody id="bgp-routes-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+            <div id="bgp-routes-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No active routes in BGP table. Check peer connections and ensure BGP is enabled.
+            </div>
+          </div>
+
+          <!-- Sub-Tab 4: OSPF Interfaces -->
+          <div id="dyn-subtab-ospf-ifaces" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">OSPF Interface Templates (/routing ospf interface-template)</h3>
+                <span id="ospf-ifaces-count-badge" class="text-xs font-mono text-slate-400">0 Interfaces</span>
+                <span id="ospf-live-status-pill" class="text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700">Checking...</span>
+              </div>
+              <button onclick="openAddOspfInterfaceModal()" class="text-xs font-semibold px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition">
+                + Add Interface
+              </button>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 w-16 text-center">Flags</th>
+                    <th class="py-2.5 px-3">Network (CIDR)</th>
+                    <th class="py-2.5 px-3">Area</th>
+                    <th class="py-2.5 px-3">Interface</th>
+                    <th class="py-2.5 px-3">Cost</th>
+                    <th class="py-2.5 px-3">Priority</th>
+                    <th class="py-2.5 px-3">Timers (H/D)</th>
+                    <th class="py-2.5 px-3">Auth</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="ospf-ifaces-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+            <div id="ospf-ifaces-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No OSPF interfaces configured. Click "+ Add OSPF Interface" to enable OSPF on local subnets.
+            </div>
+          </div>
+
+          <!-- Sub-Tab 5: OSPF Areas -->
+          <div id="dyn-subtab-ospf-areas" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">OSPF Areas (/routing ospf area)</h3>
+                <span id="ospf-areas-count-badge" class="text-xs font-mono text-slate-400">0 Areas</span>
+              </div>
+              <button onclick="openAddOspfAreaModal()" class="text-xs font-semibold px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition">
+                + Add Area
+              </button>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3">Name</th>
+                    <th class="py-2.5 px-3">Area ID</th>
+                    <th class="py-2.5 px-3">Type</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="ospf-areas-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+          </div>
+
+          <!-- Sub-Tab 6: OSPF Neighbors -->
+          <div id="dyn-subtab-ospf-nbrs" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <h3 class="text-sm font-bold text-white">Live OSPF Neighbors (show ip ospf neighbor)</h3>
+              <span id="ospf-nbrs-count-badge" class="text-xs font-mono text-slate-400">0 Neighbors</span>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3">Neighbor ID</th>
+                    <th class="py-2.5 px-3">IP Address</th>
+                    <th class="py-2.5 px-3">Interface</th>
+                    <th class="py-2.5 px-3">Area</th>
+                    <th class="py-2.5 px-3">State</th>
+                    <th class="py-2.5 px-3">Dead Timer</th>
+                  </tr>
+                </thead>
+                <tbody id="ospf-nbrs-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+            <div id="ospf-nbrs-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No active OSPF neighbors discovered. Ensure neighbors are in the same subnet and hello/dead timers match.
+            </div>
+          </div>
+
+          <!-- Sub-Tab 7: OSPF Routes -->
+          <div id="dyn-subtab-ospf-routes" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <h3 class="text-sm font-bold text-white">OSPF Routing Table (show ip ospf route)</h3>
+              <span id="ospf-routes-count-badge" class="text-xs font-mono text-slate-400">0 Routes</span>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3">Prefix</th>
+                    <th class="py-2.5 px-3">Type</th>
+                    <th class="py-2.5 px-3">Cost</th>
+                    <th class="py-2.5 px-3">Area</th>
+                    <th class="py-2.5 px-3">Next Hop / Dev</th>
+                  </tr>
+                </thead>
+                <tbody id="ospf-routes-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+            <div id="ospf-routes-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No OSPF routes calculated.
+            </div>
+          </div>
+        </section>
+
         <!-- ================= SECTION: DEBIAN CONFIG PERSISTENCE ================= -->
         <section id="section-persistence" class="hidden space-y-6">
           <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
@@ -2813,6 +3099,358 @@
     </div>
   </div>
 
-  <script src="app.js?v=2.4"></script>
+  <!-- ================= MODAL: BGP INSTANCE SETTINGS ================= -->
+  <div id="bgp-instance-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div>
+          <h3 class="text-sm font-bold text-white">BGP Instance Settings (/routing bgp instance)</h3>
+          <p class="text-[11px] text-slate-400">Configure global Autonomous System and route redistribution</p>
+        </div>
+        <button onclick="closeBgpInstanceModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="bgp-instance-form" onsubmit="submitBgpInstance(event)" class="p-6 space-y-4 text-xs">
+        <div class="flex items-center justify-between p-3 rounded-xl bg-slate-800/60 border border-slate-700/60">
+          <div>
+            <div class="font-semibold text-white">Enable BGP Routing</div>
+            <div class="text-[11px] text-slate-400">Run FRRouting bgpd service for dynamic exterior gateway routing</div>
+          </div>
+          <input type="checkbox" id="modal-bgp-enabled" class="w-4 h-4 rounded text-purple-600 focus:ring-purple-500 bg-slate-800 border-slate-600">
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Autonomous System (AS) *</label>
+            <input type="number" id="modal-bgp-as" required min="1" max="4294967295" placeholder="65001" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Router ID</label>
+            <input type="text" id="modal-bgp-router-id" placeholder="192.168.131.56" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Keepalive Time (s)</label>
+            <input type="number" id="modal-bgp-keepalive" value="60" min="1" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Hold Time (s)</label>
+            <input type="number" id="modal-bgp-holdtime" value="180" min="3" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="border-t border-slate-800 pt-3">
+          <label class="block font-semibold text-slate-300 mb-2">Redistribute Routes into BGP</label>
+          <div class="space-y-2">
+            <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+              <input type="checkbox" id="modal-bgp-redist-conn" class="rounded text-purple-600 bg-slate-800 border-slate-700">
+              <span>Redistribute Connected Subnets</span>
+            </label>
+            <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+              <input type="checkbox" id="modal-bgp-redist-static" class="rounded text-purple-600 bg-slate-800 border-slate-700">
+              <span>Redistribute Static Routes</span>
+            </label>
+            <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+              <input type="checkbox" id="modal-bgp-redist-ospf" class="rounded text-purple-600 bg-slate-800 border-slate-700">
+              <span>Redistribute OSPF Routes</span>
+            </label>
+          </div>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeBgpInstanceModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-purple-600 hover:bg-purple-500 text-white shadow-lg shadow-purple-500/20">Save BGP Settings</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: BGP PEER ================= -->
+  <div id="bgp-peer-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div>
+          <h3 id="bgp-peer-modal-title" class="text-sm font-bold text-white">Add BGP Peer Connection</h3>
+          <p class="text-[11px] text-slate-400">Establish eBGP / iBGP session with remote router</p>
+        </div>
+        <button onclick="closeBgpPeerModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="bgp-peer-form" onsubmit="submitBgpPeer(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="modal-bgp-peer-id">
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Connection Name *</label>
+          <input type="text" id="modal-bgp-peer-name" required placeholder="upstream-carrier-a" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Remote Address (IP) *</label>
+            <input type="text" id="modal-bgp-peer-remote-addr" required placeholder="192.168.131.1" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Remote AS *</label>
+            <input type="number" id="modal-bgp-peer-remote-as" required min="1" max="4294967295" placeholder="65000" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Local Address (Update Source)</label>
+            <input type="text" id="modal-bgp-peer-local-addr" placeholder="Optional source IP/iface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">MD5 Password (BGP Auth)</label>
+            <input type="password" id="modal-bgp-peer-password" placeholder="Optional secret" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-3 p-3 rounded-xl bg-slate-800/40 border border-slate-700/50">
+          <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-bgp-peer-multihop" class="rounded text-purple-600 bg-slate-800 border-slate-700">
+            <span>eBGP Multi-hop</span>
+          </label>
+          <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-bgp-peer-nexthop-self" checked class="rounded text-purple-600 bg-slate-800 border-slate-700">
+            <span>Next-hop Self</span>
+          </label>
+          <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-bgp-peer-passive" class="rounded text-purple-600 bg-slate-800 border-slate-700">
+            <span>Passive Mode</span>
+          </label>
+          <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-bgp-peer-disabled" class="rounded text-purple-600 bg-slate-800 border-slate-700">
+            <span>Disabled (X)</span>
+          </label>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="modal-bgp-peer-comment" placeholder="Core transit uplink" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeBgpPeerModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-purple-600 hover:bg-purple-500 text-white shadow-lg shadow-purple-500/20">Save Peer</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: BGP NETWORK ================= -->
+  <div id="bgp-network-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 id="bgp-network-modal-title" class="text-sm font-bold text-white">Advertise BGP Network</h3>
+        <button onclick="closeBgpNetworkModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="bgp-network-form" onsubmit="submitBgpNetwork(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="modal-bgp-net-id">
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Network Prefix (CIDR) *</label>
+          <input type="text" id="modal-bgp-net-cidr" required placeholder="10.200.0.0/24" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="modal-bgp-net-comment" placeholder="Public IP block prefix" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+        <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+          <input type="checkbox" id="modal-bgp-net-disabled" class="rounded text-purple-600 bg-slate-800 border-slate-700">
+          <span>Disabled</span>
+        </label>
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeBgpNetworkModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-purple-600 hover:bg-purple-500 text-white shadow-lg shadow-purple-500/20">Save Network</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: OSPF INSTANCE SETTINGS ================= -->
+  <div id="ospf-instance-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div>
+          <h3 class="text-sm font-bold text-white">OSPF Instance Settings (/routing ospf instance)</h3>
+          <p class="text-[11px] text-slate-400">Configure OSPFv2 link-state routing daemon and redistributions</p>
+        </div>
+        <button onclick="closeOspfInstanceModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="ospf-instance-form" onsubmit="submitOspfInstance(event)" class="p-6 space-y-4 text-xs">
+        <div class="flex items-center justify-between p-3 rounded-xl bg-slate-800/60 border border-slate-700/60">
+          <div>
+            <div class="font-semibold text-white">Enable OSPF Routing</div>
+            <div class="text-[11px] text-slate-400">Run FRRouting ospfd service for dynamic interior gateway routing</div>
+          </div>
+          <input type="checkbox" id="modal-ospf-enabled" class="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 bg-slate-800 border-slate-600">
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Router ID</label>
+            <input type="text" id="modal-ospf-router-id" placeholder="192.168.131.56" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-indigo-500">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Default Metric</label>
+            <input type="number" id="modal-ospf-metric" value="10" min="1" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="border-t border-slate-800 pt-3">
+          <label class="block font-semibold text-slate-300 mb-2">Redistribute Routes into OSPF</label>
+          <div class="space-y-2">
+            <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+              <input type="checkbox" id="modal-ospf-redist-conn" class="rounded text-indigo-600 bg-slate-800 border-slate-700">
+              <span>Redistribute Connected Subnets</span>
+            </label>
+            <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+              <input type="checkbox" id="modal-ospf-redist-static" class="rounded text-indigo-600 bg-slate-800 border-slate-700">
+              <span>Redistribute Static Routes</span>
+            </label>
+            <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+              <input type="checkbox" id="modal-ospf-redist-bgp" class="rounded text-indigo-600 bg-slate-800 border-slate-700">
+              <span>Redistribute BGP Routes</span>
+            </label>
+          </div>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeOspfInstanceModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/20">Save OSPF Settings</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: OSPF AREA ================= -->
+  <div id="ospf-area-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 id="ospf-area-modal-title" class="text-sm font-bold text-white">Add OSPF Area</h3>
+        <button onclick="closeOspfAreaModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="ospf-area-form" onsubmit="submitOspfArea(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="modal-ospf-area-id">
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Area Name *</label>
+          <input type="text" id="modal-ospf-area-name" required placeholder="backbone" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+        </div>
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Area ID *</label>
+            <input type="text" id="modal-ospf-area-areaid" required placeholder="0.0.0.0" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Area Type</label>
+            <select id="modal-ospf-area-type" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+              <option value="default">Default / Normal</option>
+              <option value="stub">Stub</option>
+              <option value="nssa">NSSA</option>
+            </select>
+          </div>
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="modal-ospf-area-comment" placeholder="Area description" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeOspfAreaModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/20">Save Area</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: OSPF INTERFACE ================= -->
+  <div id="ospf-interface-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div>
+          <h3 id="ospf-interface-modal-title" class="text-sm font-bold text-white">Add OSPF Interface</h3>
+          <p class="text-[11px] text-slate-400">Configure OSPF link parameters on a network subnet or interface</p>
+        </div>
+        <button onclick="closeOspfInterfaceModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="ospf-interface-form" onsubmit="submitOspfInterface(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="modal-ospf-iface-id">
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Subnet Network (CIDR) *</label>
+            <input type="text" id="modal-ospf-iface-net" required placeholder="192.168.131.0/24" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">OSPF Area *</label>
+            <input type="text" id="modal-ospf-iface-area" required value="0.0.0.0" placeholder="0.0.0.0" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Interface (Optional Device)</label>
+            <select id="modal-ospf-iface-device" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+              <option value="">Any matching interface</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Metric Cost</label>
+            <input type="number" id="modal-ospf-iface-cost" value="10" min="1" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-3 gap-3">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Priority</label>
+            <input type="number" id="modal-ospf-iface-priority" value="1" min="0" max="255" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Hello (s)</label>
+            <input type="number" id="modal-ospf-iface-hello" value="10" min="1" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Dead (s)</label>
+            <input type="number" id="modal-ospf-iface-dead" value="40" min="1" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Authentication Type</label>
+            <select id="modal-ospf-iface-auth-type" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+              <option value="none">None</option>
+              <option value="simple">Simple Password</option>
+              <option value="md5">MD5 Cryptographic</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Auth Secret Key</label>
+            <input type="password" id="modal-ospf-iface-auth-key" placeholder="••••••••" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-3 p-3 rounded-xl bg-slate-800/40 border border-slate-700/50">
+          <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-ospf-iface-passive" class="rounded text-indigo-600 bg-slate-800 border-slate-700">
+            <span>Passive (Do not form adjs)</span>
+          </label>
+          <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-ospf-iface-disabled" class="rounded text-indigo-600 bg-slate-800 border-slate-700">
+            <span>Disabled (X)</span>
+          </label>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="modal-ospf-iface-comment" placeholder="LAN segment routing" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeOspfInterfaceModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/20">Save Interface</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <script src="app.js?v=2.5"></script>
 </body>
 </html>