package vrrp import ( "encoding/json" "fmt" "net" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "time" "github.com/vishvananda/netlink" ) const ( VRRPStorePath = "/etc/debtik/vrrp.json" KeepalivedConfPath = "/etc/keepalived/keepalived.conf" VRRPStatusDir = "/run/debtik/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/debtik/vrrp echo "$STATE $(date -Iseconds)" > "/run/debtik/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 debtik\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 } // RenameInterface updates VRRP instance interface bindings when an interface is renamed. func (m *Manager) RenameInterface(oldName, newName string) { m.mu.Lock() defer m.mu.Unlock() changed := false for id, inst := range m.instances { if inst.Interface == oldName { inst.Interface = newName m.instances[id] = inst changed = true } } if changed { _ = m.saveLocked() _ = m.applyConfigLocked() } } 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 }