feat: add VRRP unicast peer support for macvlan and restricted network environments
Release / Build darwin-amd64 (push) Has been cancelled
Release / Build linux-amd64 (push) Has been cancelled
Release / Build darwin-arm64 (push) Has been cancelled
Release / Build linux-arm64 (push) Has been cancelled
Release / Create Release (push) Has been cancelled

- Add unicast_src_ip and unicast_peers config fields
- NewUnicastSocket binds to source IP for proper packet reception
- SendTo() sends VRRP advertisements directly to peer IPs
- Receive loop filters packets from non-peer sources in unicast mode
- Backward compatible: multicast mode used when unicast_peers is empty
- Add unicast config example (etc/config.unicast.yaml)
- Update README with unicast documentation and examples
This commit is contained in:
loveuer
2026-06-07 13:11:34 +08:00
parent 3fc3c860bc
commit 00eba85e39
7 changed files with 281 additions and 32 deletions
+1
View File
@@ -4,3 +4,4 @@
dist dist
x-* x-*
go-alived
+61 -3
View File
@@ -7,6 +7,7 @@ A lightweight VRRP (Virtual Router Redundancy Protocol) implementation in Go, de
- **VRRP Protocol**: RFC 3768/5798 compliant implementation - **VRRP Protocol**: RFC 3768/5798 compliant implementation
- **High Availability**: Automatic failover with priority-based master election - **High Availability**: Automatic failover with priority-based master election
- **Health Checking**: TCP, HTTP/HTTPS, ICMP ping, and script-based checks - **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 - **Easy Deployment**: Built-in install command with systemd/init.d support
- **Hot Reload**: Configuration reload via SIGHUP without service restart - **Hot Reload**: Configuration reload via SIGHUP without service restart
- **Zero Dependencies**: Single static binary, no runtime dependencies - **Zero Dependencies**: Single static binary, no runtime dependencies
@@ -145,6 +146,54 @@ vrrp_instances:
- "192.168.1.100/24" # Must match - "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 ### Health Checking
```yaml ```yaml
@@ -245,9 +294,17 @@ sudo kill -HUP $(pgrep go-alived)
| VirtualBox | Full | Bridged network + promiscuous mode | | VirtualBox | Full | Bridged network + promiscuous mode |
| Docker | Limited | Requires `--privileged --net=host` | | Docker | Limited | Requires `--privileged --net=host` |
| OpenWrt/iStoreOS | Full | Use `--method service` for install | | 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 ## Troubleshooting
@@ -266,7 +323,8 @@ sudo go-alived run -c /etc/go-alived/config.yaml
**3. Both nodes become MASTER (split-brain)** **3. Both nodes become MASTER (split-brain)**
- Check network connectivity between nodes - Check network connectivity between nodes
- Verify `virtual_router_id` matches - 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** **4. VIP not pingable after failover**
- Gratuitous ARP may be blocked - Gratuitous ARP may be blocked
+31
View File
@@ -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"
+74 -7
View File
@@ -21,6 +21,8 @@ type Instance struct {
AuthType uint8 AuthType uint8
AuthPass string AuthPass string
TrackScripts []string TrackScripts []string
UnicastSrcIP net.IP
UnicastPeers []net.IP
state *StateMachine state *StateMachine
priorityCalc *PriorityCalculator priorityCalc *PriorityCalculator
@@ -54,6 +56,8 @@ func NewInstance(
authType string, authType string,
authPass string, authPass string,
trackScripts []string, trackScripts []string,
unicastSrcIP string,
unicastPeers []string,
log *logger.Logger, log *logger.Logger,
) (*Instance, error) { ) (*Instance, error) {
if vrID < 1 || vrID > 255 { if vrID < 1 || vrID > 255 {
@@ -90,6 +94,28 @@ func NewInstance(
return nil, fmt.Errorf("failed to get interface: %w", err) 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{ inst := &Instance{
Name: name, Name: name,
VirtualRouterID: vrID, VirtualRouterID: vrID,
@@ -101,6 +127,8 @@ func NewInstance(
AuthType: authTypeNum, AuthType: authTypeNum,
AuthPass: authPass, AuthPass: authPass,
TrackScripts: trackScripts, TrackScripts: trackScripts,
UnicastSrcIP: parsedSrcIP,
UnicastPeers: parsedPeers,
state: NewStateMachine(StateInit), state: NewStateMachine(StateInit),
priorityCalc: NewPriorityCalculator(priority), priorityCalc: NewPriorityCalculator(priority),
history: NewStateHistory(100), history: NewStateHistory(100),
@@ -131,7 +159,15 @@ func (inst *Instance) Start() error {
inst.mu.Unlock() inst.mu.Unlock()
var err error 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 { if err != nil {
return fmt.Errorf("failed to create socket: %w", err) 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) return fmt.Errorf("failed to create ARP sender: %w", err)
} }
inst.log.Info("[%s] starting VRRP instance (VRID=%d, Priority=%d, Interface=%s)", if len(inst.UnicastPeers) > 0 {
inst.Name, inst.VirtualRouterID, inst.Priority, inst.Interface) 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.state.SetState(StateBackup)
inst.masterDownTimer.Start() inst.masterDownTimer.Start()
@@ -213,6 +254,13 @@ func (inst *Instance) receiveLoop() {
continue 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 { if err := pkt.Validate(inst.AuthPass); err != nil {
inst.log.Warn("[%s] packet validation failed: %v", inst.Name, err) inst.log.Warn("[%s] packet validation failed: %v", inst.Name, err)
continue 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) { func (inst *Instance) handleAdvertisement(pkt *VRRPPacket, srcIP net.IP) {
currentState := inst.state.GetState() currentState := inst.state.GetState()
localPriority := inst.priorityCalc.GetPriority() localPriority := inst.priorityCalc.GetPriority()
@@ -272,12 +329,22 @@ func (inst *Instance) sendAdvertisement() error {
inst.AuthPass, inst.AuthPass,
) )
if err := inst.socket.Send(pkt); err != nil { if len(inst.UnicastPeers) > 0 {
inst.log.Error("[%s] failed to send advertisement: %v", inst.Name, err) for _, peer := range inst.UnicastPeers {
return err 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 return nil
} }
+2
View File
@@ -36,6 +36,8 @@ func (m *Manager) LoadFromConfig(cfg *config.Config) error {
vrrpCfg.AuthType, vrrpCfg.AuthType,
vrrpCfg.AuthPass, vrrpCfg.AuthPass,
vrrpCfg.TrackScripts, vrrpCfg.TrackScripts,
vrrpCfg.UnicastSrcIP,
vrrpCfg.UnicastPeers,
m.log, m.log,
) )
if err != nil { if err != nil {
+95 -22
View File
@@ -47,29 +47,11 @@ func NewSocket(ifaceName string) (*Socket, error) {
return nil, fmt.Errorf("no IPv4 address found on interface %s", ifaceName) 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 { if err != nil {
return nil, fmt.Errorf("failed to create raw socket: %w", err) 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() groupIP := net.ParseIP(VRRPMulticastAddr).To4()
if groupIP == nil { if groupIP == nil {
rawConn.Close() rawConn.Close()
@@ -95,6 +77,71 @@ func NewSocket(ifaceName string) (*Socket, error) {
}, nil }, 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 { func (s *Socket) Send(pkt *VRRPPacket) error {
data, err := pkt.Marshal() data, err := pkt.Marshal()
if err != nil { if err != nil {
@@ -119,6 +166,30 @@ func (s *Socket) Send(pkt *VRRPPacket) error {
return nil 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) { func (s *Socket) Receive() (*VRRPPacket, net.IP, error) {
buf := make([]byte, 1500) buf := make([]byte, 1500)
@@ -140,8 +211,10 @@ func (s *Socket) SetReadDeadline(t time.Time) error {
} }
func (s *Socket) Close() error { func (s *Socket) Close() error {
if err := s.conn.LeaveGroup(s.iface, &net.IPAddr{IP: s.groupIP}); err != nil { if s.groupIP != nil {
return err if err := s.conn.LeaveGroup(s.iface, &net.IPAddr{IP: s.groupIP}); err != nil {
return err
}
} }
return s.conn.Close() return s.conn.Close()
} }
+17
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"net"
"os" "os"
"time" "time"
@@ -33,6 +34,8 @@ type VRRPInstance struct {
NotifyBackup string `yaml:"notify_backup"` NotifyBackup string `yaml:"notify_backup"`
NotifyFault string `yaml:"notify_fault"` NotifyFault string `yaml:"notify_fault"`
TrackScripts []string `yaml:"track_scripts"` TrackScripts []string `yaml:"track_scripts"`
UnicastSrcIP string `yaml:"unicast_src_ip"`
UnicastPeers []string `yaml:"unicast_peers"`
} }
type HealthChecker struct { type HealthChecker struct {
@@ -84,6 +87,20 @@ func validate(cfg *Config) error {
if len(vrrp.VirtualIPs) == 0 { if len(vrrp.VirtualIPs) == 0 {
return fmt.Errorf("vrrp_instances[%d].virtual_ips cannot be empty", i) 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 return nil