瀏覽代碼

feat(vrrp): implement VRRP high availability with keepalived engine, VIP failover and web UI

Proxmox Router 1 周之前
父節點
當前提交
dd480a2883
共有 7 個文件被更改,包括 1005 次插入 和 5 次删除
  1. 3 1
      cmd/server/main.go
  2. 11 1
      internal/api/api.go
  3. 76 0
      internal/api/vrrp_handlers.go
  4. 408 0
      internal/vrrp/manager.go
  5. 39 0
      internal/vrrp/types.go
  6. 269 2
      internal/web/static/app.js
  7. 199 1
      internal/web/static/index.html

+ 3 - 1
cmd/server/main.go

@@ -20,6 +20,7 @@ import (
 	"proxmox-router/internal/routing"
 	"proxmox-router/internal/tools"
 	"proxmox-router/internal/vpn"
+	"proxmox-router/internal/vrrp"
 	"proxmox-router/internal/web"
 )
 
@@ -40,9 +41,10 @@ func main() {
 	toolsMgr := tools.NewManager()
 	dhcpMgr := dhcp.NewManager()
 	dhcpServerMgr := dhcp.NewServerManager()
+	vrrpMgr := vrrp.NewManager()
 	webHandler := web.Handler()
 
-	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, webHandler)
+	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, vrrpMgr, webHandler)
 
 	httpServer := &http.Server{
 		Addr:         *addr,

+ 11 - 1
internal/api/api.go

@@ -14,6 +14,7 @@ import (
 	"proxmox-router/internal/system"
 	"proxmox-router/internal/tools"
 	"proxmox-router/internal/vpn"
+	"proxmox-router/internal/vrrp"
 )
 
 type Server struct {
@@ -28,11 +29,12 @@ type Server struct {
 	toolsMgr      *tools.Manager
 	dhcpMgr       *dhcp.Manager
 	dhcpServerMgr *dhcp.ServerManager
+	vrrpMgr       *vrrp.Manager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, 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, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -45,6 +47,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		toolsMgr:      toolsMgr,
 		dhcpMgr:       dhcpMgr,
 		dhcpServerMgr: dhcpServerMgr,
+		vrrpMgr:       vrrpMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -362,6 +365,13 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("DELETE /api/dhcp-server/options/{id}", s.handleDeleteDHCPOption)
 	s.mux.HandleFunc("POST /api/dhcp-server/setup", s.handleRunDHCPSetup)
 
+	// VRRP Redundancy & High Availability
+	s.mux.HandleFunc("GET /api/vrrp/summary", s.handleGetVRRPSummary)
+	s.mux.HandleFunc("GET /api/vrrp/instances", s.handleListVRRPInstances)
+	s.mux.HandleFunc("POST /api/vrrp/instances", s.handleSaveVRRPInstance)
+	s.mux.HandleFunc("DELETE /api/vrrp/instances/{id}", s.handleDeleteVRRPInstance)
+	s.mux.HandleFunc("POST /api/vrrp/instances/{id}/state", s.handleToggleVRRPState)
+
 	// Web UI Static Files
 	if s.fsHandler != nil {
 		s.mux.Handle("/", s.fsHandler)

+ 76 - 0
internal/api/vrrp_handlers.go

@@ -0,0 +1,76 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"proxmox-router/internal/vrrp"
+)
+
+func (s *Server) handleListVRRPInstances(w http.ResponseWriter, r *http.Request) {
+	list := s.vrrpMgr.List()
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(list)
+}
+
+func (s *Server) handleSaveVRRPInstance(w http.ResponseWriter, r *http.Request) {
+	var inst vrrp.VRRPInstance
+	if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
+		http.Error(w, "invalid request body", http.StatusBadRequest)
+		return
+	}
+
+	saved, err := s.vrrpMgr.Save(inst)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(saved)
+}
+
+func (s *Server) handleDeleteVRRPInstance(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		http.Error(w, "missing instance ID", http.StatusBadRequest)
+		return
+	}
+
+	if err := s.vrrpMgr.Delete(id); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]bool{"success": true})
+}
+
+func (s *Server) handleToggleVRRPState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		http.Error(w, "missing instance ID", http.StatusBadRequest)
+		return
+	}
+
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "invalid JSON", http.StatusBadRequest)
+		return
+	}
+
+	if err := s.vrrpMgr.ToggleState(id, req.Disabled); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]bool{"success": true})
+}
+
+func (s *Server) handleGetVRRPSummary(w http.ResponseWriter, r *http.Request) {
+	summary := s.vrrpMgr.GetSummary()
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(summary)
+}

+ 408 - 0
internal/vrrp/manager.go

@@ -0,0 +1,408 @@
+package vrrp
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/vishvananda/netlink"
+)
+
+const (
+	VRRPStorePath        = "/etc/proxmox-router/vrrp.json"
+	KeepalivedConfPath   = "/etc/keepalived/keepalived.conf"
+	VRRPStatusDir        = "/run/proxmox-router/vrrp"
+	VRRPNotifyScriptPath = "/usr/local/bin/vrrp-notify.sh"
+)
+
+type Manager struct {
+	mu        sync.RWMutex
+	instances map[string]VRRPInstance
+	storePath string
+	confPath  string
+}
+
+func NewManager() *Manager {
+	m := &Manager{
+		instances: make(map[string]VRRPInstance),
+		storePath: VRRPStorePath,
+		confPath:  KeepalivedConfPath,
+	}
+
+	_ = os.MkdirAll(filepath.Dir(m.storePath), 0755)
+	_ = os.MkdirAll(filepath.Dir(m.confPath), 0755)
+	_ = os.MkdirAll(VRRPStatusDir, 0755)
+
+	m.ensureNotifyScript()
+	m.load()
+
+	// Apply config on startup if instances exist
+	_ = m.applyConfigLocked()
+
+	return m
+}
+
+func (m *Manager) ensureNotifyScript() {
+	scriptContent := `#!/bin/bash
+STATE="$1"
+NAME="$2"
+mkdir -p /run/proxmox-router/vrrp
+echo "$STATE $(date -Iseconds)" > "/run/proxmox-router/vrrp/${NAME}.state"
+`
+	_ = os.WriteFile(VRRPNotifyScriptPath, []byte(scriptContent), 0755)
+}
+
+func (m *Manager) load() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	data, err := os.ReadFile(m.storePath)
+	if err != nil {
+		return
+	}
+
+	var store VRRPConfigStore
+	if err := json.Unmarshal(data, &store); err != nil {
+		return
+	}
+
+	for _, inst := range store.Instances {
+		m.instances[inst.ID] = inst
+	}
+}
+
+func (m *Manager) saveLocked() error {
+	store := VRRPConfigStore{
+		Instances: make([]VRRPInstance, 0, len(m.instances)),
+	}
+	for _, inst := range m.instances {
+		store.Instances = append(store.Instances, inst)
+	}
+
+	data, err := json.MarshalIndent(store, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(m.storePath, data, 0644)
+}
+
+func (m *Manager) List() []VRRPInstance {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	isDaemonActive := m.isKeepalivedRunning()
+
+	list := make([]VRRPInstance, 0, len(m.instances))
+	for _, inst := range m.instances {
+		copyInst := inst
+		copyInst.VirtualMAC = fmt.Sprintf("00:00:5E:00:01:%02X", copyInst.VRID)
+
+		if copyInst.Disabled {
+			copyInst.State = "DISABLED"
+			copyInst.Running = false
+		} else {
+			copyInst.State, copyInst.Running, copyInst.LastTransition = m.detectInstanceState(copyInst, isDaemonActive)
+		}
+
+		list = append(list, copyInst)
+	}
+
+	sort.Slice(list, func(i, j int) bool {
+		if list[i].Interface != list[j].Interface {
+			return list[i].Interface < list[j].Interface
+		}
+		return list[i].VRID < list[j].VRID
+	})
+
+	return list
+}
+
+func (m *Manager) detectInstanceState(inst VRRPInstance, daemonActive bool) (string, bool, time.Time) {
+	link, err := netlink.LinkByName(inst.Interface)
+	if err != nil || link.Attrs().OperState == netlink.OperDown {
+		return "FAULT", false, time.Time{}
+	}
+
+	// 1. Direct Kernel Check: Does this interface currently hold any of the virtual IPs?
+	if len(inst.VirtualIPs) > 0 {
+		addrs, err := netlink.AddrList(link, netlink.FAMILY_V4)
+		if err == nil {
+			for _, a := range addrs {
+				for _, vip := range inst.VirtualIPs {
+					cleanVIP := strings.Split(vip, "/")[0]
+					if a.IP.String() == cleanVIP {
+						return "MASTER", true, time.Time{}
+					}
+				}
+			}
+		}
+	}
+
+	// 2. Check notify state file
+	stateFile := filepath.Join(VRRPStatusDir, fmt.Sprintf("%s.state", sanitizeName(inst.Name)))
+	if data, err := os.ReadFile(stateFile); err == nil {
+		parts := strings.Fields(string(data))
+		if len(parts) >= 1 {
+			st := parts[0]
+			var t time.Time
+			if len(parts) >= 2 {
+				t, _ = time.Parse(time.RFC3339, parts[1])
+			}
+			if st == "MASTER" {
+				return "MASTER", true, t
+			}
+			if st == "FAULT" {
+				return "FAULT", false, t
+			}
+			if st == "BACKUP" && daemonActive {
+				return "BACKUP", true, t
+			}
+		}
+	}
+
+	if daemonActive {
+		return "BACKUP", true, time.Time{}
+	}
+
+	return "FAULT", false, time.Time{}
+}
+
+func (m *Manager) isKeepalivedRunning() bool {
+	out, err := exec.Command("systemctl", "is-active", "keepalived").Output()
+	return err == nil && strings.TrimSpace(string(out)) == "active"
+}
+
+func (m *Manager) Get(id string) (VRRPInstance, bool) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	inst, ok := m.instances[id]
+	if !ok {
+		return VRRPInstance{}, false
+	}
+	inst.VirtualMAC = fmt.Sprintf("00:00:5E:00:01:%02X", inst.VRID)
+	if inst.Disabled {
+		inst.State = "DISABLED"
+		inst.Running = false
+	} else {
+		inst.State, inst.Running, inst.LastTransition = m.detectInstanceState(inst, m.isKeepalivedRunning())
+	}
+	return inst, true
+}
+
+func (m *Manager) Save(inst VRRPInstance) (VRRPInstance, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if inst.Name == "" || inst.Interface == "" {
+		return inst, fmt.Errorf("name and interface are required")
+	}
+	if inst.VRID < 1 || inst.VRID > 255 {
+		return inst, fmt.Errorf("VRID must be between 1 and 255")
+	}
+	if inst.Priority < 1 || inst.Priority > 255 {
+		inst.Priority = 100
+	}
+	if inst.Interval < 1 {
+		inst.Interval = 1
+	}
+	if inst.Version != 2 && inst.Version != 3 {
+		inst.Version = 3
+	}
+	if len(inst.VirtualIPs) == 0 {
+		return inst, fmt.Errorf("at least one Virtual IP (VIP) is required")
+	}
+
+	// Validate VIP formatting
+	for _, vip := range inst.VirtualIPs {
+		ipStr := strings.Split(vip, "/")[0]
+		if net.ParseIP(strings.TrimSpace(ipStr)) == nil {
+			return inst, fmt.Errorf("invalid virtual IP address: %s", vip)
+		}
+	}
+
+	if inst.ID == "" {
+		inst.ID = fmt.Sprintf("vrrp-%s", sanitizeName(inst.Name))
+	}
+
+	m.instances[inst.ID] = inst
+	if err := m.saveLocked(); err != nil {
+		return inst, err
+	}
+
+	if err := m.applyConfigLocked(); err != nil {
+		return inst, err
+	}
+
+	return inst, nil
+}
+
+func (m *Manager) Delete(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	inst, ok := m.instances[id]
+	if !ok {
+		return fmt.Errorf("VRRP instance not found: %s", id)
+	}
+
+	delete(m.instances, id)
+	_ = os.Remove(filepath.Join(VRRPStatusDir, fmt.Sprintf("%s.state", sanitizeName(inst.Name))))
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+
+	return m.applyConfigLocked()
+}
+
+func (m *Manager) ToggleState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	inst, ok := m.instances[id]
+	if !ok {
+		return fmt.Errorf("VRRP instance not found: %s", id)
+	}
+
+	inst.Disabled = disabled
+	m.instances[id] = inst
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+
+	return m.applyConfigLocked()
+}
+
+func (m *Manager) GetSummary() VRRPSummary {
+	list := m.List()
+	s := VRRPSummary{
+		TotalInstances: len(list),
+	}
+	for _, inst := range list {
+		if inst.State == "MASTER" {
+			s.MasterInstances++
+		} else if inst.State == "BACKUP" {
+			s.BackupInstances++
+		} else if inst.State == "FAULT" {
+			s.FaultInstances++
+		}
+	}
+	return s
+}
+
+func (m *Manager) applyConfigLocked() error {
+	m.ensureNotifyScript()
+
+	activeCount := 0
+	for _, inst := range m.instances {
+		if !inst.Disabled {
+			activeCount++
+		}
+	}
+
+	if activeCount == 0 {
+		_ = exec.Command("systemctl", "stop", "keepalived").Run()
+		_ = os.Remove(m.confPath)
+		return nil
+	}
+
+	var sb strings.Builder
+	sb.WriteString("# ==================================================================\n")
+	sb.WriteString("# Enterprise VRRP (Virtual Router Redundancy Protocol) Configuration\n")
+	sb.WriteString("# Managed by DebTik Network Appliance Controller. DO NOT EDIT DIRECTLY.\n")
+	sb.WriteString("# ==================================================================\n\n")
+
+	sb.WriteString("global_defs {\n")
+	sb.WriteString("    router_id proxmox-router\n")
+	sb.WriteString("    vrrp_version 3\n")
+	sb.WriteString("    script_user root\n")
+	sb.WriteString("    enable_script_security\n")
+	sb.WriteString("}\n\n")
+
+	for _, inst := range m.instances {
+		if inst.Disabled {
+			continue
+		}
+
+		instTag := sanitizeName(inst.Name)
+		sb.WriteString(fmt.Sprintf("# --- VRRP Instance: %s on %s (VRID: %d) ---\n", inst.Name, inst.Interface, inst.VRID))
+		sb.WriteString(fmt.Sprintf("vrrp_instance %s {\n", instTag))
+		sb.WriteString("    state BACKUP\n")
+		sb.WriteString(fmt.Sprintf("    interface %s\n", inst.Interface))
+		sb.WriteString(fmt.Sprintf("    virtual_router_id %d\n", inst.VRID))
+		sb.WriteString(fmt.Sprintf("    priority %d\n", inst.Priority))
+		sb.WriteString(fmt.Sprintf("    advert_int %d\n", inst.Interval))
+
+		if inst.Preempt {
+			sb.WriteString("    preempt\n")
+		} else {
+			sb.WriteString("    nopreempt\n")
+		}
+
+		if inst.Version == 2 {
+			sb.WriteString("    version 2\n")
+		}
+
+		if inst.AuthType == "pass" && inst.AuthPassword != "" {
+			sb.WriteString("    authentication {\n")
+			sb.WriteString("        auth_type PASS\n")
+			sb.WriteString(fmt.Sprintf("        auth_pass %s\n", inst.AuthPassword))
+			sb.WriteString("    }\n")
+		}
+
+		sb.WriteString("    virtual_ipaddress {\n")
+		for _, vip := range inst.VirtualIPs {
+			vipTrim := strings.TrimSpace(vip)
+			if vipTrim != "" {
+				if strings.Contains(vipTrim, "/") {
+					sb.WriteString(fmt.Sprintf("        %s dev %s\n", vipTrim, inst.Interface))
+				} else {
+					sb.WriteString(fmt.Sprintf("        %s/32 dev %s\n", vipTrim, inst.Interface))
+				}
+			}
+		}
+		sb.WriteString("    }\n")
+
+		sb.WriteString(fmt.Sprintf("    notify_master \"%s MASTER %s\"\n", VRRPNotifyScriptPath, instTag))
+		sb.WriteString(fmt.Sprintf("    notify_backup \"%s BACKUP %s\"\n", VRRPNotifyScriptPath, instTag))
+		sb.WriteString(fmt.Sprintf("    notify_fault  \"%s FAULT %s\"\n", VRRPNotifyScriptPath, instTag))
+		sb.WriteString(fmt.Sprintf("    notify_stop   \"%s STOP %s\"\n", VRRPNotifyScriptPath, instTag))
+		sb.WriteString("}\n\n")
+	}
+
+	if err := os.WriteFile(m.confPath, []byte(sb.String()), 0644); err != nil {
+		return fmt.Errorf("failed to write keepalived.conf: %w", err)
+	}
+
+	cmd := exec.Command("systemctl", "restart", "keepalived")
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to restart keepalived: %s", string(out))
+	}
+
+	return nil
+}
+
+func sanitizeName(n string) string {
+	var res strings.Builder
+	for _, r := range n {
+		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
+			res.WriteRune(r)
+		} else {
+			res.WriteRune('_')
+		}
+	}
+	s := res.String()
+	if s == "" {
+		return "vrrp1"
+	}
+	return s
+}

+ 39 - 0
internal/vrrp/types.go

@@ -0,0 +1,39 @@
+package vrrp
+
+import "time"
+
+// VRRPInstance represents a Virtual Router Redundancy Protocol configuration
+type VRRPInstance struct {
+	ID             string    `json:"id"`
+	Name           string    `json:"name"`            // e.g. "vrrp-lan", "vrrp1"
+	Interface      string    `json:"interface"`       // e.g. "bridge1", "eth0", "vlan10"
+	VRID           int       `json:"vrid"`            // 1 - 255
+	Priority       int       `json:"priority"`        // 1 - 255 (default 100)
+	Interval       int       `json:"interval"`        // advertisement interval in seconds (default 1)
+	Preempt        bool      `json:"preempt"`         // true = master preemption active
+	Version        int       `json:"version"`         // 2 or 3 (default 3)
+	VirtualIPs     []string  `json:"virtual_ips"`     // e.g. ["192.168.88.254/24"]
+	AuthType       string    `json:"auth_type"`       // "none", "pass"
+	AuthPassword   string    `json:"auth_password,omitempty"`
+	Disabled       bool      `json:"disabled"`        // true = disabled
+	Comment        string    `json:"comment,omitempty"`
+
+	// Runtime status
+	State          string    `json:"state"`           // "MASTER", "BACKUP", "FAULT", "DISABLED"
+	Running        bool      `json:"running"`         // true if Master or Backup (daemon active)
+	VirtualMAC     string    `json:"virtual_mac"`     // e.g. "00:00:5E:00:01:0A"
+	LastTransition time.Time `json:"last_transition,omitempty"`
+}
+
+// VRRPSummary provides aggregated status for KPI cards
+type VRRPSummary struct {
+	TotalInstances  int `json:"total_instances"`
+	MasterInstances int `json:"master_instances"`
+	BackupInstances int `json:"backup_instances"`
+	FaultInstances  int `json:"fault_instances"`
+}
+
+// VRRPConfigStore wraps persistent storage
+type VRRPConfigStore struct {
+	Instances []VRRPInstance `json:"instances"`
+}

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

@@ -135,6 +135,7 @@ function navigate(tabName) {
     ipsec: { title: 'IPSec (IKEv2) strongSwan', sub: 'Enterprise Site-to-Site & Roadwarrior IPSec controller' },
     l2tp: { title: 'L2TP VPN Client', sub: 'Layer 2 Tunneling Protocol via xl2tpd' },
     pptp: { title: 'PPTP VPN Client', sub: 'Point-to-Point Tunneling Protocol with MPPE-128 encryption' },
+    vrrp: { title: 'VRRP High Availability', sub: 'Virtual Router Redundancy Protocol, active-backup gateway failover & Virtual IP (VIP) routing' },
     tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' },
     persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' }
   };
@@ -179,6 +180,7 @@ function navigate(tabName) {
   if (tabName === 'tools') loadToolsView();
   if (tabName === 'dhcp-client') loadDHCPClients();
   if (tabName === 'dhcp-server') loadDHCPServer();
+  if (tabName === 'vrrp') loadVRRP();
   setTimeout(initTableSorting, 50);
 }
 
@@ -188,7 +190,7 @@ async function fetchData(isManual = false) {
   if (isManual && spinner) spinner.classList.add('animate-spin');
 
   try {
-    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes] = await Promise.all([
+    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes] = await Promise.all([
       fetch(`${API_BASE}/system/status`),
       fetch(`${API_BASE}/interfaces`),
       fetch(`${API_BASE}/vlans`),
@@ -197,9 +199,15 @@ async function fetchData(isManual = false) {
       fetch(`${API_BASE}/routing/dynamic/summary`),
       fetch(`${API_BASE}/bridge/summary`),
       fetch(`${API_BASE}/dhcp/clients/summary`),
-      fetch(`${API_BASE}/dhcp/clients`)
+      fetch(`${API_BASE}/dhcp/clients`),
+      fetch(`${API_BASE}/vrrp/summary`).catch(() => null)
     ]);
 
+    if (vrrpSumRes && vrrpSumRes.ok) {
+      state.vrrpSummary = await vrrpSumRes.json();
+      renderVRRPSummary();
+    }
+
     if (dhcpSumRes && dhcpSumRes.ok) {
       state.dhcpSummary = await dhcpSumRes.json();
       const bDhcp = document.getElementById('badge-dhcp-client-count');
@@ -259,6 +267,7 @@ async function fetchData(isManual = false) {
     if (state.activeTab === 'pptp') loadPPTP(false);
     if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
     if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
+    if (state.activeTab === 'vrrp') loadVRRP(false);
 
   } catch (err) {
     console.error('Failed to fetch router state:', err);
@@ -13595,6 +13604,264 @@ function compareTableCells(cellA, cellB) {
   return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
 }
 
+// ==================== VRRP HIGH AVAILABILITY CONTROLLER ====================
+async function loadVRRP(showToastNotification = false) {
+  try {
+    const [instRes, sumRes] = await Promise.all([
+      fetch(`${API_BASE}/vrrp/instances`),
+      fetch(`${API_BASE}/vrrp/summary`)
+    ]);
+
+    if (instRes.ok) {
+      state.vrrpInstances = await instRes.json();
+      renderVRRP();
+    }
+    if (sumRes.ok) {
+      state.vrrpSummary = await sumRes.json();
+      renderVRRPSummary();
+    }
+    if (showToastNotification) {
+      showToast('Refreshed', 'VRRP telemetry refreshed', 'info');
+    }
+  } catch (err) {
+    console.error('Failed to load VRRP:', err);
+  }
+}
+
+function renderVRRPSummary() {
+  const s = state.vrrpSummary || { total_instances: 0, master_instances: 0, backup_instances: 0 };
+  const elTotal = document.getElementById('vrrp-stat-total');
+  if (elTotal) elTotal.innerText = s.total_instances || 0;
+  const elMaster = document.getElementById('vrrp-stat-master');
+  if (elMaster) elMaster.innerText = s.master_instances || 0;
+  const elBackup = document.getElementById('vrrp-stat-backup');
+  if (elBackup) elBackup.innerText = s.backup_instances || 0;
+
+  const bCount = document.getElementById('badge-vrrp-count');
+  if (bCount) bCount.innerText = s.total_instances || 0;
+  const dot = document.getElementById('badge-vrrp-dot');
+  if (dot) {
+    if (s.master_instances > 0) {
+      dot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse';
+    } else if (s.backup_instances > 0) {
+      dot.className = 'w-2 h-2 rounded-full bg-sky-400';
+    } else {
+      dot.className = 'w-2 h-2 rounded-full bg-slate-600';
+    }
+  }
+}
+
+function renderVRRP() {
+  const tbody = document.getElementById('vrrp-table-tbody');
+  const empty = document.getElementById('vrrp-table-empty');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const instances = state.vrrpInstances || [];
+  if (instances.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  instances.forEach(inst => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${inst.disabled ? 'opacity-50' : ''}`;
+
+    // Render Flag: M (Master), B (Backup), F (Fault), X (Disabled)
+    let flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-slate-800 text-slate-500 border border-slate-700">-</span>`;
+    if (inst.disabled) {
+      flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-rose-500/20 text-rose-400 border border-rose-500/40 shadow-sm" title="Disabled">X</span>`;
+    } else if (inst.state === 'MASTER') {
+      flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 shadow-sm" title="Master (Holding VIP)">M</span>`;
+    } else if (inst.state === 'BACKUP') {
+      flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-sky-500/20 text-sky-400 border border-sky-500/40 shadow-sm" title="Backup (Standby)">B</span>`;
+    } else if (inst.state === 'FAULT') {
+      flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-amber-500/20 text-amber-400 border border-amber-500/40 shadow-sm" title="Fault (Interface Down)">F</span>`;
+    }
+
+    // Render State Badge
+    let stateBadge = `<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 border border-slate-700">DISABLED</span>`;
+    if (!inst.disabled) {
+      if (inst.state === 'MASTER') {
+        stateBadge = `<span class="px-2 py-0.5 rounded text-[10px] bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>MASTER</span>`;
+      } else if (inst.state === 'BACKUP') {
+        stateBadge = `<span class="px-2 py-0.5 rounded text-[10px] bg-sky-500/20 text-sky-300 border border-sky-500/40 font-bold inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span>BACKUP</span>`;
+      } else if (inst.state === 'FAULT') {
+        stateBadge = `<span class="px-2 py-0.5 rounded text-[10px] bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>FAULT</span>`;
+      }
+    }
+
+    const vipsStr = (inst.virtual_ips || []).join(', ') || '-';
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center">${flagHtml}</td>
+      <td class="py-2.5 px-3 font-bold text-white font-mono">${escapeHtml(inst.name)}</td>
+      <td class="py-2.5 px-3 font-mono text-cyan-400 font-semibold">${escapeHtml(inst.interface)}</td>
+      <td class="py-2.5 px-3 font-mono font-bold text-center text-white">${inst.vrid}</td>
+      <td class="py-2.5 px-3 font-mono text-center text-slate-300 font-semibold">${inst.priority}</td>
+      <td class="py-2.5 px-3 font-mono text-emerald-400 font-bold"><span class="px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20">${escapeHtml(vipsStr)}</span></td>
+      <td class="py-2.5 px-3 font-mono text-center text-slate-300">${inst.preempt ? '<span class="text-emerald-400 font-semibold">yes</span>' : '<span class="text-slate-500">no</span>'}</td>
+      <td class="py-2.5 px-3 font-mono text-slate-400 text-[11px]">${escapeHtml(inst.virtual_mac || '-')}</td>
+      <td class="py-2.5 px-3 text-center">${stateBadge}</td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[140px]" title="${escapeHtml(inst.comment || '')}">${escapeHtml(inst.comment || '-')}</td>
+      <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
+        <button onclick="toggleVRRPState('${inst.id}', ${!inst.disabled})" class="text-xs ${inst.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${inst.disabled ? 'Enable' : 'Disable'}</button>
+        <button onclick="openEditVRRPModal('${inst.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+        <button onclick="deleteVRRP('${inst.id}', '${escapeHtml(inst.name)}')" class="text-xs text-rose-400 hover:text-rose-300">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function populateVRRPIfaceSelect(elemId, selectedIface) {
+  const sel = document.getElementById(elemId);
+  if (!sel) return;
+  sel.innerHTML = '<option value="">Select interface...</option>';
+  const ifaces = state.interfaces || [];
+  ifaces.forEach(i => {
+    const opt = document.createElement('option');
+    opt.value = i.name;
+    opt.innerText = `${i.name} (${i.type || 'net'}${i.ip ? ' - ' + i.ip : ''})`;
+    if (selectedIface && selectedIface === i.name) opt.selected = true;
+    sel.appendChild(opt);
+  });
+}
+
+function openAddVRRPModal() {
+  document.getElementById('modal-vrrp-title').innerText = 'Add VRRP Instance';
+  document.getElementById('modal-vrrp-id').value = '';
+  document.getElementById('modal-vrrp-name').value = `vrrp${(state.vrrpInstances || []).length + 1}`;
+  document.getElementById('modal-vrrp-vrid').value = 10 + (state.vrrpInstances || []).length;
+  document.getElementById('modal-vrrp-priority').value = 100;
+  document.getElementById('modal-vrrp-vip').value = '192.168.88.254/24';
+  document.getElementById('modal-vrrp-version').value = '3';
+  document.getElementById('modal-vrrp-interval').value = 1;
+  document.getElementById('modal-vrrp-authtype').value = 'none';
+  document.getElementById('modal-vrrp-authpass').value = '';
+  document.getElementById('modal-vrrp-comment').value = '';
+  document.getElementById('modal-vrrp-preempt').checked = true;
+  document.getElementById('modal-vrrp-disabled').checked = false;
+
+  populateVRRPIfaceSelect('modal-vrrp-iface', '');
+  document.getElementById('modal-vrrp-instance').classList.remove('hidden');
+}
+
+function openEditVRRPModal(id) {
+  const inst = (state.vrrpInstances || []).find(item => item.id === id);
+  if (!inst) return;
+  document.getElementById('modal-vrrp-title').innerText = `Edit VRRP Instance (${inst.name})`;
+  document.getElementById('modal-vrrp-id').value = inst.id;
+  document.getElementById('modal-vrrp-name').value = inst.name;
+  document.getElementById('modal-vrrp-vrid').value = inst.vrid;
+  document.getElementById('modal-vrrp-priority').value = inst.priority;
+  document.getElementById('modal-vrrp-vip').value = (inst.virtual_ips || []).join(', ');
+  document.getElementById('modal-vrrp-version').value = String(inst.version || 3);
+  document.getElementById('modal-vrrp-interval').value = inst.interval || 1;
+  document.getElementById('modal-vrrp-authtype').value = inst.auth_type || 'none';
+  document.getElementById('modal-vrrp-authpass').value = inst.auth_password || '';
+  document.getElementById('modal-vrrp-comment').value = inst.comment || '';
+  document.getElementById('modal-vrrp-preempt').checked = inst.preempt !== false;
+  document.getElementById('modal-vrrp-disabled').checked = !!inst.disabled;
+
+  populateVRRPIfaceSelect('modal-vrrp-iface', inst.interface);
+  document.getElementById('modal-vrrp-instance').classList.remove('hidden');
+}
+
+function closeVRRPModal() {
+  document.getElementById('modal-vrrp-instance').classList.add('hidden');
+}
+
+async function submitSaveVRRP(e) {
+  e.preventDefault();
+  const id = document.getElementById('modal-vrrp-id').value;
+  const name = document.getElementById('modal-vrrp-name').value.trim();
+  const iface = document.getElementById('modal-vrrp-iface').value;
+  const vrid = parseInt(document.getElementById('modal-vrrp-vrid').value, 10) || 1;
+  const priority = parseInt(document.getElementById('modal-vrrp-priority').value, 10) || 100;
+  const vipRaw = document.getElementById('modal-vrrp-vip').value.trim();
+  const version = parseInt(document.getElementById('modal-vrrp-version').value, 10) || 3;
+  const interval = parseInt(document.getElementById('modal-vrrp-interval').value, 10) || 1;
+  const authType = document.getElementById('modal-vrrp-authtype').value;
+  const authPass = document.getElementById('modal-vrrp-authpass').value.trim();
+  const comment = document.getElementById('modal-vrrp-comment').value.trim();
+  const preempt = document.getElementById('modal-vrrp-preempt').checked;
+  const disabled = document.getElementById('modal-vrrp-disabled').checked;
+
+  const vips = vipRaw.split(',').map(s => s.trim()).filter(Boolean);
+
+  try {
+    const res = await fetch(`${API_BASE}/vrrp/instances`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        id,
+        name,
+        interface: iface,
+        vrid,
+        priority,
+        virtual_ips: vips,
+        version,
+        interval,
+        auth_type: authType,
+        auth_password: authPass,
+        comment,
+        preempt,
+        disabled
+      })
+    });
+    if (res.ok) {
+      showToast('VRRP Saved', `VRRP instance ${name} configured`, 'success');
+      closeVRRPModal();
+      loadVRRP(false);
+      fetchData(false);
+    } else {
+      const err = await res.text();
+      showToast('Error', err, 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteVRRP(id, name) {
+  if (!confirm(`Delete VRRP instance "${name}"?`)) return;
+  try {
+    const res = await fetch(`${API_BASE}/vrrp/instances/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', `VRRP instance ${name} removed`, 'info');
+      loadVRRP(false);
+      fetchData(false);
+    } else {
+      const err = await res.text();
+      showToast('Error', err, 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleVRRPState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/vrrp/instances/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) {
+      showToast('State Updated', disabled ? 'VRRP disabled' : 'VRRP enabled', 'info');
+      loadVRRP(false);
+      fetchData(false);
+    } else {
+      const err = await res.text();
+      showToast('Error', err, 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
 // Global observer for dynamically injected tables (e.g. inside modals or drawers)
 if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
   const docTableObserver = new MutationObserver((mutations) => {

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

@@ -196,6 +196,23 @@
           <span id="badge-filters-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
         </a>
 
+        <!-- CATEGORY: HIGH AVAILABILITY -->
+        <div class="px-3 pt-3 pb-1 text-[10px] font-bold text-slate-500 uppercase tracking-wider">High Availability</div>
+
+        <a href="#vrrp" onclick="navigate('vrrp')" id="nav-vrrp"
+           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-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
+            </svg>
+            <span>VRRP (HA)</span>
+          </div>
+          <div class="flex items-center gap-1.5">
+            <span id="badge-vrrp-dot" class="w-2 h-2 rounded-full bg-slate-600" title="VRRP State"></span>
+            <span id="badge-vrrp-count" class="text-[10px] bg-emerald-950 text-emerald-300 border border-emerald-800/40 px-1.5 py-0.2 rounded-full font-mono">0</span>
+          </div>
+        </a>
+
         <!-- CATEGORY: WAN & ACCESS -->
         <div class="px-3 pt-3 pb-1 text-[10px] font-bold text-slate-500 uppercase tracking-wider">WAN & Access</div>
 
@@ -3199,6 +3216,89 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: VRRP HIGH AVAILABILITY ================= -->
+        <section id="section-vrrp" class="hidden space-y-6">
+          <div class="bg-gradient-to-r from-emerald-950/40 via-slate-900 to-slate-900 border border-emerald-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <div class="flex items-center gap-2 mb-1">
+                <span class="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 tracking-wide uppercase">High Availability</span>
+                <h2 class="text-base font-bold text-white flex items-center gap-2">
+                  <span>VRRP Virtual Routers</span>
+                  <span class="px-2 py-0.5 text-[10px] rounded-full bg-slate-800 text-slate-300 border border-slate-700 font-mono font-normal">Active / Backup Failover</span>
+                </h2>
+              </div>
+              <p class="text-xs text-slate-400">Virtual Router Redundancy Protocol (VRRPv3/v2) with automated Virtual IP (VIP) failover, preemptive election, priority ranking, and virtual MAC routing.</p>
+            </div>
+            <div class="flex items-center gap-2">
+              <button onclick="openAddVRRPModal()" class="text-xs font-semibold px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20 transition flex items-center gap-1.5">
+                <span>+ Add VRRP Instance</span>
+              </button>
+              <button onclick="loadVRRP(true)" class="text-xs font-semibold px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition" title="Refresh">
+                <span>🔄</span>
+              </button>
+            </div>
+          </div>
+
+          <!-- KPI Summary Cards -->
+          <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
+            <div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
+              <div class="text-xs text-slate-400">Total VRRP Routers</div>
+              <div id="vrrp-stat-total" class="text-xl font-bold text-white font-mono mt-1">0</div>
+            </div>
+            <div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
+              <div class="text-xs text-slate-400">Master Instances (Active VIP)</div>
+              <div id="vrrp-stat-master" class="text-xl font-bold text-emerald-400 font-mono mt-1">0</div>
+              <div class="text-[10px] text-slate-500 mt-0.5">Holding virtual IP gateways</div>
+            </div>
+            <div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
+              <div class="text-xs text-slate-400">Backup Instances (Standby)</div>
+              <div id="vrrp-stat-backup" class="text-xl font-bold text-sky-400 font-mono mt-1">0</div>
+              <div class="text-[10px] text-slate-500 mt-0.5">Listening for heartbeats</div>
+            </div>
+            <div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
+              <div class="text-xs text-slate-400">Redundancy Protocol</div>
+              <div class="text-xl font-bold text-amber-300 font-mono mt-1">VRRPv3</div>
+              <div class="text-[10px] text-slate-500 mt-0.5">keepalived HA engine</div>
+            </div>
+          </div>
+
+          <!-- VRRP Table View -->
+          <div 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">
+              <div>
+                <h3 class="text-xs font-bold text-white">Configured VRRP Instances</h3>
+                <p class="text-[11px] text-slate-400 mt-0.5">Virtual router instances, priority weights, virtual IP addresses and live election states</p>
+              </div>
+              <div class="text-xs text-slate-400 font-mono">
+                <span class="text-emerald-400 font-bold">M</span> = Master &nbsp;|&nbsp; <span class="text-sky-400 font-bold">B</span> = Backup &nbsp;|&nbsp; <span class="text-rose-400 font-bold">X</span> = Disabled
+              </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-12 text-center">Flag</th>
+                    <th class="py-2.5 px-3">Name</th>
+                    <th class="py-2.5 px-3">Interface</th>
+                    <th class="py-2.5 px-3 text-center">VRID</th>
+                    <th class="py-2.5 px-3 text-center">Priority</th>
+                    <th class="py-2.5 px-3">Virtual IP (VIP)</th>
+                    <th class="py-2.5 px-3 text-center">Preempt</th>
+                    <th class="py-2.5 px-3">Virtual MAC</th>
+                    <th class="py-2.5 px-3 text-center">Live State</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="vrrp-table-tbody" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+            <div id="vrrp-table-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No VRRP instances configured. Click "+ Add VRRP Instance" to configure high availability redundancy.
+            </div>
+          </div>
+        </section>
+
         <!-- ================= SECTION: DEBIAN CONFIG PERSISTENCE ================= -->
         <section id="section-persistence" class="hidden space-y-6">
           <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
@@ -10168,7 +10268,105 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: ADD / EDIT VRRP INSTANCE ================= -->
+  <div id="modal-vrrp-instance" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div class="flex items-center gap-2">
+          <span class="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 tracking-wide uppercase">VRRP</span>
+          <h3 id="modal-vrrp-title" class="text-sm font-bold text-white">Add VRRP Instance</h3>
+        </div>
+        <button onclick="closeVRRPModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+
+      <form onsubmit="submitSaveVRRP(event)" class="p-6 space-y-4 text-xs font-sans">
+        <input type="hidden" id="modal-vrrp-id">
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Instance Name *</label>
+            <input type="text" id="modal-vrrp-name" placeholder="vrrp-lan" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Interface *</label>
+            <select id="modal-vrrp-iface" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+              <option value="">Select interface...</option>
+            </select>
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Virtual Router ID (VRID 1-255) *</label>
+            <input type="number" id="modal-vrrp-vrid" min="1" max="255" value="10" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+            <span class="text-[10px] text-slate-400">Must match on Master & Backup routers</span>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Priority (1-255) *</label>
+            <input type="number" id="modal-vrrp-priority" min="1" max="255" value="100" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+            <span class="text-[10px] text-slate-400">Higher priority wins Master (e.g. 200 vs 100)</span>
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Virtual IP Address (VIP) *</label>
+          <input type="text" id="modal-vrrp-vip" placeholder="192.168.88.254/24" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+          <span class="text-[10px] text-slate-400">Shared gateway IP address handed over during failover (CIDR)</span>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Protocol Version</label>
+            <select id="modal-vrrp-version" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+              <option value="3" selected>VRRPv3 (RFC 5798 - Recommended)</option>
+              <option value="2">VRRPv2 (RFC 3768 - Legacy)</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Advertisement Interval (sec)</label>
+            <input type="number" id="modal-vrrp-interval" min="1" max="255" value="1" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Authentication (Optional)</label>
+            <select id="modal-vrrp-authtype" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+              <option value="none" selected>None</option>
+              <option value="pass">Simple Password (PASS)</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Auth Password</label>
+            <input type="password" id="modal-vrrp-authpass" placeholder="Secret password" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="modal-vrrp-comment" placeholder="LAN Gateway High Availability" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-emerald-500">
+        </div>
+
+        <div class="flex items-center justify-between pt-2 border-t border-slate-800/80">
+          <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-vrrp-preempt" checked class="rounded bg-slate-800 border-slate-700 text-emerald-600">
+            <span>Preemption Mode (Master re-takes role when recovered)</span>
+          </label>
+          <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-vrrp-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-600">
+            <span>Disabled (X)</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeVRRPModal()" 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-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20">Save VRRP</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <datalist id="fw-addrlist-datalist"></datalist>
-  <script src="app.js?v=11.0"></script>
+  <script src="app.js?v=12.0"></script>
 </body>
 </html>