diff --git a/.gitignore b/.gitignore index 87e49d2..99c75db 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist x-* +go-alived diff --git a/README.md b/README.md index 1484bb1..cf8221e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A lightweight VRRP (Virtual Router Redundancy Protocol) implementation in Go, de - **VRRP Protocol**: RFC 3768/5798 compliant implementation - **High Availability**: Automatic failover with priority-based master election - **Health Checking**: TCP, HTTP/HTTPS, ICMP ping, and script-based checks +- **Unicast Support**: Direct peer communication for restricted network environments (macvlan, cloud) - **Easy Deployment**: Built-in install command with systemd/init.d support - **Hot Reload**: Configuration reload via SIGHUP without service restart - **Zero Dependencies**: Single static binary, no runtime dependencies @@ -145,6 +146,54 @@ vrrp_instances: - "192.168.1.100/24" # Must match ``` +### Unicast Configuration (for macvlan / cloud environments) + +In environments where multicast is not available (macvlan networks, public clouds, etc.), use unicast to send VRRP advertisements directly to peer IPs: + +**Node 1 (MASTER)**: +```yaml +global: + router_id: "node1" + +vrrp_instances: + - name: "VI_1" + interface: "eth0" + state: "MASTER" + virtual_router_id: 51 + priority: 100 + advert_interval: 1 + auth_type: "PASS" + auth_pass: "secret123" + virtual_ips: + - "192.168.1.100/24" + unicast_src_ip: "192.168.1.10" + unicast_peers: + - "192.168.1.11" +``` + +**Node 2 (BACKUP)**: +```yaml +global: + router_id: "node2" + +vrrp_instances: + - name: "VI_1" + interface: "eth0" + state: "BACKUP" + virtual_router_id: 51 + priority: 50 + advert_interval: 1 + auth_type: "PASS" + auth_pass: "secret123" + virtual_ips: + - "192.168.1.100/24" + unicast_src_ip: "192.168.1.11" + unicast_peers: + - "192.168.1.10" +``` + +See `etc/config.unicast.yaml` for a complete unicast example. + ### Health Checking ```yaml @@ -245,9 +294,17 @@ sudo kill -HUP $(pgrep go-alived) | VirtualBox | Full | Bridged network + promiscuous mode | | Docker | Limited | Requires `--privileged --net=host` | | OpenWrt/iStoreOS | Full | Use `--method service` for install | -| AWS/Aliyun/Azure | None | Multicast disabled | +| macvlan networks | Full | Use unicast mode | +| AWS/Aliyun/Azure/GCP | Limited | Use unicast mode | -> **Note**: VRRP requires multicast support (224.0.0.18). Most public clouds disable multicast at the network layer. Use cloud-native HA solutions instead. +> **Note**: VRRP defaults to multicast (224.0.0.18). In environments where multicast is disabled or unavailable (public clouds, macvlan networks), use **unicast mode** by configuring `unicast_src_ip` and `unicast_peers`. + +## Requirements + +- Go 1.21+ (for building) +- Linux/macOS with root privileges (for raw sockets and interface management) +- Network interface with IPv4 address +- Multicast support (default mode) **or** unicast configuration (for restricted environments) ## Troubleshooting @@ -266,7 +323,8 @@ sudo go-alived run -c /etc/go-alived/config.yaml **3. Both nodes become MASTER (split-brain)** - Check network connectivity between nodes - Verify `virtual_router_id` matches -- Ensure multicast traffic is allowed +- In multicast mode: ensure multicast traffic is allowed +- In unicast mode: verify `unicast_peers` and `unicast_src_ip` are correct **4. VIP not pingable after failover** - Gratuitous ARP may be blocked diff --git a/etc/config.unicast.yaml b/etc/config.unicast.yaml new file mode 100644 index 0000000..7d87e9a --- /dev/null +++ b/etc/config.unicast.yaml @@ -0,0 +1,31 @@ +global: + router_id: "node1" + +vrrp_instances: + # Example: VRRP with unicast peers for macvlan environments + # In macvlan networks, multicast between sub-interfaces of the same + # parent interface does not work. Use unicast to send VRRP advertisements + # directly to peer IP addresses. + - name: "VI_1" + interface: "eth0" + state: "BACKUP" + virtual_router_id: 51 + priority: 100 + advert_interval: 1 + auth_type: "PASS" + auth_pass: "secret123" + virtual_ips: + - "192.168.1.100/24" + + # Unicast source IP: the local IP address to use for sending VRRP packets. + # If omitted, the interface's primary IPv4 address is used automatically. + unicast_src_ip: "192.168.1.10" + + # Unicast peer list: IP addresses of the other VRRP nodes. + # When configured, VRRP advertisements are sent directly to these IPs + # instead of using multicast (224.0.0.18). + # On each node, list the OTHER nodes' IPs here (not your own). + unicast_peers: + - "192.168.1.11" + # Add more peers for setups with more than two nodes: + # - "192.168.1.12" diff --git a/internal/vrrp/instance.go b/internal/vrrp/instance.go index 3202406..aea0c65 100644 --- a/internal/vrrp/instance.go +++ b/internal/vrrp/instance.go @@ -21,6 +21,8 @@ type Instance struct { AuthType uint8 AuthPass string TrackScripts []string + UnicastSrcIP net.IP + UnicastPeers []net.IP state *StateMachine priorityCalc *PriorityCalculator @@ -54,6 +56,8 @@ func NewInstance( authType string, authPass string, trackScripts []string, + unicastSrcIP string, + unicastPeers []string, log *logger.Logger, ) (*Instance, error) { if vrID < 1 || vrID > 255 { @@ -90,6 +94,28 @@ func NewInstance( return nil, fmt.Errorf("failed to get interface: %w", err) } + var parsedSrcIP net.IP + var parsedPeers []net.IP + + if len(unicastPeers) > 0 { + parsedPeers = make([]net.IP, 0, len(unicastPeers)) + for _, peer := range unicastPeers { + ip := net.ParseIP(peer) + if ip == nil { + return nil, fmt.Errorf("invalid unicast peer IP: %s", peer) + } + parsedPeers = append(parsedPeers, ip.To4()) + } + + if unicastSrcIP != "" { + parsedSrcIP = net.ParseIP(unicastSrcIP) + if parsedSrcIP == nil { + return nil, fmt.Errorf("invalid unicast src IP: %s", unicastSrcIP) + } + parsedSrcIP = parsedSrcIP.To4() + } + } + inst := &Instance{ Name: name, VirtualRouterID: vrID, @@ -101,6 +127,8 @@ func NewInstance( AuthType: authTypeNum, AuthPass: authPass, TrackScripts: trackScripts, + UnicastSrcIP: parsedSrcIP, + UnicastPeers: parsedPeers, state: NewStateMachine(StateInit), priorityCalc: NewPriorityCalculator(priority), history: NewStateHistory(100), @@ -131,7 +159,15 @@ func (inst *Instance) Start() error { inst.mu.Unlock() var err error - inst.socket, err = NewSocket(inst.Interface) + if len(inst.UnicastPeers) > 0 { + if inst.UnicastSrcIP != nil { + inst.socket, err = NewUnicastSocket(inst.Interface, inst.UnicastSrcIP) + } else { + inst.socket, err = NewSocket(inst.Interface) + } + } else { + inst.socket, err = NewSocket(inst.Interface) + } if err != nil { return fmt.Errorf("failed to create socket: %w", err) } @@ -142,8 +178,13 @@ func (inst *Instance) Start() error { return fmt.Errorf("failed to create ARP sender: %w", err) } - inst.log.Info("[%s] starting VRRP instance (VRID=%d, Priority=%d, Interface=%s)", - inst.Name, inst.VirtualRouterID, inst.Priority, inst.Interface) + if len(inst.UnicastPeers) > 0 { + inst.log.Info("[%s] starting VRRP instance (VRID=%d, Priority=%d, Interface=%s, Mode=unicast, SrcIP=%s, Peers=%v)", + inst.Name, inst.VirtualRouterID, inst.Priority, inst.Interface, inst.socket.localIP, inst.UnicastPeers) + } else { + inst.log.Info("[%s] starting VRRP instance (VRID=%d, Priority=%d, Interface=%s, Mode=multicast)", + inst.Name, inst.VirtualRouterID, inst.Priority, inst.Interface) + } inst.state.SetState(StateBackup) inst.masterDownTimer.Start() @@ -213,6 +254,13 @@ func (inst *Instance) receiveLoop() { continue } + if len(inst.UnicastPeers) > 0 { + if !inst.isUnicastPeer(srcIP) { + inst.log.Debug("[%s] ignoring packet from non-peer %s", inst.Name, srcIP) + continue + } + } + if err := pkt.Validate(inst.AuthPass); err != nil { inst.log.Warn("[%s] packet validation failed: %v", inst.Name, err) continue @@ -222,6 +270,15 @@ func (inst *Instance) receiveLoop() { } } +func (inst *Instance) isUnicastPeer(ip net.IP) bool { + for _, peer := range inst.UnicastPeers { + if peer.Equal(ip) { + return true + } + } + return false +} + func (inst *Instance) handleAdvertisement(pkt *VRRPPacket, srcIP net.IP) { currentState := inst.state.GetState() localPriority := inst.priorityCalc.GetPriority() @@ -272,12 +329,22 @@ func (inst *Instance) sendAdvertisement() error { inst.AuthPass, ) - if err := inst.socket.Send(pkt); err != nil { - inst.log.Error("[%s] failed to send advertisement: %v", inst.Name, err) - return err + if len(inst.UnicastPeers) > 0 { + for _, peer := range inst.UnicastPeers { + if err := inst.socket.SendTo(pkt, peer); err != nil { + inst.log.Error("[%s] failed to send unicast advertisement to %s: %v", inst.Name, peer, err) + return err + } + } + inst.log.Debug("[%s] sent unicast advertisement (priority=%d, peers=%d)", inst.Name, priority, len(inst.UnicastPeers)) + } else { + if err := inst.socket.Send(pkt); err != nil { + inst.log.Error("[%s] failed to send advertisement: %v", inst.Name, err) + return err + } + inst.log.Debug("[%s] sent multicast advertisement (priority=%d)", inst.Name, priority) } - inst.log.Debug("[%s] sent advertisement (priority=%d)", inst.Name, priority) return nil } diff --git a/internal/vrrp/manager.go b/internal/vrrp/manager.go index f58010c..e85c779 100644 --- a/internal/vrrp/manager.go +++ b/internal/vrrp/manager.go @@ -36,6 +36,8 @@ func (m *Manager) LoadFromConfig(cfg *config.Config) error { vrrpCfg.AuthType, vrrpCfg.AuthPass, vrrpCfg.TrackScripts, + vrrpCfg.UnicastSrcIP, + vrrpCfg.UnicastPeers, m.log, ) if err != nil { diff --git a/internal/vrrp/socket.go b/internal/vrrp/socket.go index b1d9dd7..892f4bb 100644 --- a/internal/vrrp/socket.go +++ b/internal/vrrp/socket.go @@ -47,29 +47,11 @@ func NewSocket(ifaceName string) (*Socket, error) { return nil, fmt.Errorf("no IPv4 address found on interface %s", ifaceName) } - fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, VRRPProtocolNumber) + rawConn, packetConn, err := createRawConn() if err != nil { return nil, fmt.Errorf("failed to create raw socket: %w", err) } - if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil { - syscall.Close(fd) - return nil, fmt.Errorf("failed to set SO_REUSEADDR: %w", err) - } - - file := os.NewFile(uintptr(fd), "vrrp-socket") - packetConn, err := net.FilePacketConn(file) - file.Close() - if err != nil { - return nil, fmt.Errorf("failed to create packet connection: %w", err) - } - - rawConn, err := ipv4.NewRawConn(packetConn) - if err != nil { - packetConn.Close() - return nil, fmt.Errorf("failed to create raw connection: %w", err) - } - groupIP := net.ParseIP(VRRPMulticastAddr).To4() if groupIP == nil { rawConn.Close() @@ -95,6 +77,71 @@ func NewSocket(ifaceName string) (*Socket, error) { }, nil } +func NewUnicastSocket(ifaceName string, srcIP net.IP) (*Socket, error) { + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + return nil, fmt.Errorf("failed to get interface %s: %w", ifaceName, err) + } + + rawConn, packetConn, err := createRawConn() + if err != nil { + return nil, err + } + + // Bind to the specific interface and source IP so we receive unicast VRRP packets + bindAddr := &syscall.SockaddrInet4{} + copy(bindAddr.Addr[:], srcIP.To4()) + fd, _ := rawConn.SyscallConn() + var bindErr error + fd.Control(func(fd uintptr) { + bindErr = syscall.Bind(int(fd), bindAddr) + }) + if bindErr != nil { + rawConn.Close() + return nil, fmt.Errorf("failed to bind to %s: %w", srcIP, bindErr) + } + + if err := rawConn.SetControlMessage(ipv4.FlagTTL|ipv4.FlagSrc|ipv4.FlagDst|ipv4.FlagInterface, true); err != nil { + rawConn.Close() + return nil, fmt.Errorf("failed to set control message: %w", err) + } + + return &Socket{ + conn: rawConn, + packetConn: packetConn, + iface: iface, + localIP: srcIP, + groupIP: nil, + }, nil +} + +func createRawConn() (*ipv4.RawConn, net.PacketConn, error) { + fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, VRRPProtocolNumber) + if err != nil { + return nil, nil, fmt.Errorf("failed to create raw socket: %w", err) + } + + if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil { + syscall.Close(fd) + return nil, nil, fmt.Errorf("failed to set SO_REUSEADDR: %w", err) + } + + file := os.NewFile(uintptr(fd), "vrrp-socket") + packetConn, err := net.FilePacketConn(file) + file.Close() + if err != nil { + return nil, nil, fmt.Errorf("failed to create packet connection: %w", err) + } + + rawConn, err := ipv4.NewRawConn(packetConn) + if err != nil { + packetConn.Close() + return nil, nil, fmt.Errorf("failed to create raw connection: %w", err) + } + + return rawConn, packetConn, nil +} + func (s *Socket) Send(pkt *VRRPPacket) error { data, err := pkt.Marshal() if err != nil { @@ -119,6 +166,30 @@ func (s *Socket) Send(pkt *VRRPPacket) error { return nil } +func (s *Socket) SendTo(pkt *VRRPPacket, dst net.IP) error { + data, err := pkt.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal packet: %w", err) + } + + header := &ipv4.Header{ + Version: ipv4.Version, + Len: ipv4.HeaderLen, + TOS: 0xC0, + TotalLen: ipv4.HeaderLen + len(data), + TTL: 255, + Protocol: VRRPProtocolNumber, + Dst: dst, + Src: s.localIP, + } + + if err := s.conn.WriteTo(header, data, nil); err != nil { + return fmt.Errorf("failed to send packet to %s: %w", dst, err) + } + + return nil +} + func (s *Socket) Receive() (*VRRPPacket, net.IP, error) { buf := make([]byte, 1500) @@ -140,8 +211,10 @@ func (s *Socket) SetReadDeadline(t time.Time) error { } func (s *Socket) Close() error { - if err := s.conn.LeaveGroup(s.iface, &net.IPAddr{IP: s.groupIP}); err != nil { - return err + if s.groupIP != nil { + if err := s.conn.LeaveGroup(s.iface, &net.IPAddr{IP: s.groupIP}); err != nil { + return err + } } return s.conn.Close() -} \ No newline at end of file +} diff --git a/pkg/config/config.go b/pkg/config/config.go index e6c5059..d8ddb8e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net" "os" "time" @@ -33,6 +34,8 @@ type VRRPInstance struct { NotifyBackup string `yaml:"notify_backup"` NotifyFault string `yaml:"notify_fault"` TrackScripts []string `yaml:"track_scripts"` + UnicastSrcIP string `yaml:"unicast_src_ip"` + UnicastPeers []string `yaml:"unicast_peers"` } type HealthChecker struct { @@ -84,6 +87,20 @@ func validate(cfg *Config) error { if len(vrrp.VirtualIPs) == 0 { return fmt.Errorf("vrrp_instances[%d].virtual_ips cannot be empty", i) } + + if len(vrrp.UnicastPeers) > 0 { + for j, peer := range vrrp.UnicastPeers { + if ip := net.ParseIP(peer); ip == nil || ip.To4() == nil { + return fmt.Errorf("vrrp_instances[%d].unicast_peers[%d] is not a valid IPv4 address: %s", i, j, peer) + } + } + } + + if vrrp.UnicastSrcIP != "" { + if ip := net.ParseIP(vrrp.UnicastSrcIP); ip == nil || ip.To4() == nil { + return fmt.Errorf("vrrp_instances[%d].unicast_src_ip is not a valid IPv4 address: %s", i, vrrp.UnicastSrcIP) + } + } } return nil