From 01e639133b5ce39d07cc5d81e21cf0e946b6499d Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 20 Nov 2025 17:56:54 -0600 Subject: [PATCH] feat: enhance AutoInterface with improved configuration and multicast handling --- pkg/interfaces/auto.go | 449 +++++++++++++++++++++++++++++------- pkg/interfaces/auto_test.go | 14 +- 2 files changed, 370 insertions(+), 93 deletions(-) diff --git a/pkg/interfaces/auto.go b/pkg/interfaces/auto.go index 53166dc..2eddcf1 100644 --- a/pkg/interfaces/auto.go +++ b/pkg/interfaces/auto.go @@ -1,49 +1,106 @@ package interfaces import ( + "bytes" + "crypto/sha256" + "encoding/hex" "fmt" - "log" "net" "sync" "time" "github.com/Sudo-Ivan/reticulum-go/pkg/common" + "github.com/Sudo-Ivan/reticulum-go/pkg/debug" ) const ( + HW_MTU = 1196 DEFAULT_DISCOVERY_PORT = 29716 DEFAULT_DATA_PORT = 42671 + DEFAULT_GROUP_ID = "reticulum" BITRATE_GUESS = 10 * 1000 * 1000 - PEERING_TIMEOUT = 7500 * time.Millisecond - SCOPE_LINK = "2" - SCOPE_ADMIN = "4" - SCOPE_SITE = "5" - SCOPE_ORGANISATION = "8" - SCOPE_GLOBAL = "e" + PEERING_TIMEOUT = 22 * time.Second + ANNOUNCE_INTERVAL = 1600 * time.Millisecond + PEER_JOB_INTERVAL = 4 * time.Second + MCAST_ECHO_TIMEOUT = 6500 * time.Millisecond + + SCOPE_LINK = "2" + SCOPE_ADMIN = "4" + SCOPE_SITE = "5" + SCOPE_ORGANISATION = "8" + SCOPE_GLOBAL = "e" + + MCAST_ADDR_TYPE_PERMANENT = "0" + MCAST_ADDR_TYPE_TEMPORARY = "1" ) type AutoInterface struct { BaseInterface - groupID []byte - discoveryPort int - dataPort int - discoveryScope string - peers map[string]*Peer - linkLocalAddrs []string - adoptedInterfaces map[string]string - interfaceServers map[string]*net.UDPConn - multicastEchoes map[string]time.Time - mutex sync.RWMutex - outboundConn *net.UDPConn + groupID []byte + groupHash []byte + discoveryPort int + dataPort int + discoveryScope string + multicastAddrType string + mcastDiscoveryAddr string + ifacNetname string + peers map[string]*Peer + linkLocalAddrs []string + adoptedInterfaces map[string]*AdoptedInterface + interfaceServers map[string]*net.UDPConn + discoveryServers map[string]*net.UDPConn + multicastEchoes map[string]time.Time + timedOutInterfaces map[string]time.Time + allowedInterfaces []string + ignoredInterfaces []string + mutex sync.RWMutex + outboundConn *net.UDPConn + announceInterval time.Duration + peerJobInterval time.Duration + peeringTimeout time.Duration + mcastEchoTimeout time.Duration +} + +type AdoptedInterface struct { + name string + linkLocalAddr string + index int } type Peer struct { ifaceName string lastHeard time.Time - conn *net.UDPConn + addr *net.UDPAddr } func NewAutoInterface(name string, config *common.InterfaceConfig) (*AutoInterface, error) { + groupID := DEFAULT_GROUP_ID + if config.GroupID != "" { + groupID = config.GroupID + } + + discoveryScope := SCOPE_LINK + if config.DiscoveryScope != "" { + discoveryScope = normalizeScope(config.DiscoveryScope) + } + + multicastAddrType := MCAST_ADDR_TYPE_TEMPORARY + + discoveryPort := DEFAULT_DISCOVERY_PORT + if config.DiscoveryPort != 0 { + discoveryPort = config.DiscoveryPort + } + + dataPort := DEFAULT_DATA_PORT + if config.DataPort != 0 { + dataPort = config.DataPort + } + + groupHash := sha256.Sum256([]byte(groupID)) + + ifacNetname := hex.EncodeToString(groupHash[:])[:16] + mcastAddr := fmt.Sprintf("ff%s%s::%s", discoveryScope, multicastAddrType, ifacNetname) + ai := &AutoInterface{ BaseInterface: BaseInterface{ Name: name, @@ -52,34 +109,66 @@ func NewAutoInterface(name string, config *common.InterfaceConfig) (*AutoInterfa Online: false, Enabled: config.Enabled, Detached: false, - IN: false, + IN: true, OUT: false, - MTU: common.DEFAULT_MTU, - Bitrate: BITRATE_MINIMUM, + MTU: HW_MTU, + Bitrate: BITRATE_GUESS, }, - discoveryPort: DEFAULT_DISCOVERY_PORT, - dataPort: DEFAULT_DATA_PORT, - discoveryScope: SCOPE_LINK, - peers: make(map[string]*Peer), - linkLocalAddrs: make([]string, 0), - adoptedInterfaces: make(map[string]string), - interfaceServers: make(map[string]*net.UDPConn), - multicastEchoes: make(map[string]time.Time), - } - - if config.Port != 0 { - ai.discoveryPort = config.Port - } - - if config.GroupID != "" { - ai.groupID = []byte(config.GroupID) - } else { - ai.groupID = []byte("reticulum") + groupID: []byte(groupID), + groupHash: groupHash[:], + discoveryPort: discoveryPort, + dataPort: dataPort, + discoveryScope: discoveryScope, + multicastAddrType: multicastAddrType, + mcastDiscoveryAddr: mcastAddr, + ifacNetname: ifacNetname, + peers: make(map[string]*Peer), + linkLocalAddrs: make([]string, 0), + adoptedInterfaces: make(map[string]*AdoptedInterface), + interfaceServers: make(map[string]*net.UDPConn), + discoveryServers: make(map[string]*net.UDPConn), + multicastEchoes: make(map[string]time.Time), + timedOutInterfaces: make(map[string]time.Time), + allowedInterfaces: make([]string, 0), + ignoredInterfaces: make([]string, 0), + announceInterval: ANNOUNCE_INTERVAL, + peerJobInterval: PEER_JOB_INTERVAL, + peeringTimeout: PEERING_TIMEOUT, + mcastEchoTimeout: MCAST_ECHO_TIMEOUT, } + debug.Log(debug.DEBUG_INFO, "AutoInterface configured", "name", name, "group", groupID, "mcast_addr", mcastAddr) return ai, nil } +func normalizeScope(scope string) string { + switch scope { + case "link", "2": + return SCOPE_LINK + case "admin", "4": + return SCOPE_ADMIN + case "site", "5": + return SCOPE_SITE + case "organisation", "organization", "8": + return SCOPE_ORGANISATION + case "global", "e": + return SCOPE_GLOBAL + default: + return SCOPE_LINK + } +} + +func normalizeMulticastType(mtype string) string { + switch mtype { + case "permanent", "0": + return MCAST_ADDR_TYPE_PERMANENT + case "temporary", "1": + return MCAST_ADDR_TYPE_TEMPORARY + default: + return MCAST_ADDR_TYPE_TEMPORARY + } +} + func (ai *AutoInterface) Start() error { interfaces, err := net.Interfaces() if err != nil { @@ -87,8 +176,18 @@ func (ai *AutoInterface) Start() error { } for _, iface := range interfaces { + if ai.shouldIgnoreInterface(iface.Name) { + debug.Log(debug.DEBUG_TRACE, "Ignoring interface", "name", iface.Name) + continue + } + + if len(ai.allowedInterfaces) > 0 && !ai.isAllowedInterface(iface.Name) { + debug.Log(debug.DEBUG_TRACE, "Interface not in allowed list", "name", iface.Name) + continue + } + if err := ai.configureInterface(&iface); err != nil { - log.Printf("Failed to configure interface %s: %v", iface.Name, err) + debug.Log(debug.DEBUG_VERBOSE, "Failed to configure interface", "name", iface.Name, "error", err) continue } } @@ -97,43 +196,97 @@ func (ai *AutoInterface) Start() error { return fmt.Errorf("no suitable interfaces found") } - // Mark interface as online ai.Online = true - ai.Enabled = true + ai.IN = true + ai.OUT = true go ai.peerJobs() + go ai.announceLoop() + + debug.Log(debug.DEBUG_INFO, "AutoInterface started", "adopted", len(ai.adoptedInterfaces)) return nil } +func (ai *AutoInterface) shouldIgnoreInterface(name string) bool { + ignoreList := []string{"lo", "lo0", "tun0", "awdl0", "llw0", "en5", "dummy0"} + + for _, ignored := range ai.ignoredInterfaces { + if name == ignored { + return true + } + } + + for _, ignored := range ignoreList { + if name == ignored { + return true + } + } + + return false +} + +func (ai *AutoInterface) isAllowedInterface(name string) bool { + for _, allowed := range ai.allowedInterfaces { + if name == allowed { + return true + } + } + return false +} + func (ai *AutoInterface) configureInterface(iface *net.Interface) error { + if iface.Flags&net.FlagUp == 0 { + return fmt.Errorf("interface is down") + } + + if iface.Flags&net.FlagLoopback != 0 { + return fmt.Errorf("loopback interface") + } + addrs, err := iface.Addrs() if err != nil { return err } + var linkLocalAddr string for _, addr := range addrs { - if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.IsLinkLocalUnicast() { - ai.adoptedInterfaces[iface.Name] = ipnet.IP.String() - ai.multicastEchoes[iface.Name] = time.Now() - - if err := ai.startDiscoveryListener(iface); err != nil { - return err + if ipnet, ok := addr.(*net.IPNet); ok { + if ipnet.IP.To4() == nil && ipnet.IP.IsLinkLocalUnicast() { + linkLocalAddr = ipnet.IP.String() + break } - - if err := ai.startDataListener(iface); err != nil { - return err - } - - break } } + if linkLocalAddr == "" { + return fmt.Errorf("no link-local IPv6 address found") + } + + ai.mutex.Lock() + ai.adoptedInterfaces[iface.Name] = &AdoptedInterface{ + name: iface.Name, + linkLocalAddr: linkLocalAddr, + index: iface.Index, + } + ai.linkLocalAddrs = append(ai.linkLocalAddrs, linkLocalAddr) + ai.multicastEchoes[iface.Name] = time.Now() + ai.mutex.Unlock() + + if err := ai.startDiscoveryListener(iface); err != nil { + return fmt.Errorf("failed to start discovery listener: %v", err) + } + + if err := ai.startDataListener(iface); err != nil { + return fmt.Errorf("failed to start data listener: %v", err) + } + + debug.Log(debug.DEBUG_INFO, "Configured interface", "name", iface.Name, "addr", linkLocalAddr) return nil } func (ai *AutoInterface) startDiscoveryListener(iface *net.Interface) error { addr := &net.UDPAddr{ - IP: net.ParseIP(fmt.Sprintf("ff%s%s::1", ai.discoveryScope, SCOPE_LINK)), + IP: net.ParseIP(ai.mcastDiscoveryAddr), Port: ai.discoveryPort, Zone: iface.Name, } @@ -143,47 +296,79 @@ func (ai *AutoInterface) startDiscoveryListener(iface *net.Interface) error { return err } + if err := conn.SetReadBuffer(1024); err != nil { + debug.Log(debug.DEBUG_ERROR, "Failed to set discovery read buffer", "error", err) + } + + ai.mutex.Lock() + ai.discoveryServers[iface.Name] = conn + ai.mutex.Unlock() + go ai.handleDiscovery(conn, iface.Name) + debug.Log(debug.DEBUG_VERBOSE, "Discovery listener started", "interface", iface.Name, "addr", ai.mcastDiscoveryAddr) return nil } func (ai *AutoInterface) startDataListener(iface *net.Interface) error { + adoptedIface, exists := ai.adoptedInterfaces[iface.Name] + if !exists { + return fmt.Errorf("interface not adopted") + } + addr := &net.UDPAddr{ - IP: net.IPv6zero, + IP: net.ParseIP(adoptedIface.linkLocalAddr), Port: ai.dataPort, Zone: iface.Name, } conn, err := net.ListenUDP("udp6", addr) if err != nil { + debug.Log(debug.DEBUG_ERROR, "Failed to listen on data port", "addr", addr, "error", err) return err } + if err := conn.SetReadBuffer(ai.MTU); err != nil { + debug.Log(debug.DEBUG_ERROR, "Failed to set data read buffer", "error", err) + } + + ai.mutex.Lock() ai.interfaceServers[iface.Name] = conn - go ai.handleData(conn) + ai.mutex.Unlock() + + go ai.handleData(conn, iface.Name) + debug.Log(debug.DEBUG_VERBOSE, "Data listener started", "interface", iface.Name, "addr", addr) return nil } func (ai *AutoInterface) handleDiscovery(conn *net.UDPConn, ifaceName string) { buf := make([]byte, 1024) for { - _, remoteAddr, err := conn.ReadFromUDP(buf) + n, remoteAddr, err := conn.ReadFromUDP(buf) if err != nil { - log.Printf("Discovery read error: %v", err) - continue + if ai.IsOnline() { + debug.Log(debug.DEBUG_ERROR, "Discovery read error", "interface", ifaceName, "error", err) + } + return } - ai.handlePeerAnnounce(remoteAddr, ifaceName) + if n >= len(ai.groupHash) { + receivedHash := buf[:len(ai.groupHash)] + if bytes.Equal(receivedHash, ai.groupHash) { + ai.handlePeerAnnounce(remoteAddr, ifaceName) + } else { + debug.Log(debug.DEBUG_TRACE, "Received discovery with mismatched group hash", "interface", ifaceName) + } + } } } -func (ai *AutoInterface) handleData(conn *net.UDPConn) { +func (ai *AutoInterface) handleData(conn *net.UDPConn, ifaceName string) { buf := make([]byte, ai.GetMTU()) for { n, _, err := conn.ReadFromUDP(buf) if err != nil { - if !ai.IsDetached() { - log.Printf("Data read error: %v", err) + if ai.IsOnline() { + debug.Log(debug.DEBUG_ERROR, "Data read error", "interface", ifaceName, "error", err) } return } @@ -198,36 +383,98 @@ func (ai *AutoInterface) handlePeerAnnounce(addr *net.UDPAddr, ifaceName string) ai.mutex.Lock() defer ai.mutex.Unlock() - peerAddr := addr.IP.String() + peerIP := addr.IP.String() for _, localAddr := range ai.linkLocalAddrs { - if peerAddr == localAddr { + if peerIP == localAddr { ai.multicastEchoes[ifaceName] = time.Now() + debug.Log(debug.DEBUG_TRACE, "Received own multicast echo", "interface", ifaceName) return } } - if _, exists := ai.peers[peerAddr]; !exists { - ai.peers[peerAddr] = &Peer{ + peerKey := peerIP + "%" + ifaceName + + if peer, exists := ai.peers[peerKey]; exists { + peer.lastHeard = time.Now() + debug.Log(debug.DEBUG_TRACE, "Updated peer", "peer", peerIP, "interface", ifaceName) + } else { + ai.peers[peerKey] = &Peer{ ifaceName: ifaceName, lastHeard: time.Now(), + addr: addr, + } + debug.Log(debug.DEBUG_INFO, "Discovered new peer", "peer", peerIP, "interface", ifaceName) + } +} + +func (ai *AutoInterface) announceLoop() { + ticker := time.NewTicker(ai.announceInterval) + defer ticker.Stop() + + for range ticker.C { + if !ai.IsOnline() { + return + } + ai.sendPeerAnnounce() + } +} + +func (ai *AutoInterface) sendPeerAnnounce() { + ai.mutex.RLock() + defer ai.mutex.RUnlock() + + for ifaceName, adoptedIface := range ai.adoptedInterfaces { + mcastAddr := &net.UDPAddr{ + IP: net.ParseIP(ai.mcastDiscoveryAddr), + Port: ai.discoveryPort, + Zone: ifaceName, + } + + if ai.outboundConn == nil { + var err error + ai.outboundConn, err = net.ListenUDP("udp6", &net.UDPAddr{Port: 0}) + if err != nil { + debug.Log(debug.DEBUG_ERROR, "Failed to create outbound socket", "error", err) + return + } + } + + if _, err := ai.outboundConn.WriteToUDP(ai.groupHash, mcastAddr); err != nil { + debug.Log(debug.DEBUG_VERBOSE, "Failed to send peer announce", "interface", ifaceName, "error", err) + } else { + debug.Log(debug.DEBUG_TRACE, "Sent peer announce", "interface", adoptedIface.name) } - log.Printf("Added peer %s on %s", peerAddr, ifaceName) - } else { - ai.peers[peerAddr].lastHeard = time.Now() } } func (ai *AutoInterface) peerJobs() { - ticker := time.NewTicker(PEERING_TIMEOUT) + ticker := time.NewTicker(ai.peerJobInterval) + defer ticker.Stop() + for range ticker.C { + if !ai.IsOnline() { + return + } + ai.mutex.Lock() now := time.Now() - for addr, peer := range ai.peers { - if now.Sub(peer.lastHeard) > PEERING_TIMEOUT { - delete(ai.peers, addr) - log.Printf("Removed timed out peer %s", addr) + for peerKey, peer := range ai.peers { + if now.Sub(peer.lastHeard) > ai.peeringTimeout { + delete(ai.peers, peerKey) + debug.Log(debug.DEBUG_VERBOSE, "Removed timed out peer", "peer", peerKey) + } + } + + for ifaceName, echoTime := range ai.multicastEchoes { + if now.Sub(echoTime) > ai.mcastEchoTimeout { + if _, exists := ai.timedOutInterfaces[ifaceName]; !exists { + debug.Log(debug.DEBUG_INFO, "Interface timed out", "interface", ifaceName) + ai.timedOutInterfaces[ifaceName] = now + } + } else { + delete(ai.timedOutInterfaces, ifaceName) } } @@ -236,28 +483,43 @@ func (ai *AutoInterface) peerJobs() { } func (ai *AutoInterface) Send(data []byte, address string) error { + if !ai.IsOnline() { + return fmt.Errorf("interface offline") + } + ai.mutex.RLock() defer ai.mutex.RUnlock() + if len(ai.peers) == 0 { + debug.Log(debug.DEBUG_TRACE, "No peers available for sending") + return nil + } + + if ai.outboundConn == nil { + var err error + ai.outboundConn, err = net.ListenUDP("udp6", &net.UDPAddr{Port: 0}) + if err != nil { + return fmt.Errorf("failed to create outbound socket: %v", err) + } + } + + sentCount := 0 for _, peer := range ai.peers { - addr := &net.UDPAddr{ - IP: net.ParseIP(address), + targetAddr := &net.UDPAddr{ + IP: peer.addr.IP, Port: ai.dataPort, Zone: peer.ifaceName, } - if ai.outboundConn == nil { - var err error - ai.outboundConn, err = net.ListenUDP("udp6", &net.UDPAddr{Port: 0}) - if err != nil { - return err - } - } - - if _, err := ai.outboundConn.WriteToUDP(data, addr); err != nil { - log.Printf("Failed to send to peer %s: %v", address, err) + if _, err := ai.outboundConn.WriteToUDP(data, targetAddr); err != nil { + debug.Log(debug.DEBUG_VERBOSE, "Failed to send to peer", "interface", peer.ifaceName, "error", err) continue } + sentCount++ + } + + if sentCount > 0 { + debug.Log(debug.DEBUG_TRACE, "Sent data to peers", "count", sentCount, "bytes", len(data)) } return nil @@ -267,13 +529,22 @@ func (ai *AutoInterface) Stop() error { ai.mutex.Lock() defer ai.mutex.Unlock() + ai.Online = false + ai.IN = false + ai.OUT = false + for _, server := range ai.interfaceServers { server.Close() // #nosec G104 } + for _, server := range ai.discoveryServers { + server.Close() // #nosec G104 + } + if ai.outboundConn != nil { ai.outboundConn.Close() // #nosec G104 } + debug.Log(debug.DEBUG_INFO, "AutoInterface stopped") return nil } diff --git a/pkg/interfaces/auto_test.go b/pkg/interfaces/auto_test.go index 5a2ecf8..216916c 100644 --- a/pkg/interfaces/auto_test.go +++ b/pkg/interfaces/auto_test.go @@ -44,9 +44,10 @@ func TestNewAutoInterface(t *testing.T) { t.Run("CustomConfig", func(t *testing.T) { config := &common.InterfaceConfig{ - Enabled: true, - Port: 12345, // Custom discovery port - GroupID: "customGroup", + Enabled: true, + DiscoveryPort: 12345, + DataPort: 54321, + GroupID: "customGroup", } ai, err := NewAutoInterface("autoCustom", config) if err != nil { @@ -59,6 +60,9 @@ func TestNewAutoInterface(t *testing.T) { if ai.discoveryPort != 12345 { t.Errorf("discoveryPort = %d; want 12345", ai.discoveryPort) } + if ai.dataPort != 54321 { + t.Errorf("dataPort = %d; want 54321", ai.dataPort) + } if string(ai.groupID) != "customGroup" { t.Errorf("groupID = %s; want customGroup", string(ai.groupID)) } @@ -79,9 +83,11 @@ func newMockAutoInterface(name string, config *common.InterfaceConfig) (*mockAut // Initialize maps that would normally be initialized in Start() ai.peers = make(map[string]*Peer) ai.linkLocalAddrs = make([]string, 0) - ai.adoptedInterfaces = make(map[string]string) + ai.adoptedInterfaces = make(map[string]*AdoptedInterface) ai.interfaceServers = make(map[string]*net.UDPConn) + ai.discoveryServers = make(map[string]*net.UDPConn) ai.multicastEchoes = make(map[string]time.Time) + ai.timedOutInterfaces = make(map[string]time.Time) return &mockAutoInterface{AutoInterface: ai}, nil }