Browse Source

feat(bridge): implement MikroTik RouterOS /interface bridge subsystem with ports, vlans, fdb hosts, and IP assignment

Proxmox Router 1 week ago
parent
commit
642851253e

+ 3 - 1
cmd/server/main.go

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

+ 26 - 1
internal/api/api.go

@@ -5,6 +5,7 @@ import (
 	"net/http"
 	"strings"
 
+	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
@@ -21,11 +22,12 @@ type Server struct {
 	fwMgr         *firewall.FirewallManager
 	routingMgr    *routing.RoutingManager
 	dynRoutingMgr *dynamicrouting.Manager
+	bridgeMgr     *bridge.BridgeManager
 	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, dynRoutingMgr *dynamicrouting.Manager, 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, bridgeMgr *bridge.BridgeManager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -34,6 +36,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		fwMgr:         fwMgr,
 		routingMgr:    routingMgr,
 		dynRoutingMgr: dynRoutingMgr,
+		bridgeMgr:     bridgeMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -115,6 +118,28 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/vlans", s.handleCreateVLAN)
 	s.mux.HandleFunc("DELETE /api/vlans/{name}", s.handleDeleteVLAN)
 
+	// Bridge Management (/interface bridge)
+	s.mux.HandleFunc("GET /api/bridge/summary", s.handleBridgeSummary)
+	s.mux.HandleFunc("GET /api/bridge/bridges", s.handleListBridges)
+	s.mux.HandleFunc("POST /api/bridge/bridges", s.handleSaveBridge)
+	s.mux.HandleFunc("DELETE /api/bridge/bridges/{id}", s.handleDeleteBridge)
+	s.mux.HandleFunc("POST /api/bridge/bridges/{id}/state", s.handleSetBridgeState)
+	s.mux.HandleFunc("POST /api/bridge/bridges/{id}/ip", s.handleAddBridgeIP)
+	s.mux.HandleFunc("DELETE /api/bridge/bridges/{id}/ip", s.handleDeleteBridgeIP)
+
+	s.mux.HandleFunc("GET /api/bridge/ports", s.handleListBridgePorts)
+	s.mux.HandleFunc("POST /api/bridge/ports", s.handleSaveBridgePort)
+	s.mux.HandleFunc("DELETE /api/bridge/ports/{id}", s.handleDeleteBridgePort)
+	s.mux.HandleFunc("POST /api/bridge/ports/{id}/state", s.handleSetBridgePortState)
+
+	s.mux.HandleFunc("GET /api/bridge/vlans", s.handleListBridgeVLANs)
+	s.mux.HandleFunc("POST /api/bridge/vlans", s.handleSaveBridgeVLAN)
+	s.mux.HandleFunc("DELETE /api/bridge/vlans/{id}", s.handleDeleteBridgeVLAN)
+	s.mux.HandleFunc("POST /api/bridge/vlans/{id}/state", s.handleSetBridgeVLANState)
+
+	s.mux.HandleFunc("GET /api/bridge/hosts", s.handleListBridgeHosts)
+	s.mux.HandleFunc("POST /api/bridge/hosts/flush", s.handleFlushBridgeHosts)
+
 	s.mux.HandleFunc("GET /api/debian/config", s.handleGetDebianConfig)
 	s.mux.HandleFunc("POST /api/debian/apply", s.handleSaveDebianConfig)
 

+ 199 - 0
internal/api/bridge_handlers.go

@@ -0,0 +1,199 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"proxmox-router/internal/bridge"
+)
+
+func (s *Server) handleBridgeSummary(w http.ResponseWriter, r *http.Request) {
+	summary := s.bridgeMgr.GetSummary()
+	writeJSON(w, http.StatusOK, summary)
+}
+
+// ---------------- Bridges (/interface bridge) ----------------
+
+func (s *Server) handleListBridges(w http.ResponseWriter, r *http.Request) {
+	list := s.bridgeMgr.ListBridges()
+	writeJSON(w, http.StatusOK, list)
+}
+
+func (s *Server) handleSaveBridge(w http.ResponseWriter, r *http.Request) {
+	var b bridge.BridgeInstance
+	if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
+		return
+	}
+	if err := s.bridgeMgr.SaveBridge(b); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": b.ID})
+}
+
+func (s *Server) handleDeleteBridge(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := s.bridgeMgr.DeleteBridge(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleSetBridgeState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.bridgeMgr.SetBridgeState(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+func (s *Server) handleAddBridgeIP(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		IP string `json:"ip"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.IP == "" {
+		writeError(w, http.StatusBadRequest, "ip address is required")
+		return
+	}
+	if err := s.bridgeMgr.AddBridgeIP(id, req.IP); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "ip": req.IP})
+}
+
+func (s *Server) handleDeleteBridgeIP(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		IP string `json:"ip"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.IP == "" {
+		writeError(w, http.StatusBadRequest, "ip address is required")
+		return
+	}
+	if err := s.bridgeMgr.DeleteBridgeIP(id, req.IP); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "ip": req.IP})
+}
+
+// ---------------- Ports (/interface bridge port) ----------------
+
+func (s *Server) handleListBridgePorts(w http.ResponseWriter, r *http.Request) {
+	list := s.bridgeMgr.ListPorts()
+	writeJSON(w, http.StatusOK, list)
+}
+
+func (s *Server) handleSaveBridgePort(w http.ResponseWriter, r *http.Request) {
+	var p bridge.BridgePort
+	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
+		return
+	}
+	if err := s.bridgeMgr.SavePort(p); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": p.ID})
+}
+
+func (s *Server) handleDeleteBridgePort(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := s.bridgeMgr.DeletePort(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleSetBridgePortState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.bridgeMgr.SetPortState(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+// ---------------- VLANs (/interface bridge vlan) ----------------
+
+func (s *Server) handleListBridgeVLANs(w http.ResponseWriter, r *http.Request) {
+	list := s.bridgeMgr.ListVLANs()
+	writeJSON(w, http.StatusOK, list)
+}
+
+func (s *Server) handleSaveBridgeVLAN(w http.ResponseWriter, r *http.Request) {
+	var v bridge.BridgeVLAN
+	if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
+		return
+	}
+	if err := s.bridgeMgr.SaveVLAN(v); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": v.ID})
+}
+
+func (s *Server) handleDeleteBridgeVLAN(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := s.bridgeMgr.DeleteVLAN(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleSetBridgeVLANState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.bridgeMgr.SetVLANState(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+// ---------------- Hosts / FDB Table (/interface bridge host) ----------------
+
+func (s *Server) handleListBridgeHosts(w http.ResponseWriter, r *http.Request) {
+	bridgeName := r.URL.Query().Get("bridge")
+	hosts, err := s.bridgeMgr.ListHosts(bridgeName)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, hosts)
+}
+
+func (s *Server) handleFlushBridgeHosts(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		Bridge string `json:"bridge"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if req.Bridge == "" {
+		writeError(w, http.StatusBadRequest, "bridge is required")
+		return
+	}
+	if err := s.bridgeMgr.FlushHosts(req.Bridge); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "bridge": req.Bridge})
+}

+ 811 - 0
internal/bridge/manager.go

@@ -0,0 +1,811 @@
+package bridge
+
+import (
+	"bufio"
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/vishvananda/netlink"
+)
+
+const BridgeConfigFile = "/etc/proxmox-router/bridge.json"
+
+type BridgeConfigStore struct {
+	Bridges []*BridgeInstance `json:"bridges"`
+	Ports   []*BridgePort     `json:"ports"`
+	VLANs   []*BridgeVLAN     `json:"vlans"`
+}
+
+type BridgeManager struct {
+	mu        sync.RWMutex
+	storePath string
+	bridges   map[string]*BridgeInstance
+	ports     map[string]*BridgePort
+	vlans     map[string]*BridgeVLAN
+}
+
+func NewBridgeManager() *BridgeManager {
+	bm := &BridgeManager{
+		storePath: BridgeConfigFile,
+		bridges:   make(map[string]*BridgeInstance),
+		ports:     make(map[string]*BridgePort),
+		vlans:     make(map[string]*BridgeVLAN),
+	}
+	bm.load()
+	bm.SyncAll()
+	return bm
+}
+
+func (m *BridgeManager) load() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	data, err := os.ReadFile(m.storePath)
+	if err != nil {
+		return
+	}
+	var store BridgeConfigStore
+	if err := json.Unmarshal(data, &store); err != nil {
+		return
+	}
+
+	for _, b := range store.Bridges {
+		m.bridges[b.ID] = b
+	}
+	for _, p := range store.Ports {
+		m.ports[p.ID] = p
+	}
+	for _, v := range store.VLANs {
+		m.vlans[v.ID] = v
+	}
+}
+
+func (m *BridgeManager) save() error {
+	_ = os.MkdirAll(filepath.Dir(m.storePath), 0755)
+
+	store := BridgeConfigStore{
+		Bridges: make([]*BridgeInstance, 0, len(m.bridges)),
+		Ports:   make([]*BridgePort, 0, len(m.ports)),
+		VLANs:   make([]*BridgeVLAN, 0, len(m.vlans)),
+	}
+
+	for _, b := range m.bridges {
+		store.Bridges = append(store.Bridges, b)
+	}
+	for _, p := range m.ports {
+		store.Ports = append(store.Ports, p)
+	}
+	for _, v := range m.vlans {
+		store.VLANs = append(store.VLANs, v)
+	}
+
+	data, err := json.MarshalIndent(store, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(m.storePath, data, 0644)
+}
+
+// ==================== Bridge Interfaces ====================
+
+func (m *BridgeManager) ListBridges() []*BridgeInstance {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	list := make([]*BridgeInstance, 0, len(m.bridges))
+	for _, b := range m.bridges {
+		m.updateBridgeLiveStats(b)
+		list = append(list, b)
+	}
+	return list
+}
+
+func (m *BridgeManager) GetBridge(idOrName string) (*BridgeInstance, bool) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if b, ok := m.bridges[idOrName]; ok {
+		m.updateBridgeLiveStats(b)
+		return b, true
+	}
+	for _, b := range m.bridges {
+		if b.Name == idOrName {
+			m.updateBridgeLiveStats(b)
+			return b, true
+		}
+	}
+	return nil, false
+}
+
+func (m *BridgeManager) SaveBridge(b BridgeInstance) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if b.Name == "" {
+		return fmt.Errorf("bridge name is required")
+	}
+	if b.MTU <= 0 {
+		b.MTU = 1500
+	}
+	if b.PVID <= 0 {
+		b.PVID = 1
+	}
+	if b.STP == "" {
+		b.STP = "rstp"
+	}
+	if b.Priority <= 0 {
+		b.Priority = 32768
+	}
+
+	if b.ID == "" {
+		b.ID = fmt.Sprintf("br-%d", time.Now().UnixNano())
+	}
+
+	// Update existing or add new
+	m.bridges[b.ID] = &b
+	_ = m.save()
+
+	// Apply in Linux kernel
+	return m.syncBridge(&b)
+}
+
+func (m *BridgeManager) DeleteBridge(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	b, ok := m.bridges[id]
+	if !ok {
+		return fmt.Errorf("bridge not found")
+	}
+
+	// Detach any ports belonging to this bridge
+	for _, p := range m.ports {
+		if p.Bridge == b.Name {
+			_ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run()
+			delete(m.ports, p.ID)
+		}
+	}
+
+	// Delete any VLAN entries for this bridge
+	for vid, v := range m.vlans {
+		if v.Bridge == b.Name {
+			delete(m.vlans, vid)
+		}
+	}
+
+	// Delete bridge link in kernel
+	_ = exec.Command("ip", "link", "del", b.Name).Run()
+
+	delete(m.bridges, id)
+	return m.save()
+}
+
+func (m *BridgeManager) SetBridgeState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	b, ok := m.bridges[id]
+	if !ok {
+		return fmt.Errorf("bridge not found")
+	}
+
+	b.Disabled = disabled
+	_ = m.save()
+
+	if disabled {
+		_ = exec.Command("ip", "link", "set", b.Name, "down").Run()
+	} else {
+		_ = exec.Command("ip", "link", "set", b.Name, "up").Run()
+	}
+	return nil
+}
+
+func (m *BridgeManager) AddBridgeIP(id string, ipCIDR string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	b, ok := m.bridges[id]
+	if !ok {
+		return fmt.Errorf("bridge not found")
+	}
+
+	ipCIDR = strings.TrimSpace(ipCIDR)
+	if _, _, err := net.ParseCIDR(ipCIDR); err != nil {
+		return fmt.Errorf("invalid IP/CIDR: %w", err)
+	}
+
+	// Check if already in list
+	for _, ip := range b.IPAddresses {
+		if ip == ipCIDR {
+			return nil
+		}
+	}
+
+	b.IPAddresses = append(b.IPAddresses, ipCIDR)
+	_ = m.save()
+
+	// Add to kernel
+	_ = exec.Command("ip", "addr", "add", ipCIDR, "dev", b.Name).Run()
+	return nil
+}
+
+func (m *BridgeManager) DeleteBridgeIP(id string, ipCIDR string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	b, ok := m.bridges[id]
+	if !ok {
+		return fmt.Errorf("bridge not found")
+	}
+
+	ipCIDR = strings.TrimSpace(ipCIDR)
+	var updated []string
+	for _, ip := range b.IPAddresses {
+		if ip != ipCIDR {
+			updated = append(updated, ip)
+		}
+	}
+	b.IPAddresses = updated
+	_ = m.save()
+
+	_ = exec.Command("ip", "addr", "del", ipCIDR, "dev", b.Name).Run()
+	return nil
+}
+
+func (m *BridgeManager) syncBridge(b *BridgeInstance) error {
+	link, err := netlink.LinkByName(b.Name)
+	if err != nil {
+		// Create bridge
+		args := []string{"link", "add", b.Name, "type", "bridge"}
+		if b.VlanFiltering {
+			args = append(args, "vlan_filtering", "1")
+		} else {
+			args = append(args, "vlan_filtering", "0")
+		}
+		if b.PVID > 0 {
+			args = append(args, "vlan_default_pvid", strconv.Itoa(b.PVID))
+		}
+		if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil {
+			return fmt.Errorf("failed to create bridge %s: %s: %w", b.Name, string(out), err)
+		}
+		link, _ = netlink.LinkByName(b.Name)
+	}
+
+	if link != nil {
+		// Set MTU
+		if b.MTU > 0 {
+			_ = netlink.LinkSetMTU(link, b.MTU)
+		}
+
+		// Set Admin MAC if specified
+		if b.MACAddress != "" {
+			if hw, err := net.ParseMAC(b.MACAddress); err == nil {
+				_ = netlink.LinkSetHardwareAddr(link, hw)
+			}
+		}
+
+		// Set VLAN filtering on existing bridge
+		vfVal := "0"
+		if b.VlanFiltering {
+			vfVal = "1"
+		}
+		pvidVal := strconv.Itoa(b.PVID)
+		if b.PVID <= 0 {
+			pvidVal = "1"
+		}
+		_ = exec.Command("ip", "link", "set", b.Name, "type", "bridge", "vlan_filtering", vfVal, "vlan_default_pvid", pvidVal).Run()
+
+		// Set STP
+		stpVal := "0"
+		if b.STP == "rstp" || b.STP == "stp" {
+			stpVal = "1"
+		}
+		_ = os.WriteFile(fmt.Sprintf("/sys/class/net/%s/bridge/stp_state", b.Name), []byte(stpVal), 0644)
+
+		// Set link up or down
+		if b.Disabled {
+			_ = netlink.LinkSetDown(link)
+		} else {
+			_ = netlink.LinkSetUp(link)
+		}
+
+		// Sync IP addresses
+		for _, ip := range b.IPAddresses {
+			_ = exec.Command("ip", "addr", "add", ip, "dev", b.Name).Run()
+		}
+	}
+
+	return nil
+}
+
+func (m *BridgeManager) updateBridgeLiveStats(b *BridgeInstance) {
+	// Count ports belonging to this bridge
+	portsCount := 0
+	for _, p := range m.ports {
+		if p.Bridge == b.Name && !p.Disabled {
+			portsCount++
+		}
+	}
+	b.PortsCount = portsCount
+
+	link, err := netlink.LinkByName(b.Name)
+	if err != nil {
+		b.Running = false
+		return
+	}
+
+	attrs := link.Attrs()
+	b.Running = (attrs.Flags&net.FlagUp != 0) && (attrs.OperState == netlink.OperUp || attrs.OperState == netlink.OperUnknown)
+	if b.MACAddress == "" {
+		b.MACAddress = attrs.HardwareAddr.String()
+	}
+
+	if attrs.Statistics != nil {
+		b.RxPackets = attrs.Statistics.RxPackets
+		b.TxPackets = attrs.Statistics.TxPackets
+		b.RxBytes = attrs.Statistics.RxBytes
+		b.TxBytes = attrs.Statistics.TxBytes
+		b.RxHuman = formatBytes(b.RxBytes)
+		b.TxHuman = formatBytes(b.TxBytes)
+	}
+
+	// Read IP addresses from kernel
+	if addrs, err := netlink.AddrList(link, netlink.FAMILY_V4); err == nil {
+		var ips []string
+		for _, a := range addrs {
+			ips = append(ips, a.IPNet.String())
+		}
+		b.IPAddresses = ips
+	}
+}
+
+// ==================== Bridge Ports ====================
+
+func (m *BridgeManager) ListPorts() []*BridgePort {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	list := make([]*BridgePort, 0, len(m.ports))
+	for _, p := range m.ports {
+		m.updatePortLiveStatus(p)
+		list = append(list, p)
+	}
+	return list
+}
+
+func (m *BridgeManager) SavePort(p BridgePort) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if p.Bridge == "" || p.Interface == "" {
+		return fmt.Errorf("bridge and interface are required")
+	}
+	if p.PVID <= 0 {
+		p.PVID = 1
+	}
+	if p.Priority <= 0 {
+		p.Priority = 128
+	}
+	if p.PathCost <= 0 {
+		p.PathCost = 10
+	}
+	if p.FrameTypes == "" {
+		p.FrameTypes = "admit-all"
+	}
+
+	if p.ID == "" {
+		p.ID = fmt.Sprintf("port-%d", time.Now().UnixNano())
+	}
+
+	m.ports[p.ID] = &p
+	_ = m.save()
+
+	return m.syncPort(&p)
+}
+
+func (m *BridgeManager) DeletePort(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	p, ok := m.ports[id]
+	if !ok {
+		return fmt.Errorf("port not found")
+	}
+
+	// Remove from bridge in kernel
+	_ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run()
+
+	delete(m.ports, id)
+	return m.save()
+}
+
+func (m *BridgeManager) SetPortState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	p, ok := m.ports[id]
+	if !ok {
+		return fmt.Errorf("port not found")
+	}
+
+	p.Disabled = disabled
+	_ = m.save()
+
+	if disabled {
+		_ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run()
+	} else {
+		_ = m.syncPort(p)
+	}
+	return nil
+}
+
+func (m *BridgeManager) syncPort(p *BridgePort) error {
+	if p.Disabled {
+		_ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run()
+		return nil
+	}
+
+	// Check if interface exists
+	if _, err := netlink.LinkByName(p.Interface); err != nil {
+		return fmt.Errorf("interface %s does not exist", p.Interface)
+	}
+
+	// Attach interface to bridge
+	if out, err := exec.Command("ip", "link", "set", p.Interface, "master", p.Bridge).CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to attach %s to bridge %s: %s: %w", p.Interface, p.Bridge, string(out), err)
+	}
+
+	// Bring interface UP
+	_ = exec.Command("ip", "link", "set", p.Interface, "up").Run()
+
+	// Port isolation / Split Horizon
+	isolated := "off"
+	if p.Horizon > 0 {
+		isolated = "on"
+	}
+	_ = exec.Command("bridge", "link", "set", "dev", p.Interface, "isolated", isolated).Run()
+
+	// Learning
+	learning := "on"
+	if !p.Learn {
+		learning = "off"
+	}
+	_ = exec.Command("bridge", "link", "set", "dev", p.Interface, "learning", learning).Run()
+
+	// Ingress filtering
+	ingress := "off"
+	if p.IngressFiltering {
+		ingress = "on"
+	}
+	_ = exec.Command("bridge", "link", "set", "dev", p.Interface, "ingress_filtering", ingress).Run()
+
+	// Priority & Path Cost
+	prioCostArgs := []string{"link", "set", "dev", p.Interface}
+	if p.Priority > 0 {
+		prioCostArgs = append(prioCostArgs, "priority", strconv.Itoa(p.Priority))
+	}
+	if p.PathCost > 0 {
+		prioCostArgs = append(prioCostArgs, "cost", strconv.Itoa(p.PathCost))
+	}
+	if len(prioCostArgs) > 4 {
+		_ = exec.Command("bridge", prioCostArgs...).Run()
+	}
+
+	// VLAN filtering configuration
+	if p.PVID > 0 {
+		_ = exec.Command("bridge", "vlan", "add", "dev", p.Interface, "vid", strconv.Itoa(p.PVID), "pvid", "untagged").Run()
+	}
+
+	return nil
+}
+
+func (m *BridgeManager) updatePortLiveStatus(p *BridgePort) {
+	if p.Disabled {
+		p.Status = "disabled"
+		return
+	}
+
+	link, err := netlink.LinkByName(p.Interface)
+	if err != nil {
+		p.Status = "inactive"
+		return
+	}
+
+	attrs := link.Attrs()
+	if attrs.OperState == netlink.OperDown {
+		p.Status = "disabled"
+	} else if attrs.MasterIndex > 0 {
+		p.Status = "forwarding"
+	} else {
+		p.Status = "unattached"
+	}
+}
+
+// ==================== Bridge VLANs ====================
+
+func (m *BridgeManager) ListVLANs() []*BridgeVLAN {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	list := make([]*BridgeVLAN, 0, len(m.vlans))
+	for _, v := range m.vlans {
+		v.CurrentTagged = v.Tagged
+		v.CurrentUntagged = v.Untagged
+		list = append(list, v)
+	}
+	return list
+}
+
+func (m *BridgeManager) SaveVLAN(v BridgeVLAN) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if v.Bridge == "" || v.VlanIDs == "" {
+		return fmt.Errorf("bridge and vlan_ids are required")
+	}
+
+	if v.ID == "" {
+		v.ID = fmt.Sprintf("bvlan-%d", time.Now().UnixNano())
+	}
+
+	m.vlans[v.ID] = &v
+	_ = m.save()
+
+	return m.syncVLAN(&v)
+}
+
+func (m *BridgeManager) DeleteVLAN(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	v, ok := m.vlans[id]
+	if !ok {
+		return fmt.Errorf("vlan entry not found")
+	}
+
+	// Remove VLANs from kernel
+	vids := parseVLANRange(v.VlanIDs)
+	for _, vid := range vids {
+		vidStr := strconv.Itoa(vid)
+		for _, iface := range append(v.Tagged, v.Untagged...) {
+			_ = exec.Command("bridge", "vlan", "del", "dev", iface, "vid", vidStr).Run()
+		}
+	}
+
+	delete(m.vlans, id)
+	return m.save()
+}
+
+func (m *BridgeManager) SetVLANState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	v, ok := m.vlans[id]
+	if !ok {
+		return fmt.Errorf("vlan entry not found")
+	}
+
+	v.Disabled = disabled
+	_ = m.save()
+
+	if disabled {
+		vids := parseVLANRange(v.VlanIDs)
+		for _, vid := range vids {
+			vidStr := strconv.Itoa(vid)
+			for _, iface := range append(v.Tagged, v.Untagged...) {
+				_ = exec.Command("bridge", "vlan", "del", "dev", iface, "vid", vidStr).Run()
+			}
+		}
+	} else {
+		_ = m.syncVLAN(v)
+	}
+	return nil
+}
+
+func (m *BridgeManager) syncVLAN(v *BridgeVLAN) error {
+	if v.Disabled {
+		return nil
+	}
+
+	vids := parseVLANRange(v.VlanIDs)
+	for _, vid := range vids {
+		vidStr := strconv.Itoa(vid)
+		for _, iface := range v.Tagged {
+			_ = exec.Command("bridge", "vlan", "add", "dev", iface, "vid", vidStr).Run()
+		}
+		for _, iface := range v.Untagged {
+			_ = exec.Command("bridge", "vlan", "add", "dev", iface, "vid", vidStr, "untagged").Run()
+		}
+	}
+	return nil
+}
+
+// ==================== Bridge Hosts (FDB / MAC Table) ====================
+
+func (m *BridgeManager) ListHosts(bridgeFilter string) ([]BridgeHost, error) {
+	args := []string{"fdb", "show"}
+	if bridgeFilter != "" {
+		args = append(args, "br", bridgeFilter)
+	}
+
+	out, err := exec.Command("bridge", args...).Output()
+	if err != nil {
+		return nil, fmt.Errorf("failed to run bridge fdb show: %w", err)
+	}
+
+	var hosts []BridgeHost
+	scanner := bufio.NewScanner(strings.NewReader(string(out)))
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if line == "" {
+			continue
+		}
+
+		fields := strings.Fields(line)
+		if len(fields) < 3 {
+			continue
+		}
+
+		mac := fields[0]
+		dev := ""
+		vlan := 1
+		master := ""
+		age := 0.0
+		isLocal := false
+		isStatic := false
+
+		for i := 1; i < len(fields); i++ {
+			switch fields[i] {
+			case "dev":
+				if i+1 < len(fields) {
+					dev = fields[i+1]
+					i++
+				}
+			case "vlan":
+				if i+1 < len(fields) {
+					vlan, _ = strconv.Atoi(fields[i+1])
+					i++
+				}
+			case "master":
+				if i+1 < len(fields) {
+					master = fields[i+1]
+					i++
+				}
+			case "self":
+				isLocal = true
+			case "permanent":
+				isLocal = true
+				isStatic = true
+			case "static":
+				isStatic = true
+			default:
+				if val, err := strconv.ParseFloat(fields[i], 64); err == nil {
+					age = val
+				}
+			}
+		}
+
+		if master == "" && strings.HasPrefix(dev, "br") {
+			master = dev
+		}
+
+		var flags []string
+		if isLocal {
+			flags = append(flags, "L")
+		} else if isStatic {
+			flags = append(flags, "S")
+		} else {
+			flags = append(flags, "D")
+		}
+
+		hosts = append(hosts, BridgeHost{
+			MAC:       mac,
+			Interface: dev,
+			Bridge:    master,
+			VlanID:    vlan,
+			Age:       age,
+			Local:     isLocal,
+			Static:    isStatic,
+			Flags:     strings.Join(flags, ","),
+		})
+	}
+
+	return hosts, nil
+}
+
+func (m *BridgeManager) FlushHosts(bridge string) error {
+	if bridge == "" {
+		return fmt.Errorf("bridge name required for flush")
+	}
+	_ = exec.Command("bridge", "fdb", "flush", "dev", bridge, "dynamic").Run()
+	return nil
+}
+
+// ==================== Summary & SyncAll ====================
+
+func (m *BridgeManager) GetSummary() BridgeSummary {
+	m.mu.RLock()
+	bCount := len(m.bridges)
+	pCount := len(m.ports)
+	vCount := len(m.vlans)
+	m.mu.RUnlock()
+
+	hCount := 0
+	if hosts, err := m.ListHosts(""); err == nil {
+		hCount = len(hosts)
+	}
+
+	return BridgeSummary{
+		BridgesCount: bCount,
+		PortsCount:   pCount,
+		VLANsCount:   vCount,
+		HostsCount:   hCount,
+	}
+}
+
+func (m *BridgeManager) SyncAll() {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	// 1. Sync all bridges
+	for _, b := range m.bridges {
+		_ = m.syncBridge(b)
+	}
+
+	// 2. Sync all ports
+	for _, p := range m.ports {
+		_ = m.syncPort(p)
+	}
+
+	// 3. Sync all VLAN entries
+	for _, v := range m.vlans {
+		_ = m.syncVLAN(v)
+	}
+}
+
+// Helper: parse "10" or "10,20,30" or "10-20" into int slice
+func parseVLANRange(s string) []int {
+	var result []int
+	parts := strings.Split(s, ",")
+	for _, p := range parts {
+		p = strings.TrimSpace(p)
+		if strings.Contains(p, "-") {
+			rangeParts := strings.Split(p, "-")
+			if len(rangeParts) == 2 {
+				start, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
+				end, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
+				if err1 == nil && err2 == nil && start <= end {
+					for i := start; i <= end; i++ {
+						result = append(result, i)
+					}
+					continue
+				}
+			}
+		}
+		if val, err := strconv.Atoi(p); err == nil {
+			result = append(result, val)
+		}
+	}
+	return result
+}
+
+func formatBytes(b uint64) string {
+	const unit = 1024
+	if b < unit {
+		return fmt.Sprintf("%d B", b)
+	}
+	div, exp := uint64(unit), 0
+	for n := b / unit; n >= unit; n /= unit {
+		div *= unit
+		exp++
+	}
+	return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
+}

+ 75 - 0
internal/bridge/types.go

@@ -0,0 +1,75 @@
+package bridge
+
+// BridgeInstance represents a MikroTik-style /interface bridge
+type BridgeInstance struct {
+	ID            string   `json:"id"`
+	Name          string   `json:"name"`           // e.g. "bridge1" or "bridge-lan"
+	MTU           int      `json:"mtu"`            // default 1500
+	MACAddress    string   `json:"mac_address"`    // Admin MAC or auto
+	STP           string   `json:"stp"`            // "rstp", "stp", "none"
+	Priority      int      `json:"priority"`       // STP bridge priority (default 32768 / 0x8000)
+	VlanFiltering bool     `json:"vlan_filtering"` // 802.1Q VLAN aware bridge
+	PVID          int      `json:"pvid"`           // default VLAN ID (1)
+	FastForward   bool     `json:"fast_forward"`   // bridge fastpath
+	Comment       string   `json:"comment,omitempty"`
+	Disabled      bool     `json:"disabled"`
+	Running       bool     `json:"running"` // MikroTik 'R' flag
+	PortsCount    int      `json:"ports_count"`
+	IPAddresses   []string `json:"ip_addresses,omitempty"`
+	RxPackets     uint64   `json:"rx_packets"`
+	TxPackets     uint64   `json:"tx_packets"`
+	RxBytes       uint64   `json:"rx_bytes"`
+	TxBytes       uint64   `json:"tx_bytes"`
+	RxHuman       string   `json:"rx_human,omitempty"`
+	TxHuman       string   `json:"tx_human,omitempty"`
+}
+
+// BridgePort represents a slave interface attached to a bridge (/interface bridge port)
+type BridgePort struct {
+	ID               string `json:"id"`
+	Bridge           string `json:"bridge"`            // Parent bridge name, e.g. "bridge1"
+	Interface        string `json:"interface"`         // e.g. "eth0", "Goldix", "eth0.10"
+	PVID             int    `json:"pvid"`              // Port VLAN ID (default 1)
+	FrameTypes       string `json:"frame_types"`       // "admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"
+	IngressFiltering bool   `json:"ingress_filtering"` // Drop frames with unexpected VLAN tags
+	Priority         int    `json:"priority"`          // STP port priority (default 128 / 0x80)
+	PathCost         int    `json:"path_cost"`         // STP port path cost (default 10)
+	Horizon          int    `json:"horizon"`           // Split-horizon domain (0=none, 1, 2...)
+	Learn            bool   `json:"learn"`             // MAC learning enabled (default true)
+	Comment          string `json:"comment,omitempty"`
+	Disabled         bool   `json:"disabled"`
+	Status           string `json:"status"` // "forwarding", "learning", "blocking", "disabled"
+}
+
+// BridgeVLAN represents a VLAN entry in the bridge VLAN filtering table (/interface bridge vlan)
+type BridgeVLAN struct {
+	ID              string   `json:"id"`
+	Bridge          string   `json:"bridge"`   // e.g. "bridge1"
+	VlanIDs         string   `json:"vlan_ids"` // e.g. "10", "10,20,30", "10-20"
+	Tagged          []string `json:"tagged"`   // Tagged member interfaces
+	Untagged        []string `json:"untagged"` // Untagged member interfaces
+	Comment         string   `json:"comment,omitempty"`
+	Disabled        bool     `json:"disabled"`
+	CurrentTagged   []string `json:"current_tagged,omitempty"`
+	CurrentUntagged []string `json:"current_untagged,omitempty"`
+}
+
+// BridgeHost represents a learned or static MAC address in the FDB table (/interface bridge host)
+type BridgeHost struct {
+	MAC       string  `json:"mac"`
+	Interface string  `json:"interface"` // Port learned on, or "local"
+	Bridge    string  `json:"bridge"`
+	VlanID    int     `json:"vlan_id"`
+	Age       float64 `json:"age"` // seconds
+	Local     bool    `json:"local"`
+	Static    bool    `json:"static"`
+	Flags     string  `json:"flags"` // e.g. "D" (Dynamic), "S" (Static), "L" (Local)
+}
+
+// BridgeSummary provides count totals for navigation badges
+type BridgeSummary struct {
+	BridgesCount int `json:"bridges_count"`
+	PortsCount   int `json:"ports_count"`
+	VLANsCount   int `json:"vlans_count"`
+	HostsCount   int `json:"hosts_count"`
+}

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

@@ -7,6 +7,12 @@ let state = {
   routes: [],
   activeTab: 'dashboard',
   fwSubTab: 'filter',
+  bridgeSubTab: 'bridges',
+  bridges: [],
+  bridgePorts: [],
+  bridgeVLANs: [],
+  bridgeHosts: [],
+  bridgeSummary: null,
   routesSubTab: 'routes',
   pppSubTab: 'secrets',
   eoipTunnels: [],
@@ -71,6 +77,7 @@ function navigate(tabName) {
   const titles = {
     dashboard: { title: 'Dashboard Overview', sub: 'Live router performance and network telemetry' },
     interfaces: { title: 'Network Interfaces', sub: 'Hierarchical view of physical interfaces and nested 802.1Q VLANs' },
+    bridge: { title: 'Bridge Interfaces', sub: 'MikroTik-style IEEE 802.1D / 802.1Q VLAN Filtering Bridge with RSTP & FDB' },
     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' },
@@ -100,6 +107,7 @@ function navigate(tabName) {
     }
   });
 
+  if (tabName === 'bridge') loadBridge();
   if (tabName === 'firewall') loadFirewall();
   if (tabName === 'routes') loadRoutes();
   if (tabName === 'dynamic-routing') loadDynamicRouting();
@@ -119,15 +127,28 @@ async function fetchData(isManual = false) {
   if (isManual && spinner) spinner.classList.add('animate-spin');
 
   try {
-    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes] = await Promise.all([
+    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes] = 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}/routing/dynamic/summary`)
+      fetch(`${API_BASE}/routing/dynamic/summary`),
+      fetch(`${API_BASE}/bridge/summary`)
     ]);
 
+    if (brSummaryRes.ok) {
+      state.bridgeSummary = await brSummaryRes.json();
+      const bBridge = document.getElementById('badge-bridge-count');
+      if (bBridge) bBridge.innerText = state.bridgeSummary?.bridges_count || 0;
+      const sBr = document.getElementById('bridge-stat-bridges');
+      if (sBr) sBr.innerText = state.bridgeSummary?.bridges_count || 0;
+      const sPorts = document.getElementById('bridge-stat-ports');
+      if (sPorts) sPorts.innerText = state.bridgeSummary?.ports_count || 0;
+      const sHosts = document.getElementById('bridge-stat-hosts');
+      if (sHosts) sHosts.innerText = state.bridgeSummary?.hosts_count || 0;
+    }
+
     if (statusRes.ok) state.systemStatus = await statusRes.json();
     if (ifacesRes.ok) {
       const ifacesData = await ifacesRes.json();
@@ -5737,3 +5758,850 @@ async function toggleOspfInterfaceState(id, disabled) {
     showToast('Error', err.message, 'error');
   }
 }
+
+// ============================================================================
+// ==================== MIKROTIK BRIDGE SUBSYSTEM ==============================
+// ============================================================================
+
+function switchBridgeSubTab(tab) {
+  state.bridgeSubTab = tab;
+  const tabs = ['bridges', 'ports', 'vlans', 'hosts'];
+  tabs.forEach(t => {
+    const panel = document.getElementById(`bridge-subtab-${t}`);
+    const btn = document.getElementById(`bridge-subtab-btn-${t}`);
+    if (panel) {
+      if (t === tab) panel.classList.remove('hidden');
+      else panel.classList.add('hidden');
+    }
+    if (btn) {
+      if (t === tab) {
+        btn.className = 'px-4 py-2 text-xs font-bold rounded-lg bg-slate-800 text-white shadow-sm transition flex items-center gap-2';
+      } else {
+        btn.className = 'px-4 py-2 text-xs font-bold rounded-lg text-slate-400 hover:text-slate-200 transition flex items-center gap-2';
+      }
+    }
+  });
+
+  const addBtn = document.getElementById('bridge-top-action-btn');
+  if (addBtn) {
+    if (tab === 'bridges') addBtn.innerHTML = '<span>+ Add Bridge</span>';
+    else if (tab === 'ports') addBtn.innerHTML = '<span>+ Add Port</span>';
+    else if (tab === 'vlans') addBtn.innerHTML = '<span>+ Add VLAN Entry</span>';
+    else if (tab === 'hosts') addBtn.innerHTML = '<span>🔄 Refresh MACs</span>';
+  }
+
+  if (tab === 'hosts') loadBridgeHosts();
+}
+
+function openCurrentBridgeModal() {
+  if (state.bridgeSubTab === 'bridges') openAddBridgeModal();
+  else if (state.bridgeSubTab === 'ports') openAddBridgePortModal();
+  else if (state.bridgeSubTab === 'vlans') openAddBridgeVLANModal();
+  else if (state.bridgeSubTab === 'hosts') loadBridgeHosts();
+}
+
+async function loadBridge() {
+  try {
+    const [bridgesRes, portsRes, vlansRes, summaryRes, eoipRes, vlanIfacesRes] = await Promise.all([
+      fetch(`${API_BASE}/bridge/bridges`),
+      fetch(`${API_BASE}/bridge/ports`),
+      fetch(`${API_BASE}/bridge/vlans`),
+      fetch(`${API_BASE}/bridge/summary`),
+      fetch(`${API_BASE}/vpn/eoip`),
+      fetch(`${API_BASE}/vlans`)
+    ]);
+
+    if (bridgesRes.ok) state.bridges = await bridgesRes.json();
+    if (portsRes.ok) state.bridgePorts = await portsRes.json();
+    if (vlansRes.ok) state.bridgeVLANs = await vlansRes.json();
+    if (summaryRes.ok) state.bridgeSummary = await summaryRes.json();
+    if (eoipRes && eoipRes.ok) state.eoipTunnels = await eoipRes.json();
+    if (vlanIfacesRes && vlanIfacesRes.ok) state.vlans = await vlanIfacesRes.json();
+
+    // Badges & Stats
+    const bBridges = document.getElementById('bridge-badge-bridges');
+    if (bBridges) bBridges.innerText = (state.bridges || []).length;
+    const bPorts = document.getElementById('bridge-badge-ports');
+    if (bPorts) bPorts.innerText = (state.bridgePorts || []).length;
+    const bVlans = document.getElementById('bridge-badge-vlans');
+    if (bVlans) bVlans.innerText = (state.bridgeVLANs || []).length;
+    const bHosts = document.getElementById('bridge-badge-hosts');
+    if (bHosts) bHosts.innerText = state.bridgeSummary?.hosts_count || 0;
+
+    const sBr = document.getElementById('bridge-stat-bridges');
+    if (sBr) sBr.innerText = (state.bridges || []).length;
+    const sPorts = document.getElementById('bridge-stat-ports');
+    if (sPorts) sPorts.innerText = (state.bridgePorts || []).length;
+    const sHosts = document.getElementById('bridge-stat-hosts');
+    if (sHosts) sHosts.innerText = state.bridgeSummary?.hosts_count || 0;
+
+    renderBridges();
+    renderBridgePorts();
+    renderBridgeVLANs();
+
+    if (state.bridgeSubTab === 'hosts') loadBridgeHosts();
+  } catch (err) {
+    console.error('Failed to load bridge subsystem:', err);
+  }
+}
+
+// ----------------- Bridges -----------------
+
+function renderBridges() {
+  const tbody = document.getElementById('bridge-table-body');
+  const empty = document.getElementById('bridge-empty-bridges');
+  const countBadge = document.getElementById('bridge-count-badge');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const bridges = state.bridges || [];
+  if (countBadge) countBadge.innerText = `${bridges.length} Bridges`;
+
+  if (bridges.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  bridges.forEach((b, idx) => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${b.disabled ? 'opacity-50' : ''}`;
+
+    // Format IP addresses pill list
+    let ipsHtml = '<span class="text-slate-600 font-sans">None</span>';
+    if (b.ip_addresses && b.ip_addresses.length > 0) {
+      ipsHtml = b.ip_addresses.map(ip => `
+        <span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-mono bg-sky-950/80 border border-sky-800/40 text-sky-300">
+          <span>${ip}</span>
+          <button onclick="deleteBridgeIP('${b.id}', '${ip}')" class="text-slate-500 hover:text-rose-400 font-bold ml-0.5">×</button>
+        </span>
+      `).join(' ');
+    }
+
+    const runningFlag = b.running ? '<span class="text-emerald-400 font-bold" title="Running (R)">R</span>' : '<span class="text-slate-600">-</span>';
+    const disabledFlag = b.disabled ? '<span class="text-rose-400 font-bold" title="Disabled (X)">X</span>' : '';
+    const flagDisplay = `${runningFlag}${disabledFlag ? ' ' + disabledFlag : ''}`;
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${idx + 1}</td>
+      <td class="py-2.5 px-3 text-center font-mono">${flagDisplay}</td>
+      <td class="py-2.5 px-3 font-bold text-white text-sm">
+        <span class="text-sky-400 mr-1.5">🌉</span>${b.name}
+      </td>
+      <td class="py-2.5 px-3 text-slate-300">${b.mtu}</td>
+      <td class="py-2.5 px-3 font-mono text-slate-300 text-[11px]">${b.mac_address || '-'}</td>
+      <td class="py-2.5 px-3">
+        <span class="px-1.5 py-0.5 rounded text-[10px] font-bold ${b.stp === 'none' ? 'bg-slate-800 text-slate-400' : 'bg-sky-950 text-sky-300 border border-sky-800/40'} uppercase">${b.stp}</span>
+      </td>
+      <td class="py-2.5 px-3 text-center">
+        ${b.vlan_filtering ? '<span class="text-emerald-400 font-bold">yes</span>' : '<span class="text-slate-500">no</span>'}
+      </td>
+      <td class="py-2.5 px-3 font-mono">${b.pvid || 1}</td>
+      <td class="py-2.5 px-3">
+        <span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${b.ports_count > 0 ? 'bg-sky-950 text-sky-400 border border-sky-800/40' : 'bg-slate-800 text-slate-500'} font-mono">${b.ports_count}</span>
+      </td>
+      <td class="py-2.5 px-3">
+        <div class="flex items-center gap-1.5 flex-wrap">
+          ${ipsHtml}
+          <button onclick="openAddBridgeIPModal('${b.id}', '${b.name}')" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 font-sans" title="Add IP to Bridge">+ IP</button>
+        </div>
+      </td>
+      <td class="py-2.5 px-3 text-[11px] text-slate-400">
+        <span class="text-emerald-400">↓ ${b.rx_human || '0 B'}</span> / <span class="text-cyan-400">↑ ${b.tx_human || '0 B'}</span>
+      </td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${b.comment || ''}">${b.comment || '-'}</td>
+      <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
+        <button onclick="toggleBridgeState('${b.id}', ${!b.disabled})" class="text-xs ${b.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline font-semibold">${b.disabled ? 'Enable' : 'Disable'}</button>
+        <button onclick="openEditBridgeModal('${b.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+        <button onclick="deleteBridge('${b.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddBridgeModal() {
+  document.getElementById('bridge-modal-title').innerText = 'Add Bridge Interface';
+  document.getElementById('bridge-modal-id').value = '';
+  document.getElementById('bridge-modal-name').value = 'bridge1';
+  document.getElementById('bridge-modal-name').disabled = false;
+  document.getElementById('bridge-modal-mtu').value = '1500';
+  document.getElementById('bridge-modal-mac').value = '';
+  document.getElementById('bridge-modal-stp').value = 'rstp';
+  document.getElementById('bridge-modal-priority').value = '32768';
+  document.getElementById('bridge-modal-vlanfiltering').checked = false;
+  document.getElementById('bridge-modal-pvid').value = '1';
+  document.getElementById('bridge-modal-fastforward').checked = true;
+  document.getElementById('bridge-modal-comment').value = '';
+  document.getElementById('bridge-modal-disabled').checked = false;
+  toggleBridgeModalVlanFiltering();
+  document.getElementById('bridge-modal').classList.remove('hidden');
+}
+
+function openEditBridgeModal(id) {
+  const b = (state.bridges || []).find(x => x.id === id);
+  if (!b) return;
+
+  document.getElementById('bridge-modal-title').innerText = `Edit Bridge (${b.name})`;
+  document.getElementById('bridge-modal-id').value = b.id;
+  document.getElementById('bridge-modal-name').value = b.name;
+  document.getElementById('bridge-modal-name').disabled = true;
+  document.getElementById('bridge-modal-mtu').value = b.mtu || 1500;
+  document.getElementById('bridge-modal-mac').value = b.mac_address || '';
+  document.getElementById('bridge-modal-stp').value = b.stp || 'rstp';
+  document.getElementById('bridge-modal-priority').value = b.priority || 32768;
+  document.getElementById('bridge-modal-vlanfiltering').checked = !!b.vlan_filtering;
+  document.getElementById('bridge-modal-pvid').value = b.pvid || 1;
+  document.getElementById('bridge-modal-fastforward').checked = b.fast_forward !== false;
+  document.getElementById('bridge-modal-comment').value = b.comment || '';
+  document.getElementById('bridge-modal-disabled').checked = !!b.disabled;
+  toggleBridgeModalVlanFiltering();
+  document.getElementById('bridge-modal').classList.remove('hidden');
+}
+
+function closeBridgeModal() {
+  document.getElementById('bridge-modal').classList.add('hidden');
+}
+
+function toggleBridgeModalVlanFiltering() {
+  const chk = document.getElementById('bridge-modal-vlanfiltering').checked;
+  const pvidBox = document.getElementById('bridge-modal-pvid-box');
+  if (pvidBox) {
+    if (chk) pvidBox.classList.remove('opacity-40');
+    else pvidBox.classList.add('opacity-40');
+  }
+}
+
+async function submitSaveBridge(e) {
+  e.preventDefault();
+  const id = document.getElementById('bridge-modal-id').value;
+  const name = document.getElementById('bridge-modal-name').value.trim();
+  const mtu = parseInt(document.getElementById('bridge-modal-mtu').value, 10) || 1500;
+  const mac = document.getElementById('bridge-modal-mac').value.trim();
+  const stp = document.getElementById('bridge-modal-stp').value;
+  const priority = parseInt(document.getElementById('bridge-modal-priority').value, 10) || 32768;
+  const vlanFiltering = document.getElementById('bridge-modal-vlanfiltering').checked;
+  const pvid = parseInt(document.getElementById('bridge-modal-pvid').value, 10) || 1;
+  const fastForward = document.getElementById('bridge-modal-fastforward').checked;
+  const comment = document.getElementById('bridge-modal-comment').value.trim();
+  const disabled = document.getElementById('bridge-modal-disabled').checked;
+
+  try {
+    const res = await fetch(`${API_BASE}/bridge/bridges`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        id,
+        name,
+        mtu,
+        mac_address: mac,
+        stp,
+        priority,
+        vlan_filtering: vlanFiltering,
+        pvid,
+        fast_forward: fastForward,
+        comment,
+        disabled
+      })
+    });
+    if (res.ok) {
+      showToast('Bridge Saved', `Bridge ${name} configured`, 'success');
+      closeBridgeModal();
+      loadBridge();
+      fetchData(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || err.message || 'Failed to save bridge', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleBridgeState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) {
+      loadBridge();
+      fetchData(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteBridge(id) {
+  const b = (state.bridges || []).find(x => x.id === id);
+  const name = b ? b.name : id;
+  if (!confirm(`Are you sure you want to delete bridge '${name}'? All member ports will be detached.`)) return;
+
+  try {
+    const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', `Bridge ${name} deleted`, 'success');
+      loadBridge();
+      fetchData(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || err.message || 'Failed to delete bridge', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Bridge IP Assignments -----------------
+
+function openAddBridgeIPModal(id, name) {
+  document.getElementById('bridge-ip-modal-id').value = id;
+  document.getElementById('bridge-ip-modal-sub').innerText = name;
+  document.getElementById('bridge-ip-modal-cidr').value = '';
+  document.getElementById('bridge-ip-modal').classList.remove('hidden');
+}
+
+function closeBridgeIPModal() {
+  document.getElementById('bridge-ip-modal').classList.add('hidden');
+}
+
+async function submitAddBridgeIP(e) {
+  e.preventDefault();
+  const id = document.getElementById('bridge-ip-modal-id').value;
+  const ip = document.getElementById('bridge-ip-modal-cidr').value.trim();
+
+  try {
+    const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/ip`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ ip })
+    });
+    if (res.ok) {
+      showToast('IP Assigned', `${ip} assigned to bridge`, 'success');
+      closeBridgeIPModal();
+      loadBridge();
+      fetchData(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || err.message || 'Failed to add IP', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteBridgeIP(id, ip) {
+  if (!confirm(`Remove IP address ${ip} from bridge?`)) return;
+  try {
+    const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/ip`, {
+      method: 'DELETE',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ ip })
+    });
+    if (res.ok) {
+      showToast('IP Removed', `${ip} removed from bridge`, 'success');
+      loadBridge();
+      fetchData(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Bridge Ports -----------------
+
+function renderBridgePorts() {
+  const tbody = document.getElementById('bridge-ports-table-body');
+  const empty = document.getElementById('bridge-empty-ports');
+  const countBadge = document.getElementById('bridge-ports-count-badge');
+  const filterBridge = document.getElementById('bridge-ports-filter-bridge')?.value || '';
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  // Update filter select
+  populateBridgeSelect('bridge-ports-filter-bridge', filterBridge, true);
+
+  const ports = (state.bridgePorts || []).filter(p => !filterBridge || p.bridge === filterBridge);
+  if (countBadge) countBadge.innerText = `${ports.length} Ports`;
+
+  if (ports.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  ports.forEach((p, idx) => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${p.disabled ? 'opacity-50' : ''}`;
+
+    const disabledFlag = p.disabled ? '<span class="text-rose-400 font-bold" title="Disabled (X)">X</span>' : '<span class="text-slate-600">-</span>';
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${idx + 1}</td>
+      <td class="py-2.5 px-3 text-center font-mono">${disabledFlag}</td>
+      <td class="py-2.5 px-3 font-bold text-white text-sm">
+        <span class="text-emerald-400 mr-1.5">🔌</span>${p.interface}
+      </td>
+      <td class="py-2.5 px-3 font-semibold text-sky-400">${p.bridge}</td>
+      <td class="py-2.5 px-3 font-mono">${p.pvid || 1}</td>
+      <td class="py-2.5 px-3 text-slate-300">${p.frame_types}</td>
+      <td class="py-2.5 px-3 text-center">${p.ingress_filtering ? '<span class="text-amber-400 font-bold">yes</span>' : '<span class="text-slate-500">no</span>'}</td>
+      <td class="py-2.5 px-3">
+        ${p.horizon > 0 ? `<span class="px-1.5 py-0.5 rounded text-[10px] bg-purple-950 text-purple-300 border border-purple-800/40 font-mono">domain ${p.horizon}</span>` : '<span class="text-slate-500">none</span>'}
+      </td>
+      <td class="py-2.5 px-3">
+        <span class="px-2 py-0.5 rounded text-[10px] font-bold ${p.status === 'forwarding' ? 'bg-emerald-950 text-emerald-400 border border-emerald-800/40' : 'bg-slate-800 text-slate-400'} uppercase font-mono">${p.status}</span>
+      </td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${p.comment || ''}">${p.comment || '-'}</td>
+      <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
+        <button onclick="toggleBridgePortState('${p.id}', ${!p.disabled})" class="text-xs ${p.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline font-semibold">${p.disabled ? 'Enable' : 'Disable'}</button>
+        <button onclick="openEditBridgePortModal('${p.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+        <button onclick="deleteBridgePort('${p.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddBridgePortModal() {
+  document.getElementById('bridge-port-modal-title').innerText = 'Add Port to Bridge';
+  document.getElementById('bridge-port-modal-id').value = '';
+  populateBridgeSelect('bridge-port-modal-bridge');
+  populateBridgePortCandidateSelect('bridge-port-modal-interface');
+  document.getElementById('bridge-port-modal-pvid').value = '1';
+  document.getElementById('bridge-port-modal-frametypes').value = 'admit-all';
+  document.getElementById('bridge-port-modal-ingressfiltering').checked = false;
+  document.getElementById('bridge-port-modal-horizon').value = '0';
+  document.getElementById('bridge-port-modal-pathcost').value = '10';
+  document.getElementById('bridge-port-modal-priority').value = '128';
+  document.getElementById('bridge-port-modal-learn').checked = true;
+  document.getElementById('bridge-port-modal-comment').value = '';
+  document.getElementById('bridge-port-modal-disabled').checked = false;
+  document.getElementById('bridge-port-modal').classList.remove('hidden');
+}
+
+function openEditBridgePortModal(id) {
+  const p = (state.bridgePorts || []).find(x => x.id === id);
+  if (!p) return;
+
+  document.getElementById('bridge-port-modal-title').innerText = `Edit Bridge Port (${p.interface})`;
+  document.getElementById('bridge-port-modal-id').value = p.id;
+  populateBridgeSelect('bridge-port-modal-bridge', p.bridge);
+  populateBridgePortCandidateSelect('bridge-port-modal-interface', p.interface);
+  document.getElementById('bridge-port-modal-pvid').value = p.pvid || 1;
+  document.getElementById('bridge-port-modal-frametypes').value = p.frame_types || 'admit-all';
+  document.getElementById('bridge-port-modal-ingressfiltering').checked = !!p.ingress_filtering;
+  document.getElementById('bridge-port-modal-horizon').value = p.horizon || 0;
+  document.getElementById('bridge-port-modal-pathcost').value = p.path_cost || 10;
+  document.getElementById('bridge-port-modal-priority').value = p.priority || 128;
+  document.getElementById('bridge-port-modal-learn').checked = p.learn !== false;
+  document.getElementById('bridge-port-modal-comment').value = p.comment || '';
+  document.getElementById('bridge-port-modal-disabled').checked = !!p.disabled;
+  document.getElementById('bridge-port-modal').classList.remove('hidden');
+}
+
+function closeBridgePortModal() {
+  document.getElementById('bridge-port-modal').classList.add('hidden');
+}
+
+async function submitSaveBridgePort(e) {
+  e.preventDefault();
+  const id = document.getElementById('bridge-port-modal-id').value;
+  const bridge = document.getElementById('bridge-port-modal-bridge').value;
+  const iface = document.getElementById('bridge-port-modal-interface').value;
+  const pvid = parseInt(document.getElementById('bridge-port-modal-pvid').value, 10) || 1;
+  const frameTypes = document.getElementById('bridge-port-modal-frametypes').value;
+  const ingressFiltering = document.getElementById('bridge-port-modal-ingressfiltering').checked;
+  const horizon = parseInt(document.getElementById('bridge-port-modal-horizon').value, 10) || 0;
+  const pathCost = parseInt(document.getElementById('bridge-port-modal-pathcost').value, 10) || 10;
+  const priority = parseInt(document.getElementById('bridge-port-modal-priority').value, 10) || 128;
+  const learn = document.getElementById('bridge-port-modal-learn').checked;
+  const comment = document.getElementById('bridge-port-modal-comment').value.trim();
+  const disabled = document.getElementById('bridge-port-modal-disabled').checked;
+
+  try {
+    const res = await fetch(`${API_BASE}/bridge/ports`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        id,
+        bridge,
+        interface: iface,
+        pvid,
+        frame_types: frameTypes,
+        ingress_filtering: ingressFiltering,
+        horizon,
+        path_cost: pathCost,
+        priority,
+        learn,
+        comment,
+        disabled
+      })
+    });
+    if (res.ok) {
+      showToast('Port Attached', `${iface} added to ${bridge}`, 'success');
+      closeBridgePortModal();
+      loadBridge();
+      fetchData(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || err.message || 'Failed to save port', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleBridgePortState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/bridge/ports/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) {
+      loadBridge();
+      fetchData(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteBridgePort(id) {
+  const p = (state.bridgePorts || []).find(x => x.id === id);
+  const iface = p ? p.interface : id;
+  if (!confirm(`Detach interface '${iface}' from bridge?`)) return;
+
+  try {
+    const res = await fetch(`${API_BASE}/bridge/ports/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Port Detached', `${iface} removed from bridge`, 'success');
+      loadBridge();
+      fetchData(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Bridge VLANs -----------------
+
+function renderBridgeVLANs() {
+  const tbody = document.getElementById('bridge-vlans-table-body');
+  const empty = document.getElementById('bridge-empty-vlans');
+  const countBadge = document.getElementById('bridge-vlans-count-badge');
+  const filterBridge = document.getElementById('bridge-vlans-filter-bridge')?.value || '';
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  populateBridgeSelect('bridge-vlans-filter-bridge', filterBridge, true);
+
+  const vlans = (state.bridgeVLANs || []).filter(v => !filterBridge || v.bridge === filterBridge);
+  if (countBadge) countBadge.innerText = `${vlans.length} Entries`;
+
+  if (vlans.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  vlans.forEach((v, idx) => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${v.disabled ? 'opacity-50' : ''}`;
+
+    const taggedHtml = (v.tagged && v.tagged.length > 0)
+      ? v.tagged.map(p => `<span class="px-1.5 py-0.5 rounded text-[10px] bg-cyan-950/80 border border-cyan-800/40 text-cyan-300 font-mono">${p}</span>`).join(' ')
+      : '<span class="text-slate-600">none</span>';
+
+    const untaggedHtml = (v.untagged && v.untagged.length > 0)
+      ? v.untagged.map(p => `<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 border border-slate-700/60 text-slate-300 font-mono">${p}</span>`).join(' ')
+      : '<span class="text-slate-600">none</span>';
+
+    const disabledFlag = v.disabled ? '<span class="text-rose-400 font-bold" title="Disabled (X)">X</span>' : '<span class="text-slate-600">-</span>';
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${idx + 1}</td>
+      <td class="py-2.5 px-3 text-center font-mono">${disabledFlag}</td>
+      <td class="py-2.5 px-3 font-semibold text-sky-400">${v.bridge}</td>
+      <td class="py-2.5 px-3 font-bold text-white font-mono text-sm">${v.vlan_ids}</td>
+      <td class="py-2.5 px-3"><div class="flex items-center gap-1.5 flex-wrap">${taggedHtml}</div></td>
+      <td class="py-2.5 px-3"><div class="flex items-center gap-1.5 flex-wrap">${untaggedHtml}</div></td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${v.comment || ''}">${v.comment || '-'}</td>
+      <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
+        <button onclick="toggleBridgeVLANState('${v.id}', ${!v.disabled})" class="text-xs ${v.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline font-semibold">${v.disabled ? 'Enable' : 'Disable'}</button>
+        <button onclick="openEditBridgeVLANModal('${v.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+        <button onclick="deleteBridgeVLAN('${v.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddBridgeVLANModal() {
+  document.getElementById('bridge-vlan-modal-title').innerText = 'Add Bridge VLAN Entry';
+  document.getElementById('bridge-vlan-modal-id').value = '';
+  populateBridgeSelect('bridge-vlan-modal-bridge');
+  document.getElementById('bridge-vlan-modal-vlanids').value = '';
+  document.getElementById('bridge-vlan-modal-comment').value = '';
+  document.getElementById('bridge-vlan-modal-disabled').checked = false;
+  updateBridgeVlanPortCheckboxes();
+  document.getElementById('bridge-vlan-modal').classList.remove('hidden');
+}
+
+function openEditBridgeVLANModal(id) {
+  const v = (state.bridgeVLANs || []).find(x => x.id === id);
+  if (!v) return;
+
+  document.getElementById('bridge-vlan-modal-title').innerText = `Edit Bridge VLAN (${v.vlan_ids})`;
+  document.getElementById('bridge-vlan-modal-id').value = v.id;
+  populateBridgeSelect('bridge-vlan-modal-bridge', v.bridge);
+  document.getElementById('bridge-vlan-modal-vlanids').value = v.vlan_ids;
+  document.getElementById('bridge-vlan-modal-comment').value = v.comment || '';
+  document.getElementById('bridge-vlan-modal-disabled').checked = !!v.disabled;
+  updateBridgeVlanPortCheckboxes(v.tagged || [], v.untagged || []);
+  document.getElementById('bridge-vlan-modal').classList.remove('hidden');
+}
+
+function closeBridgeVlanModal() {
+  document.getElementById('bridge-vlan-modal').classList.add('hidden');
+}
+
+function updateBridgeVlanPortCheckboxes(taggedSelected = [], untaggedSelected = []) {
+  const selBridge = document.getElementById('bridge-vlan-modal-bridge')?.value || '';
+  const taggedBox = document.getElementById('bridge-vlan-tagged-ports');
+  const untaggedBox = document.getElementById('bridge-vlan-untagged-ports');
+  if (!taggedBox || !untaggedBox) return;
+
+  // Ports belonging to the selected bridge
+  const memberPorts = (state.bridgePorts || []).filter(p => !selBridge || p.bridge === selBridge);
+
+  if (memberPorts.length === 0) {
+    taggedBox.innerHTML = '<span class="text-slate-500 text-xs font-sans">No member ports found in this bridge</span>';
+    untaggedBox.innerHTML = '<span class="text-slate-500 text-xs font-sans">No member ports found in this bridge</span>';
+    return;
+  }
+
+  taggedBox.innerHTML = memberPorts.map(p => `
+    <label class="flex items-center space-x-2 text-xs cursor-pointer hover:bg-slate-900/60 p-1 rounded">
+      <input type="checkbox" name="bvlan-tagged" value="${p.interface}" ${taggedSelected.includes(p.interface) ? 'checked' : ''} class="rounded bg-slate-800 border-slate-700 text-cyan-500">
+      <span>${p.interface}</span>
+    </label>
+  `).join('');
+
+  untaggedBox.innerHTML = memberPorts.map(p => `
+    <label class="flex items-center space-x-2 text-xs cursor-pointer hover:bg-slate-900/60 p-1 rounded">
+      <input type="checkbox" name="bvlan-untagged" value="${p.interface}" ${untaggedSelected.includes(p.interface) ? 'checked' : ''} class="rounded bg-slate-800 border-slate-700 text-slate-400">
+      <span>${p.interface}</span>
+    </label>
+  `).join('');
+}
+
+async function submitSaveBridgeVLAN(e) {
+  e.preventDefault();
+  const id = document.getElementById('bridge-vlan-modal-id').value;
+  const bridge = document.getElementById('bridge-vlan-modal-bridge').value;
+  const vlanIDs = document.getElementById('bridge-vlan-modal-vlanids').value.trim();
+  const comment = document.getElementById('bridge-vlan-modal-comment').value.trim();
+  const disabled = document.getElementById('bridge-vlan-modal-disabled').checked;
+
+  const tagged = Array.from(document.querySelectorAll('input[name="bvlan-tagged"]:checked')).map(x => x.value);
+  const untagged = Array.from(document.querySelectorAll('input[name="bvlan-untagged"]:checked')).map(x => x.value);
+
+  try {
+    const res = await fetch(`${API_BASE}/bridge/vlans`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        id,
+        bridge,
+        vlan_ids: vlanIDs,
+        tagged,
+        untagged,
+        comment,
+        disabled
+      })
+    });
+    if (res.ok) {
+      showToast('VLAN Entry Saved', `VLAN ${vlanIDs} configured on ${bridge}`, 'success');
+      closeBridgeVlanModal();
+      loadBridge();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || err.message || 'Failed to save VLAN entry', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleBridgeVLANState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/bridge/vlans/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) loadBridge();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteBridgeVLAN(id) {
+  if (!confirm('Delete this bridge VLAN entry?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/bridge/vlans/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'VLAN entry removed from bridge', 'success');
+      loadBridge();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Hosts / MAC Table -----------------
+
+async function loadBridgeHosts() {
+  const filterBridge = document.getElementById('bridge-hosts-filter-bridge')?.value || '';
+  populateBridgeSelect('bridge-hosts-filter-bridge', filterBridge, true);
+
+  try {
+    const url = filterBridge ? `${API_BASE}/bridge/hosts?bridge=${encodeURIComponent(filterBridge)}` : `${API_BASE}/bridge/hosts`;
+    const res = await fetch(url);
+    if (res.ok) {
+      state.bridgeHosts = await res.json();
+      renderBridgeHosts();
+    }
+  } catch (err) {
+    console.error('Failed to load bridge hosts:', err);
+  }
+}
+
+function renderBridgeHosts() {
+  const tbody = document.getElementById('bridge-hosts-table-body');
+  const empty = document.getElementById('bridge-empty-hosts');
+  const countBadge = document.getElementById('bridge-hosts-count-badge');
+  const search = (document.getElementById('bridge-hosts-search')?.value || '').toLowerCase().trim();
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const hosts = (state.bridgeHosts || []).filter(h => {
+    if (!search) return true;
+    return (h.mac || '').toLowerCase().includes(search) || (h.interface || '').toLowerCase().includes(search) || (h.bridge || '').toLowerCase().includes(search);
+  });
+
+  if (countBadge) countBadge.innerText = `${hosts.length} Hosts`;
+
+  if (hosts.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  hosts.forEach(h => {
+    const tr = document.createElement('tr');
+    tr.className = 'hover:bg-slate-800/40 transition text-xs';
+
+    let flagBadges = '';
+    if (h.local) flagBadges += '<span class="px-1.5 py-0.2 rounded text-[10px] bg-sky-950 text-sky-400 border border-sky-800/40 font-mono font-bold mr-1" title="Local router interface MAC">L</span>';
+    if (h.static) flagBadges += '<span class="px-1.5 py-0.2 rounded text-[10px] bg-purple-950 text-purple-400 border border-purple-800/40 font-mono font-bold mr-1" title="Static entry">S</span>';
+    if (!h.local && !h.static) flagBadges += '<span class="px-1.5 py-0.2 rounded text-[10px] bg-emerald-950 text-emerald-400 border border-emerald-800/40 font-mono font-bold mr-1" title="Dynamically learned">D</span>';
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 font-mono font-bold text-white text-sm">${h.mac}</td>
+      <td class="py-2.5 px-3 font-semibold text-emerald-400 font-mono">${h.interface || '-'}</td>
+      <td class="py-2.5 px-3 font-mono text-sky-400">${h.bridge || '-'}</td>
+      <td class="py-2.5 px-3 font-mono">${h.vlan_id || 1}</td>
+      <td class="py-2.5 px-3 font-mono text-slate-400">${h.age ? h.age.toFixed(1) + 's' : '-'}</td>
+      <td class="py-2.5 px-3">${flagBadges}</td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+async function flushBridgeHosts() {
+  const selBridge = document.getElementById('bridge-hosts-filter-bridge')?.value || (state.bridges[0] ? state.bridges[0].name : '');
+  if (!selBridge) {
+    showToast('Notice', 'Please select a bridge to flush dynamic MACs', 'info');
+    return;
+  }
+  if (!confirm(`Flush all dynamic learned MAC addresses on bridge '${selBridge}'?`)) return;
+
+  try {
+    const res = await fetch(`${API_BASE}/bridge/hosts/flush`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ bridge: selBridge })
+    });
+    if (res.ok) {
+      showToast('FDB Flushed', `Dynamic MACs flushed on ${selBridge}`, 'success');
+      loadBridgeHosts();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Helpers -----------------
+
+function populateBridgeSelect(selectId, currentVal = '', includeAll = false) {
+  const sel = document.getElementById(selectId);
+  if (!sel) return;
+  const prevVal = currentVal || sel.value;
+  sel.innerHTML = includeAll ? '<option value="">All Bridges</option>' : '';
+
+  (state.bridges || []).forEach(b => {
+    const opt = document.createElement('option');
+    opt.value = b.name;
+    opt.innerText = `${b.name} (${b.ports_count} ports)`;
+    if (b.name === prevVal) opt.selected = true;
+    sel.appendChild(opt);
+  });
+}
+
+function populateBridgePortCandidateSelect(selectId, currentVal = '') {
+  const sel = document.getElementById(selectId);
+  if (!sel) return;
+  sel.innerHTML = '<option value="">(Select Interface...)</option>';
+
+  // 1. Physical Interfaces
+  const grpPhys = document.createElement('optgroup');
+  grpPhys.label = 'Physical Interfaces';
+  (state.interfaces || []).filter(i => i.type !== 'bridge' && i.type !== 'vlan' && i.type !== 'loopback').forEach(i => {
+    const opt = document.createElement('option');
+    opt.value = i.name;
+    opt.innerText = `${i.name} (${i.role || i.type})`;
+    if (i.name === currentVal) opt.selected = true;
+    grpPhys.appendChild(opt);
+  });
+  if (grpPhys.children.length > 0) sel.appendChild(grpPhys);
+
+  // 2. VLAN Sub-interfaces
+  const grpVlans = document.createElement('optgroup');
+  grpVlans.label = 'VLAN Sub-interfaces (802.1Q)';
+  (state.vlans || []).forEach(v => {
+    const opt = document.createElement('option');
+    opt.value = v.name;
+    opt.innerText = `${v.name} (VLAN ${v.vlan_id})`;
+    if (v.name === currentVal) opt.selected = true;
+    grpVlans.appendChild(opt);
+  });
+  if (grpVlans.children.length > 0) sel.appendChild(grpVlans);
+
+  // 3. EoIP Tunnels (MikroTik EoIP bridging!)
+  const grpEoip = document.createElement('optgroup');
+  grpEoip.label = 'EoIP Tunnels (Layer 2 VPN)';
+  (state.eoipTunnels || []).forEach(t => {
+    const opt = document.createElement('option');
+    opt.value = t.name;
+    opt.innerText = `${t.name} (Tunnel ID: ${t.tunnel_id} -> ${t.remote_address})`;
+    if (t.name === currentVal) opt.selected = true;
+    grpEoip.appendChild(opt);
+  });
+  if (grpEoip.children.length > 0) sel.appendChild(grpEoip);
+}
+

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

@@ -102,6 +102,17 @@
           <span id="badge-ifaces-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="#bridge" onclick="navigate('bridge')" id="nav-bridge"
+           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-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M3 14h18M5 14v4a2 2 0 002 2h2a2 2 0 002-2v-4m6 0v4a2 2 0 002 2h2a2 2 0 002-2v-4M4 10V6a2 2 0 012-2h12a2 2 0 012 2v4" />
+            </svg>
+            <span>Bridge</span>
+          </div>
+          <span id="badge-bridge-count" class="text-[10px] bg-sky-950 text-sky-300 border border-sky-800/40 px-1.5 py-0.2 rounded-full font-mono">0</span>
+        </a>
+
         <a href="#vlans" onclick="navigate('vlans')" id="nav-vlans"
            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">
@@ -505,6 +516,214 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: BRIDGE (/interface bridge) ================= -->
+        <section id="section-bridge" class="hidden space-y-6">
+          <!-- Bridge Navigation Header -->
+          <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-4 flex flex-wrap items-center justify-between gap-4">
+            <div class="flex items-center space-x-3">
+              <div class="p-2.5 rounded-lg bg-sky-500/10 border border-sky-500/20 text-sky-400">
+                <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M3 14h18M5 14v4a2 2 0 002 2h2a2 2 0 002-2v-4m6 0v4a2 2 0 002 2h2a2 2 0 002-2v-4M4 10V6a2 2 0 012-2h12a2 2 0 012 2v4" />
+                </svg>
+              </div>
+              <div>
+                <div class="flex items-center gap-2">
+                  <h2 class="text-base font-bold text-white">Bridge Interfaces</h2>
+                  <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-sky-500/20 text-sky-300 border border-sky-500/30 font-semibold">IEEE 802.1D / 802.1Q</span>
+                </div>
+                <p class="text-xs text-slate-400">MikroTik RouterOS style bridge with RSTP, VLAN filtering, split-horizon port isolation & dynamic host MAC forwarding table.</p>
+              </div>
+            </div>
+
+            <div class="flex items-center space-x-3">
+              <button id="bridge-top-action-btn" onclick="openCurrentBridgeModal()" class="flex items-center space-x-2 bg-gradient-to-r from-sky-600 to-cyan-600 hover:from-sky-500 hover:to-cyan-500 text-white font-semibold text-xs px-4 py-2.5 rounded-lg shadow-lg shadow-sky-500/20 transition">
+                <span>+ Add Bridge</span>
+              </button>
+            </div>
+          </div>
+
+          <!-- Bridge Subtabs Nav -->
+          <div class="bg-slate-900/60 border border-slate-800/80 rounded-xl overflow-hidden p-1 flex flex-wrap items-center justify-between gap-2">
+            <div class="flex items-center space-x-1">
+              <button id="bridge-subtab-btn-bridges" onclick="switchBridgeSubTab('bridges')" class="px-4 py-2 text-xs font-bold rounded-lg bg-slate-800 text-white shadow-sm transition flex items-center gap-2">
+                <span>Bridges</span>
+                <span id="bridge-badge-bridges" class="px-1.5 py-0.2 rounded text-[10px] bg-sky-950 text-sky-300 border border-sky-800/40">0</span>
+              </button>
+              <button id="bridge-subtab-btn-ports" onclick="switchBridgeSubTab('ports')" class="px-4 py-2 text-xs font-bold rounded-lg text-slate-400 hover:text-slate-200 transition flex items-center gap-2">
+                <span>Ports</span>
+                <span id="bridge-badge-ports" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+              </button>
+              <button id="bridge-subtab-btn-vlans" onclick="switchBridgeSubTab('vlans')" class="px-4 py-2 text-xs font-bold rounded-lg text-slate-400 hover:text-slate-200 transition flex items-center gap-2">
+                <span>VLANs</span>
+                <span id="bridge-badge-vlans" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+              </button>
+              <button id="bridge-subtab-btn-hosts" onclick="switchBridgeSubTab('hosts')" class="px-4 py-2 text-xs font-bold rounded-lg text-slate-400 hover:text-slate-200 transition flex items-center gap-2">
+                <span>Hosts (MAC Table)</span>
+                <span id="bridge-badge-hosts" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+              </button>
+            </div>
+
+            <!-- Stats Bar -->
+            <div class="flex items-center space-x-4 px-3 text-xs font-mono text-slate-400">
+              <div>Bridges: <span id="bridge-stat-bridges" class="font-bold text-white">0</span></div>
+              <div>Ports: <span id="bridge-stat-ports" class="font-bold text-sky-400">0</span></div>
+              <div>Learned MACs: <span id="bridge-stat-hosts" class="font-bold text-emerald-400">0</span></div>
+            </div>
+          </div>
+
+          <!-- ================= SUBTAB 1: BRIDGES ================= -->
+          <div id="bridge-subtab-bridges" 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">
+              <h3 class="text-sm font-bold text-white">Configured Bridges</h3>
+              <span id="bridge-count-badge" class="text-xs font-mono text-slate-400">0 Bridges</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 w-10 text-center">#</th>
+                    <th class="py-2.5 px-3 w-12 text-center" title="Flags: R=Running, X=Disabled">Flag</th>
+                    <th class="py-2.5 px-3">Name</th>
+                    <th class="py-2.5 px-3">MTU</th>
+                    <th class="py-2.5 px-3">MAC Address</th>
+                    <th class="py-2.5 px-3">STP</th>
+                    <th class="py-2.5 px-3">VLAN Filtering</th>
+                    <th class="py-2.5 px-3">PVID</th>
+                    <th class="py-2.5 px-3">Ports</th>
+                    <th class="py-2.5 px-3">IP Addresses</th>
+                    <th class="py-2.5 px-3">Traffic</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="bridge-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="bridge-empty-bridges" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No bridge interfaces configured. Click "+ Add Bridge" to create one.
+            </div>
+          </div>
+
+          <!-- ================= SUBTAB 2: PORTS ================= -->
+          <div id="bridge-subtab-ports" 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">Bridge Ports</h3>
+                <span id="bridge-ports-count-badge" class="text-xs font-mono text-slate-400">0 Ports</span>
+              </div>
+              <div class="flex items-center space-x-2">
+                <label class="text-xs text-slate-400">Filter Bridge:</label>
+                <select id="bridge-ports-filter-bridge" onchange="renderBridgePorts()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white">
+                  <option value="">All Bridges</option>
+                </select>
+              </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-10 text-center">#</th>
+                    <th class="py-2.5 px-3 w-12 text-center" title="Flags: X=Disabled">Flag</th>
+                    <th class="py-2.5 px-3">Interface</th>
+                    <th class="py-2.5 px-3">Bridge</th>
+                    <th class="py-2.5 px-3">PVID</th>
+                    <th class="py-2.5 px-3">Frame Types</th>
+                    <th class="py-2.5 px-3">Ingress Filter</th>
+                    <th class="py-2.5 px-3">Horizon</th>
+                    <th class="py-2.5 px-3">Role / 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="bridge-ports-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="bridge-empty-ports" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No bridge ports configured. Click "+ Add Port" to attach a physical NIC, VLAN or EoIP tunnel.
+            </div>
+          </div>
+
+          <!-- ================= SUBTAB 3: VLANS ================= -->
+          <div id="bridge-subtab-vlans" 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">Bridge VLAN Filtering Table</h3>
+                <span id="bridge-vlans-count-badge" class="text-xs font-mono text-slate-400">0 Entries</span>
+              </div>
+              <div class="flex items-center space-x-2">
+                <label class="text-xs text-slate-400">Filter Bridge:</label>
+                <select id="bridge-vlans-filter-bridge" onchange="renderBridgeVLANs()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white">
+                  <option value="">All Bridges</option>
+                </select>
+              </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-10 text-center">#</th>
+                    <th class="py-2.5 px-3 w-12 text-center" title="Flags: X=Disabled">Flag</th>
+                    <th class="py-2.5 px-3">Bridge</th>
+                    <th class="py-2.5 px-3">VLAN IDs</th>
+                    <th class="py-2.5 px-3">Tagged Member Ports</th>
+                    <th class="py-2.5 px-3">Untagged Member Ports</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="bridge-vlans-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="bridge-empty-vlans" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No bridge VLAN filtering entries configured. Click "+ Add VLAN Entry" to configure 802.1Q tagged ports.
+            </div>
+          </div>
+
+          <!-- ================= SUBTAB 4: HOSTS (MAC TABLE) ================= -->
+          <div id="bridge-subtab-hosts" 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">Forwarding Database (MAC Table)</h3>
+                <span id="bridge-hosts-count-badge" class="text-xs font-mono text-slate-400">0 Entries</span>
+              </div>
+              <div class="flex items-center space-x-2">
+                <input type="text" id="bridge-hosts-search" oninput="renderBridgeHosts()" placeholder="Search MAC or Interface..." class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white">
+                <select id="bridge-hosts-filter-bridge" onchange="loadBridgeHosts()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white">
+                  <option value="">All Bridges</option>
+                </select>
+                <button onclick="loadBridgeHosts()" class="px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-200 border border-slate-700">🔄 Refresh</button>
+                <button onclick="flushBridgeHosts()" class="px-2.5 py-1 rounded-lg bg-rose-950/60 hover:bg-rose-900/80 text-rose-300 border border-rose-800/40 text-xs font-semibold">🗑️ Flush Dynamic</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">MAC Address</th>
+                    <th class="py-2.5 px-3">Interface / Port</th>
+                    <th class="py-2.5 px-3">Bridge</th>
+                    <th class="py-2.5 px-3">VLAN</th>
+                    <th class="py-2.5 px-3">Age (s)</th>
+                    <th class="py-2.5 px-3">Flags (L=Local, S=Static, D=Dynamic)</th>
+                  </tr>
+                </thead>
+                <tbody id="bridge-hosts-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="bridge-empty-hosts" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No MAC addresses learned in the forwarding database.
+            </div>
+          </div>
+        </section>
+
         <!-- ================= SECTION: VLANS (802.1Q) ================= -->
         <section id="section-vlans" class="hidden space-y-6">
           <div class="bg-gradient-to-r from-cyan-950/60 via-slate-900 to-slate-900 border border-cyan-800/40 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
@@ -3784,6 +4003,250 @@
     </div>
   </div>
 
-  <script src="app.js?v=2.8"></script>
+  <!-- ================= MODAL: ADD / EDIT BRIDGE ================= -->
+  <div id="bridge-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">
+        <h3 id="bridge-modal-title" class="text-sm font-bold text-white">Add Bridge Interface</h3>
+        <button onclick="closeBridgeModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveBridge(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="bridge-modal-id">
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Bridge Name *</label>
+            <input type="text" id="bridge-modal-name" required placeholder="e.g. bridge1" 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">MTU</label>
+            <input type="number" id="bridge-modal-mtu" value="1500" min="68" 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>
+          <label class="block font-semibold text-slate-300 mb-1.5">Admin MAC Address (Optional)</label>
+          <input type="text" id="bridge-modal-mac" placeholder="Leave blank for auto MAC" 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">STP Protocol</label>
+            <select id="bridge-modal-stp" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+              <option value="rstp" selected>RSTP (Rapid STP - Recommended)</option>
+              <option value="stp">STP (IEEE 802.1D)</option>
+              <option value="none">none (Disable STP)</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">STP Priority</label>
+            <input type="number" id="bridge-modal-priority" value="32768" min="0" max="65535" step="4096" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <!-- VLAN Filtering Box -->
+        <div class="p-3.5 bg-slate-950/60 border border-slate-800 rounded-xl space-y-3">
+          <label class="flex items-center space-x-2.5 cursor-pointer">
+            <input type="checkbox" id="bridge-modal-vlanfiltering" onchange="toggleBridgeModalVlanFiltering()" class="rounded bg-slate-800 border-slate-700 text-sky-500">
+            <span class="font-semibold text-white">VLAN Filtering (802.1Q Aware)</span>
+          </label>
+          <div id="bridge-modal-pvid-box" class="grid grid-cols-2 gap-3 pl-6">
+            <div>
+              <label class="block text-[11px] text-slate-400 mb-1">Default PVID</label>
+              <input type="number" id="bridge-modal-pvid" value="1" min="1" max="4094" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-white font-mono text-xs">
+            </div>
+            <div class="flex items-center pt-4">
+              <label class="flex items-center space-x-2 text-slate-400 cursor-pointer text-xs">
+                <input type="checkbox" id="bridge-modal-fastforward" checked class="rounded bg-slate-800 border-slate-700 text-sky-500">
+                <span>Fast Forward</span>
+              </label>
+            </div>
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="bridge-modal-comment" placeholder="e.g. LAN Bridge with EoIP" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+
+        <div class="pt-1">
+          <label class="flex items-center space-x-2 cursor-pointer">
+            <input type="checkbox" id="bridge-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500">
+            <span class="font-medium text-slate-300">Disabled (X)</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeBridgeModal()" 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-sky-600 hover:bg-sky-500 text-white shadow-lg shadow-sky-500/20">Save Bridge</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: ADD / EDIT BRIDGE PORT ================= -->
+  <div id="bridge-port-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">
+        <h3 id="bridge-port-modal-title" class="text-sm font-bold text-white">Add Port to Bridge</h3>
+        <button onclick="closeBridgePortModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveBridgePort(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="bridge-port-modal-id">
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Target Bridge *</label>
+            <select id="bridge-port-modal-bridge" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono"></select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Interface *</label>
+            <select id="bridge-port-modal-interface" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono"></select>
+          </div>
+        </div>
+
+        <div class="grid grid-cols-3 gap-3">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">PVID</label>
+            <input type="number" id="bridge-port-modal-pvid" value="1" min="1" max="4094" 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">Path Cost</label>
+            <input type="number" id="bridge-port-modal-pathcost" 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">Horizon (Split)</label>
+            <input type="number" id="bridge-port-modal-horizon" value="0" min="0" max="65535" title="0=none. Ports with identical horizon > 0 cannot communicate (split-horizon isolation for EoIP / VPLS)" 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">Frame Types</label>
+            <select id="bridge-port-modal-frametypes" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+              <option value="admit-all" selected>admit all</option>
+              <option value="admit-only-vlan-tagged">admit only VLAN tagged</option>
+              <option value="admit-only-untagged-and-priority-tagged">admit only untagged</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Priority</label>
+            <input type="number" id="bridge-port-modal-priority" value="128" min="0" max="240" step="16" 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 bg-slate-950/60 border border-slate-800 rounded-xl">
+          <label class="flex items-center space-x-2 cursor-pointer">
+            <input type="checkbox" id="bridge-port-modal-learn" checked class="rounded bg-slate-800 border-slate-700 text-sky-500">
+            <span class="text-white font-semibold">MAC Learning</span>
+          </label>
+          <label class="flex items-center space-x-2 cursor-pointer">
+            <input type="checkbox" id="bridge-port-modal-ingressfiltering" class="rounded bg-slate-800 border-slate-700 text-sky-500">
+            <span class="text-white font-semibold">Ingress Filtering</span>
+          </label>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="bridge-port-modal-comment" placeholder="e.g. Uplink to switch" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+
+        <div class="pt-1">
+          <label class="flex items-center space-x-2 cursor-pointer">
+            <input type="checkbox" id="bridge-port-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500">
+            <span class="font-medium text-slate-300">Disabled (X)</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeBridgePortModal()" 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-sky-600 hover:bg-sky-500 text-white shadow-lg shadow-sky-500/20">Save Port</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: ADD / EDIT BRIDGE VLAN ================= -->
+  <div id="bridge-vlan-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">
+        <h3 id="bridge-vlan-modal-title" class="text-sm font-bold text-white">Add Bridge VLAN Entry</h3>
+        <button onclick="closeBridgeVlanModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveBridgeVLAN(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="bridge-vlan-modal-id">
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Target Bridge *</label>
+            <select id="bridge-vlan-modal-bridge" onchange="updateBridgeVlanPortCheckboxes()" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono"></select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">VLAN IDs *</label>
+            <input type="text" id="bridge-vlan-modal-vlanids" required placeholder="e.g. 10 or 10,20,30 or 10-20" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Tagged Member Ports (Trunk)</label>
+          <div id="bridge-vlan-tagged-ports" class="max-h-36 overflow-y-auto p-2.5 bg-slate-950/60 border border-slate-800 rounded-xl space-y-1 font-mono text-slate-300">
+            <span class="text-slate-500 text-xs font-sans">Select a bridge above to view member ports</span>
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Untagged Member Ports (Access)</label>
+          <div id="bridge-vlan-untagged-ports" class="max-h-36 overflow-y-auto p-2.5 bg-slate-950/60 border border-slate-800 rounded-xl space-y-1 font-mono text-slate-300">
+            <span class="text-slate-500 text-xs font-sans">Select a bridge above to view member ports</span>
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="bridge-vlan-modal-comment" placeholder="e.g. Office & Guest VLANs" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+
+        <div class="pt-1">
+          <label class="flex items-center space-x-2 cursor-pointer">
+            <input type="checkbox" id="bridge-vlan-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500">
+            <span class="font-medium text-slate-300">Disabled (X)</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeBridgeVlanModal()" 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-sky-600 hover:bg-sky-500 text-white shadow-lg shadow-sky-500/20">Save VLAN Entry</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: ADD IP ADDRESS TO BRIDGE ================= -->
+  <div id="bridge-ip-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-sm 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">Assign IP Address to Bridge</h3>
+          <span id="bridge-ip-modal-sub" class="text-[11px] font-mono text-sky-400">bridge1</span>
+        </div>
+        <button onclick="closeBridgeIPModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitAddBridgeIP(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="bridge-ip-modal-id">
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">IPv4 / Subnet (CIDR) *</label>
+          <input type="text" id="bridge-ip-modal-cidr" required placeholder="e.g. 192.168.88.1/24" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeBridgeIPModal()" 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-sky-600 hover:bg-sky-500 text-white shadow-lg shadow-sky-500/20">Assign IP</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <script src="app.js?v=2.9"></script>
 </body>
 </html>