feat: enhance AutoInterface with improved configuration and multicast handling

This commit is contained in:
2025-11-20 17:56:54 -06:00
parent 6c6953e664
commit 01e639133b
2 changed files with 370 additions and 93 deletions

View File

@@ -1,49 +1,106 @@
package interfaces package interfaces
import ( import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"log"
"net" "net"
"sync" "sync"
"time" "time"
"github.com/Sudo-Ivan/reticulum-go/pkg/common" "github.com/Sudo-Ivan/reticulum-go/pkg/common"
"github.com/Sudo-Ivan/reticulum-go/pkg/debug"
) )
const ( const (
HW_MTU = 1196
DEFAULT_DISCOVERY_PORT = 29716 DEFAULT_DISCOVERY_PORT = 29716
DEFAULT_DATA_PORT = 42671 DEFAULT_DATA_PORT = 42671
DEFAULT_GROUP_ID = "reticulum"
BITRATE_GUESS = 10 * 1000 * 1000 BITRATE_GUESS = 10 * 1000 * 1000
PEERING_TIMEOUT = 7500 * time.Millisecond PEERING_TIMEOUT = 22 * time.Second
SCOPE_LINK = "2" ANNOUNCE_INTERVAL = 1600 * time.Millisecond
SCOPE_ADMIN = "4" PEER_JOB_INTERVAL = 4 * time.Second
SCOPE_SITE = "5" MCAST_ECHO_TIMEOUT = 6500 * time.Millisecond
SCOPE_ORGANISATION = "8"
SCOPE_GLOBAL = "e" 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 { type AutoInterface struct {
BaseInterface BaseInterface
groupID []byte groupID []byte
discoveryPort int groupHash []byte
dataPort int discoveryPort int
discoveryScope string dataPort int
peers map[string]*Peer discoveryScope string
linkLocalAddrs []string multicastAddrType string
adoptedInterfaces map[string]string mcastDiscoveryAddr string
interfaceServers map[string]*net.UDPConn ifacNetname string
multicastEchoes map[string]time.Time peers map[string]*Peer
mutex sync.RWMutex linkLocalAddrs []string
outboundConn *net.UDPConn 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 { type Peer struct {
ifaceName string ifaceName string
lastHeard time.Time lastHeard time.Time
conn *net.UDPConn addr *net.UDPAddr
} }
func NewAutoInterface(name string, config *common.InterfaceConfig) (*AutoInterface, error) { 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{ ai := &AutoInterface{
BaseInterface: BaseInterface{ BaseInterface: BaseInterface{
Name: name, Name: name,
@@ -52,34 +109,66 @@ func NewAutoInterface(name string, config *common.InterfaceConfig) (*AutoInterfa
Online: false, Online: false,
Enabled: config.Enabled, Enabled: config.Enabled,
Detached: false, Detached: false,
IN: false, IN: true,
OUT: false, OUT: false,
MTU: common.DEFAULT_MTU, MTU: HW_MTU,
Bitrate: BITRATE_MINIMUM, Bitrate: BITRATE_GUESS,
}, },
discoveryPort: DEFAULT_DISCOVERY_PORT, groupID: []byte(groupID),
dataPort: DEFAULT_DATA_PORT, groupHash: groupHash[:],
discoveryScope: SCOPE_LINK, discoveryPort: discoveryPort,
peers: make(map[string]*Peer), dataPort: dataPort,
linkLocalAddrs: make([]string, 0), discoveryScope: discoveryScope,
adoptedInterfaces: make(map[string]string), multicastAddrType: multicastAddrType,
interfaceServers: make(map[string]*net.UDPConn), mcastDiscoveryAddr: mcastAddr,
multicastEchoes: make(map[string]time.Time), ifacNetname: ifacNetname,
} peers: make(map[string]*Peer),
linkLocalAddrs: make([]string, 0),
if config.Port != 0 { adoptedInterfaces: make(map[string]*AdoptedInterface),
ai.discoveryPort = config.Port interfaceServers: make(map[string]*net.UDPConn),
} discoveryServers: make(map[string]*net.UDPConn),
multicastEchoes: make(map[string]time.Time),
if config.GroupID != "" { timedOutInterfaces: make(map[string]time.Time),
ai.groupID = []byte(config.GroupID) allowedInterfaces: make([]string, 0),
} else { ignoredInterfaces: make([]string, 0),
ai.groupID = []byte("reticulum") 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 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 { func (ai *AutoInterface) Start() error {
interfaces, err := net.Interfaces() interfaces, err := net.Interfaces()
if err != nil { if err != nil {
@@ -87,8 +176,18 @@ func (ai *AutoInterface) Start() error {
} }
for _, iface := range interfaces { 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 { 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 continue
} }
} }
@@ -97,43 +196,97 @@ func (ai *AutoInterface) Start() error {
return fmt.Errorf("no suitable interfaces found") return fmt.Errorf("no suitable interfaces found")
} }
// Mark interface as online
ai.Online = true ai.Online = true
ai.Enabled = true ai.IN = true
ai.OUT = true
go ai.peerJobs() go ai.peerJobs()
go ai.announceLoop()
debug.Log(debug.DEBUG_INFO, "AutoInterface started", "adopted", len(ai.adoptedInterfaces))
return nil 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 { 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() addrs, err := iface.Addrs()
if err != nil { if err != nil {
return err return err
} }
var linkLocalAddr string
for _, addr := range addrs { for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.IsLinkLocalUnicast() { if ipnet, ok := addr.(*net.IPNet); ok {
ai.adoptedInterfaces[iface.Name] = ipnet.IP.String() if ipnet.IP.To4() == nil && ipnet.IP.IsLinkLocalUnicast() {
ai.multicastEchoes[iface.Name] = time.Now() linkLocalAddr = ipnet.IP.String()
break
if err := ai.startDiscoveryListener(iface); err != nil {
return err
} }
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 return nil
} }
func (ai *AutoInterface) startDiscoveryListener(iface *net.Interface) error { func (ai *AutoInterface) startDiscoveryListener(iface *net.Interface) error {
addr := &net.UDPAddr{ addr := &net.UDPAddr{
IP: net.ParseIP(fmt.Sprintf("ff%s%s::1", ai.discoveryScope, SCOPE_LINK)), IP: net.ParseIP(ai.mcastDiscoveryAddr),
Port: ai.discoveryPort, Port: ai.discoveryPort,
Zone: iface.Name, Zone: iface.Name,
} }
@@ -143,47 +296,79 @@ func (ai *AutoInterface) startDiscoveryListener(iface *net.Interface) error {
return err 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) go ai.handleDiscovery(conn, iface.Name)
debug.Log(debug.DEBUG_VERBOSE, "Discovery listener started", "interface", iface.Name, "addr", ai.mcastDiscoveryAddr)
return nil return nil
} }
func (ai *AutoInterface) startDataListener(iface *net.Interface) error { 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{ addr := &net.UDPAddr{
IP: net.IPv6zero, IP: net.ParseIP(adoptedIface.linkLocalAddr),
Port: ai.dataPort, Port: ai.dataPort,
Zone: iface.Name, Zone: iface.Name,
} }
conn, err := net.ListenUDP("udp6", addr) conn, err := net.ListenUDP("udp6", addr)
if err != nil { if err != nil {
debug.Log(debug.DEBUG_ERROR, "Failed to listen on data port", "addr", addr, "error", err)
return 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 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 return nil
} }
func (ai *AutoInterface) handleDiscovery(conn *net.UDPConn, ifaceName string) { func (ai *AutoInterface) handleDiscovery(conn *net.UDPConn, ifaceName string) {
buf := make([]byte, 1024) buf := make([]byte, 1024)
for { for {
_, remoteAddr, err := conn.ReadFromUDP(buf) n, remoteAddr, err := conn.ReadFromUDP(buf)
if err != nil { if err != nil {
log.Printf("Discovery read error: %v", err) if ai.IsOnline() {
continue 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()) buf := make([]byte, ai.GetMTU())
for { for {
n, _, err := conn.ReadFromUDP(buf) n, _, err := conn.ReadFromUDP(buf)
if err != nil { if err != nil {
if !ai.IsDetached() { if ai.IsOnline() {
log.Printf("Data read error: %v", err) debug.Log(debug.DEBUG_ERROR, "Data read error", "interface", ifaceName, "error", err)
} }
return return
} }
@@ -198,36 +383,98 @@ func (ai *AutoInterface) handlePeerAnnounce(addr *net.UDPAddr, ifaceName string)
ai.mutex.Lock() ai.mutex.Lock()
defer ai.mutex.Unlock() defer ai.mutex.Unlock()
peerAddr := addr.IP.String() peerIP := addr.IP.String()
for _, localAddr := range ai.linkLocalAddrs { for _, localAddr := range ai.linkLocalAddrs {
if peerAddr == localAddr { if peerIP == localAddr {
ai.multicastEchoes[ifaceName] = time.Now() ai.multicastEchoes[ifaceName] = time.Now()
debug.Log(debug.DEBUG_TRACE, "Received own multicast echo", "interface", ifaceName)
return return
} }
} }
if _, exists := ai.peers[peerAddr]; !exists { peerKey := peerIP + "%" + ifaceName
ai.peers[peerAddr] = &Peer{
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, ifaceName: ifaceName,
lastHeard: time.Now(), 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() { func (ai *AutoInterface) peerJobs() {
ticker := time.NewTicker(PEERING_TIMEOUT) ticker := time.NewTicker(ai.peerJobInterval)
defer ticker.Stop()
for range ticker.C { for range ticker.C {
if !ai.IsOnline() {
return
}
ai.mutex.Lock() ai.mutex.Lock()
now := time.Now() now := time.Now()
for addr, peer := range ai.peers { for peerKey, peer := range ai.peers {
if now.Sub(peer.lastHeard) > PEERING_TIMEOUT { if now.Sub(peer.lastHeard) > ai.peeringTimeout {
delete(ai.peers, addr) delete(ai.peers, peerKey)
log.Printf("Removed timed out peer %s", addr) 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 { func (ai *AutoInterface) Send(data []byte, address string) error {
if !ai.IsOnline() {
return fmt.Errorf("interface offline")
}
ai.mutex.RLock() ai.mutex.RLock()
defer ai.mutex.RUnlock() 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 { for _, peer := range ai.peers {
addr := &net.UDPAddr{ targetAddr := &net.UDPAddr{
IP: net.ParseIP(address), IP: peer.addr.IP,
Port: ai.dataPort, Port: ai.dataPort,
Zone: peer.ifaceName, Zone: peer.ifaceName,
} }
if ai.outboundConn == nil { if _, err := ai.outboundConn.WriteToUDP(data, targetAddr); err != nil {
var err error debug.Log(debug.DEBUG_VERBOSE, "Failed to send to peer", "interface", peer.ifaceName, "error", err)
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)
continue continue
} }
sentCount++
}
if sentCount > 0 {
debug.Log(debug.DEBUG_TRACE, "Sent data to peers", "count", sentCount, "bytes", len(data))
} }
return nil return nil
@@ -267,13 +529,22 @@ func (ai *AutoInterface) Stop() error {
ai.mutex.Lock() ai.mutex.Lock()
defer ai.mutex.Unlock() defer ai.mutex.Unlock()
ai.Online = false
ai.IN = false
ai.OUT = false
for _, server := range ai.interfaceServers { for _, server := range ai.interfaceServers {
server.Close() // #nosec G104 server.Close() // #nosec G104
} }
for _, server := range ai.discoveryServers {
server.Close() // #nosec G104
}
if ai.outboundConn != nil { if ai.outboundConn != nil {
ai.outboundConn.Close() // #nosec G104 ai.outboundConn.Close() // #nosec G104
} }
debug.Log(debug.DEBUG_INFO, "AutoInterface stopped")
return nil return nil
} }

View File

@@ -44,9 +44,10 @@ func TestNewAutoInterface(t *testing.T) {
t.Run("CustomConfig", func(t *testing.T) { t.Run("CustomConfig", func(t *testing.T) {
config := &common.InterfaceConfig{ config := &common.InterfaceConfig{
Enabled: true, Enabled: true,
Port: 12345, // Custom discovery port DiscoveryPort: 12345,
GroupID: "customGroup", DataPort: 54321,
GroupID: "customGroup",
} }
ai, err := NewAutoInterface("autoCustom", config) ai, err := NewAutoInterface("autoCustom", config)
if err != nil { if err != nil {
@@ -59,6 +60,9 @@ func TestNewAutoInterface(t *testing.T) {
if ai.discoveryPort != 12345 { if ai.discoveryPort != 12345 {
t.Errorf("discoveryPort = %d; want 12345", ai.discoveryPort) 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" { if string(ai.groupID) != "customGroup" {
t.Errorf("groupID = %s; want customGroup", string(ai.groupID)) 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() // Initialize maps that would normally be initialized in Start()
ai.peers = make(map[string]*Peer) ai.peers = make(map[string]*Peer)
ai.linkLocalAddrs = make([]string, 0) 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.interfaceServers = make(map[string]*net.UDPConn)
ai.discoveryServers = make(map[string]*net.UDPConn)
ai.multicastEchoes = make(map[string]time.Time) ai.multicastEchoes = make(map[string]time.Time)
ai.timedOutInterfaces = make(map[string]time.Time)
return &mockAutoInterface{AutoInterface: ai}, nil return &mockAutoInterface{AutoInterface: ai}, nil
} }