diff --git a/.vscode/settings.json b/.vscode/settings.json index ba3550bf..41aeee58 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,6 @@ ] }, "git.ignoreLimitWarning": true, - "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo" + "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo", + "cmake.ignoreCMakeListsMissing": true } \ No newline at end of file diff --git a/go.mod b/go.mod index 2e5600cf..5c391c62 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 // indirect github.com/mdlayher/packet v1.1.2 // indirect github.com/mdlayher/socket v0.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index 6eb6f99a..f3167b6d 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -76,6 +78,7 @@ github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= @@ -103,10 +106,14 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 h1:2oDp6OOhLxQ9JBoUuysVz9UZ9uI6oLUbvAZu0x8o+vE= +github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118/go.mod h1:ZFUnHIVchZ9lJoWoEGUg8Q3M4U8aNNWA3CVSUTkW4og= github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs= github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM= +github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU= github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= +github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E= github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -217,16 +224,21 @@ golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -242,6 +254,7 @@ golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/lldp/afpacket.go b/internal/lldp/afpacket.go index c3921372..c60fc7d1 100644 --- a/internal/lldp/afpacket.go +++ b/internal/lldp/afpacket.go @@ -16,9 +16,15 @@ const ( afPacketSnaplen = 9216 ) +// afpacketComputeSize computes the block_size and the num_blocks in such a way that the +// allocated mmap buffer is close to but smaller than targetSizeMb. +// The restriction is that the blockSize must be divisible by both the +// frameSize and pageSize. +// +// See also: https://github.com/google/gopacket/blob/master/examples/afpacket/afpacket.go#L118 func afPacketComputeSize( targetSizeMb int, - snaplen int, + snapLen int, pageSize int, ) ( frameSize int, @@ -26,10 +32,17 @@ func afPacketComputeSize( numBlocks int, err error, ) { - if snaplen < pageSize { - frameSize = pageSize / (pageSize / snaplen) + if snapLen < pageSize { + // When snapLen < pageSize, find the largest value <= pageSize that + // is a multiple of snapLen and divides evenly into pageSize. + // This ensures frameSize is a divisor of pageSize. + // Example: snapLen=512, pageSize=4096 -> frameSize=512 + // Example: snapLen=1000, pageSize=4096 -> frameSize=1024 + frameSize = pageSize / (pageSize / snapLen) } else { - frameSize = (snaplen/pageSize + 1) * pageSize + // When snapLen >= pageSize, round up to the next multiple of pageSize. + // Example: snapLen=9216, pageSize=4096 -> frameSize=12288 (3 pages) + frameSize = ((snapLen / pageSize) + 1) * pageSize } // 128 is the default from the gopacket library so just use that @@ -37,7 +50,7 @@ func afPacketComputeSize( numBlocks = (targetSizeMb * 1024 * 1024) / blockSize if numBlocks == 0 { - return 0, 0, 0, fmt.Errorf("interface buffersize is too small") + return 0, 0, 0, fmt.Errorf("interface bufferSize is too small") } return frameSize, blockSize, numBlocks, nil diff --git a/internal/lldp/lldp.go b/internal/lldp/lldp.go index 4e114df7..11f17dd9 100644 --- a/internal/lldp/lldp.go +++ b/internal/lldp/lldp.go @@ -1,6 +1,9 @@ package lldp import ( + "context" + "fmt" + "sync" "time" "github.com/google/gopacket" @@ -13,30 +16,50 @@ import ( var defaultLogger = logging.GetSubsystemLogger("lldp") type LLDP struct { - l *zerolog.Logger - tPacket *afpacket.TPacket - pktSource *gopacket.PacketSource + mu sync.RWMutex + + l *zerolog.Logger + tPacketRx *afpacket.TPacket + tPacketTx *afpacket.TPacket + pktSourceRx *gopacket.PacketSource enableRx bool enableTx bool - packets chan gopacket.Packet - interfaceName string - stop chan struct{} - onChange func(neighbors []Neighbor) + packets chan gopacket.Packet + interfaceName string + advertiseOptions *AdvertiseOptions + onChange func(neighbors []Neighbor) neighbors *ttlcache.Cache[string, Neighbor] + + // State tracking + rxRunning bool + txRunning bool + txCtx context.Context + txCancel context.CancelFunc + rxCtx context.Context + rxCancel context.CancelFunc } -type LLDPOptions struct { - InterfaceName string - EnableRx bool - EnableTx bool - OnChange func(neighbors []Neighbor) - Logger *zerolog.Logger +type AdvertiseOptions struct { + SysName string + SysDescription string + PortDescription string + SysCapabilities []string + EnabledCapabilities []string } -func NewLLDP(opts *LLDPOptions) *LLDP { +type Options struct { + InterfaceName string + AdvertiseOptions *AdvertiseOptions + EnableRx bool + EnableTx bool + OnChange func(neighbors []Neighbor) + Logger *zerolog.Logger +} + +func NewLLDP(opts *Options) *LLDP { if opts.Logger == nil { opts.Logger = defaultLogger } @@ -46,29 +69,77 @@ func NewLLDP(opts *LLDPOptions) *LLDP { } return &LLDP{ - interfaceName: opts.InterfaceName, - enableRx: opts.EnableRx, - enableTx: opts.EnableTx, - l: opts.Logger, - neighbors: ttlcache.New(ttlcache.WithTTL[string, Neighbor](1 * time.Hour)), + interfaceName: opts.InterfaceName, + advertiseOptions: opts.AdvertiseOptions, + enableRx: opts.EnableRx, + enableTx: opts.EnableTx, + l: opts.Logger, + neighbors: ttlcache.New(ttlcache.WithTTL[string, Neighbor](1 * time.Hour)), + onChange: opts.OnChange, } } func (l *LLDP) Start() error { - if l.enableRx { - l.l.Info().Msg("setting up AF_PACKET") - if err := l.setUpCapture(); err != nil { - l.l.Error().Err(err).Msg("unable to set up AF_PACKET") - return err - } + go l.neighbors.Start() - if err := l.startCapture(); err != nil { - l.l.Error().Err(err).Msg("unable to start capture") - return err + if l.enableRx { + if err := l.startRx(); err != nil { + return fmt.Errorf("failed to start RX: %w", err) } } - go l.neighbors.Start() + // Start TX if enabled + if l.enableTx { + if err := l.startTx(); err != nil { + return fmt.Errorf("failed to start TX: %w", err) + } + } + + return nil +} + +// StartRx starts the LLDP receiver if not already running +func (l *LLDP) startRx() error { + l.mu.Lock() + running := l.rxRunning + enabled := l.enableRx + l.mu.Unlock() + + if running || !enabled { + return nil + } + + if err := l.setUpCapture(); err != nil { + return fmt.Errorf("failed to set up capture: %w", err) + } + + return l.startCapture() +} + +// StopRx stops the LLDP receiver if running +func (l *LLDP) StopRx() error { + return l.stopCapture() +} + +// StopTx stops the LLDP transmitter if running +func (l *LLDP) StopTx() error { + return l.stopTx() +} + +// SetAdvertiseOptions updates the advertise options and resends LLDP packets if TX is running +func (l *LLDP) SetAdvertiseOptions(opts *AdvertiseOptions) error { + l.mu.Lock() + txRunning := l.txRunning + l.advertiseOptions = opts + l.mu.Unlock() + + if txRunning { + // Immediately resend with new options + if err := l.sendTxPackets(); err != nil { + return fmt.Errorf("failed to resend LLDP packet with new options: %w", err) + } + l.l.Info().Msg("advertise options changed, resent LLDP packet") + } return nil } diff --git a/internal/lldp/neigh.go b/internal/lldp/neigh.go index d73c6f6c..5291e1fa 100644 --- a/internal/lldp/neigh.go +++ b/internal/lldp/neigh.go @@ -1,27 +1,47 @@ package lldp -import "time" +import ( + "fmt" + "sort" + "strings" + "time" +) -type Neighbor struct { - Mac string `json:"mac"` - Source string `json:"source"` - ChassisID string `json:"chassis_id"` - PortID string `json:"port_id"` - PortDescription string `json:"port_description"` - SystemName string `json:"system_name"` - SystemDescription string `json:"system_description"` - TTL uint16 `json:"ttl"` - ManagementAddress string `json:"management_address"` - Values map[string]string `json:"values"` +type ManagementAddress struct { + AddressFamily string `json:"address_family"` + Address string `json:"address"` + InterfaceSubtype string `json:"interface_subtype"` + InterfaceNumber uint32 `json:"interface_number"` + OID string `json:"oid,omitempty"` } -func (l *LLDP) addNeighbor(mac string, neighbor Neighbor, ttl time.Duration) { +type Neighbor struct { + Mac string `json:"mac"` + Source string `json:"source"` + ChassisID string `json:"chassis_id"` + PortID string `json:"port_id"` + PortDescription string `json:"port_description"` + SystemName string `json:"system_name"` + SystemDescription string `json:"system_description"` + TTL uint16 `json:"ttl"` + ManagementAddress *ManagementAddress `json:"management_address,omitempty"` + Capabilities []string `json:"capabilities"` + Values map[string]string `json:"values"` +} + +func (n *Neighbor) cacheKey() string { + return fmt.Sprintf("%s-%s", n.Mac, n.Source) +} + +func (l *LLDP) addNeighbor(neighbor *Neighbor, ttl time.Duration) { logger := l.l.With(). - Str("mac", mac). + Str("mac", neighbor.Mac). Interface("neighbor", neighbor). Logger() - current_neigh := l.neighbors.Get(mac) + key := neighbor.cacheKey() + + current_neigh := l.neighbors.Get(key) if current_neigh != nil { current_source := current_neigh.Value().Source if current_source == "lldp" && neighbor.Source != "lldp" { @@ -31,16 +51,16 @@ func (l *LLDP) addNeighbor(mac string, neighbor Neighbor, ttl time.Duration) { } logger.Info().Msg("adding neighbor") - l.neighbors.Set(mac, neighbor, ttl) + l.neighbors.Set(key, *neighbor, ttl) } -func (l *LLDP) deleteNeighbor(mac string) { +func (l *LLDP) deleteNeighbor(neighbor *Neighbor) { logger := l.l.With(). - Str("mac", mac). + Str("mac", neighbor.Mac). Logger() logger.Info().Msg("deleting neighbor") - l.neighbors.Delete(mac) + l.neighbors.Delete(neighbor.cacheKey()) } func (l *LLDP) GetNeighbors() []Neighbor { @@ -51,5 +71,10 @@ func (l *LLDP) GetNeighbors() []Neighbor { neighbors = append(neighbors, item.Value()) } + // sort based on MAC address + sort.Slice(neighbors, func(i, j int) bool { + return strings.Compare(neighbors[i].Mac, neighbors[j].Mac) > 0 + }) + return neighbors } diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go index 6c51f28e..b822e832 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -1,6 +1,7 @@ package lldp import ( + "context" "fmt" "net" "time" @@ -27,12 +28,26 @@ var multicastAddrs = []string{ } func (l *LLDP) setUpCapture() error { + l.mu.Lock() + defer l.mu.Unlock() + + if l.tPacketRx != nil { + return nil + } + logger := l.l.With().Str("interface", l.interfaceName).Logger() - tPacket, err := afPacketNewTPacket(l.interfaceName) + tPacketRx, err := afPacketNewTPacket(l.interfaceName) if err != nil { return err } - logger.Info().Msg("created TPacket") + logger.Info().Msg("created TPacketRx") + + // Double-check: another goroutine might have set it up while we were creating + if l.tPacketRx != nil { + // Another goroutine already set it up, close our instance + tPacketRx.Close() + return nil + } // set up multicast addresses // otherwise the kernel might discard the packets @@ -40,52 +55,95 @@ func (l *LLDP) setUpCapture() error { for _, mac := range multicastAddrs { hwAddr, err := net.ParseMAC(mac) if err != nil { - logger.Error().Msgf("unable to parse MAC address %s: %s", mac, err) + logger.Error(). + Str("mac", mac). + MACAddr("hwaddr", hwAddr). + Err(err). + Msg("unable to parse MAC address") continue } if err := addMulticastAddr(l.interfaceName, hwAddr); err != nil { - logger.Error().Msgf("unable to add multicast address %s: %s", mac, err) + logger.Error(). + MACAddr("hwaddr", hwAddr). + Err(err). + Msg("unable to add multicast address") continue } logger.Info(). MACAddr("hwaddr", hwAddr). - Msgf("added multicast address") + Msg("added multicast address") } - if err = tPacket.SetBPF(bpfFilter); err != nil { - logger.Error().Msgf("unable to set BPF filter: %s", err) - tPacket.Close() + if err = tPacketRx.SetBPF(bpfFilter); err != nil { + logger.Error(). + Err(err). + Msg("unable to set BPF filter") + tPacketRx.Close() return err } logger.Info().Msg("BPF filter set") - l.pktSource = gopacket.NewPacketSource(tPacket, layers.LayerTypeEthernet) - l.tPacket = tPacket + l.pktSourceRx = gopacket.NewPacketSource(tPacketRx, layers.LayerTypeEthernet) + l.tPacketRx = tPacketRx return nil } +func (l *LLDP) doCapture(logger *zerolog.Logger, rxCtx context.Context) { + defer func() { + l.mu.Lock() + l.rxRunning = false + l.mu.Unlock() + }() + + packetChan := l.pktSourceRx.Packets() + for { + select { + case packet, ok := <-packetChan: + if !ok { + logger.Info().Msg("packet source closed") + return + } + if err := l.handlePacket(packet, logger); err != nil { + logger.Error(). + Err(err). + Msg("error handling packet") + } + case <-rxCtx.Done(): + logger.Info().Msg("LLDP receiver stopped") + return + } + } +} + func (l *LLDP) startCapture() error { - logger := l.l.With().Str("interface", l.interfaceName).Logger() - if l.tPacket == nil { + l.mu.Lock() + defer l.mu.Unlock() + + if l.rxRunning { + return nil // Already running + } + + if l.tPacketRx == nil { return fmt.Errorf("AFPacket not initialized") } - if l.pktSource == nil { + if l.pktSourceRx == nil { return fmt.Errorf("packet source not initialized") } - go func() { - logger.Info().Msg("starting capture LLDP ethernet frames") + logger := l.l.With().Str("interface", l.interfaceName).Logger() + logger.Info().Msg("starting capture LLDP ethernet frames") - for packet := range l.pktSource.Packets() { - if err := l.handlePacket(packet, &logger); err != nil { - logger.Error().Msgf("error handling packet: %s", err) - } - } - }() + // Create a new context for this instance + l.rxCtx, l.rxCancel = context.WithCancel(context.Background()) + l.rxRunning = true + + // Capture context in closure + rxCtx := l.rxCtx + go l.doCapture(&logger, rxCtx) return nil } @@ -108,7 +166,8 @@ func (l *LLDP) handlePacket(packet gopacket.Packet, logger *zerolog.Logger) erro lldpRaw := packet.Layer(layers.LayerTypeLinkLayerDiscovery) if lldpRaw != nil { - logger.Trace().Msgf("Found LLDP Frame") + logger.Trace().Msg("Found LLDP Frame") + l.l.Info().Hex("packet", packet.Data()).Msg("received packet") lldpInfo := packet.Layer(layers.LayerTypeLinkLayerDiscoveryInfo) if lldpInfo == nil { @@ -124,7 +183,7 @@ func (l *LLDP) handlePacket(packet gopacket.Packet, logger *zerolog.Logger) erro cdpRaw := packet.Layer(layers.LayerTypeCiscoDiscovery) if cdpRaw != nil { - logger.Trace().Msgf("Found CDP Frame") + logger.Trace().Msg("Found CDP Frame") cdpInfo := packet.Layer(layers.LayerTypeCiscoDiscoveryInfo) if cdpInfo == nil { @@ -141,6 +200,32 @@ func (l *LLDP) handlePacket(packet gopacket.Packet, logger *zerolog.Logger) erro return nil } +func capabilitiesToString(capabilities layers.LLDPCapabilities) []string { + capStr := []string{} + if capabilities.Other { + capStr = append(capStr, "other") + } + if capabilities.Repeater { + capStr = append(capStr, "repeater") + } + if capabilities.Bridge { + capStr = append(capStr, "bridge") + } + if capabilities.WLANAP { + capStr = append(capStr, "wlanap") + } + if capabilities.Router { + capStr = append(capStr, "router") + } + if capabilities.Phone { + capStr = append(capStr, "phone") + } + if capabilities.DocSis { + capStr = append(capStr, "docsis") + } + return capStr +} + func (l *LLDP) handlePacketLLDP(mac string, raw *layers.LinkLayerDiscovery, info *layers.LinkLayerDiscoveryInfo) error { n := &Neighbor{ Values: make(map[string]string), @@ -171,7 +256,15 @@ func (l *LLDP) handlePacketLLDP(mac string, raw *layers.LinkLayerDiscovery, info n.SystemDescription = info.SysDescription n.Values["system_description"] = n.SystemDescription case layers.LLDPTLVMgmtAddress: - // n.ManagementAddress = info.MgmtAddress.Address + n.ManagementAddress = &ManagementAddress{ + AddressFamily: info.MgmtAddress.Subtype.String(), + Address: net.IP(info.MgmtAddress.Address).String(), + InterfaceSubtype: info.MgmtAddress.InterfaceSubtype.String(), + InterfaceNumber: info.MgmtAddress.InterfaceNumber, + OID: info.MgmtAddress.OID, + } + case layers.LLDPTLVSysCapabilities: + n.Capabilities = capabilitiesToString(info.SysCapabilities.EnabledCap) case layers.LLDPTLVTTL: n.TTL = uint16(raw.TTL) ttl = time.Duration(n.TTL) * time.Second @@ -184,9 +277,9 @@ func (l *LLDP) handlePacketLLDP(mac string, raw *layers.LinkLayerDiscovery, info } if gotEnd || ttl < 1*time.Second { - l.deleteNeighbor(mac) + l.deleteNeighbor(n) } else { - l.addNeighbor(mac, *n, ttl) + l.addNeighbor(n, ttl) } return nil @@ -213,23 +306,61 @@ func (l *LLDP) handlePacketCDP(mac string, raw *layers.CiscoDiscovery, info *lay } if len(info.MgmtAddresses) > 0 { - n.ManagementAddress = string(info.MgmtAddresses[0]) + ip := info.MgmtAddresses[0] + ipFamily := "ipv4" + if ip.To4() == nil { + ipFamily = "ipv6" + } + + l.l.Info(). + Str("ip", ip.String()). + Str("ip_family", ipFamily). + Interface("ip", ip). + Interface("info", info). + Msg("parsed IP address") + + n.ManagementAddress = &ManagementAddress{ + AddressFamily: ipFamily, + Address: ip.String(), + InterfaceSubtype: "if_name", + InterfaceNumber: 0, + OID: "", + } } - l.addNeighbor(mac, *n, ttl) + l.addNeighbor(n, ttl) return nil } -func (l *LLDP) shutdownCapture() error { - if l.tPacket != nil { - l.tPacket.Close() - l.tPacket = nil +func (l *LLDP) stopCapture() error { + l.mu.Lock() + defer l.mu.Unlock() + + if !l.rxRunning { + return nil // Already stopped } - if l.pktSource != nil { - l.pktSource = nil + logger := l.l.With().Str("interface", l.interfaceName).Logger() + logger.Info().Msg("stopping LLDP receiver") + + // Cancel context to signal stop + rxCancel := l.rxCancel + if rxCancel != nil { + rxCancel() + l.rxCancel = nil } + if l.tPacketRx != nil { + l.tPacketRx.Close() + l.tPacketRx = nil + } + + if l.pktSourceRx != nil { + l.pktSourceRx = nil + } + + time.Sleep(100 * time.Millisecond) + return nil } diff --git a/internal/lldp/tx.go b/internal/lldp/tx.go new file mode 100644 index 00000000..745414ca --- /dev/null +++ b/internal/lldp/tx.go @@ -0,0 +1,270 @@ +package lldp + +import ( + "context" + "encoding/binary" + "fmt" + "net" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/rs/zerolog" +) + +var ( + lldpDstMac = net.HardwareAddr([]byte{0x01, 0x80, 0xc2, 0x00, 0x00, 0x0e}) + lldpEtherType = layers.EthernetTypeLinkLayerDiscovery +) + +// func encodeMandatoryTLV(subType byte, id []byte) []byte { +// // 1 byte: subtype +// // N bytes: ID +// b := make([]byte, 1+len(id)) +// b[0] = byte(subtype) +// copy(b[1:], id) + +// return b +// } + +// func (l *LLDP) createLLDPPayload() ([]byte, error) { +// tlv := &layers.LinkLayerDiscoveryValue{ +// Type: layers.LLDPTLVChassisID, + +// } + +func tlvStringValue(tlvType layers.LLDPTLVType, value string) layers.LinkLayerDiscoveryValue { + return layers.LinkLayerDiscoveryValue{ + Type: tlvType, + Value: []byte(value), + Length: uint16(len(value)), + } +} + +var ( + capabilityMap = map[string]uint16{ + "other": layers.LLDPCapsOther, + "repeater": layers.LLDPCapsRepeater, + "bridge": layers.LLDPCapsBridge, + "wlanap": layers.LLDPCapsWLANAP, + "router": layers.LLDPCapsRouter, + "phone": layers.LLDPCapsPhone, + "docsis": layers.LLDPCapsDocSis, + "station_only": layers.LLDPCapsStationOnly, + "cvlan": layers.LLDPCapsCVLAN, + "svlan": layers.LLDPCapsSVLAN, + "tmpr": layers.LLDPCapsTmpr, + } +) + +func toLLDPCapabilitiesBytes(capabilities []string) uint16 { + r := uint16(0) + for _, capability := range capabilities { + if _, ok := capabilityMap[capability]; !ok { + continue + } + r |= capabilityMap[capability] + } + return r +} + +func (l *LLDP) toPayloadValues() []layers.LinkLayerDiscoveryValue { + // See also: layers.LinkLayerDiscovery.SerializeTo() + r := []layers.LinkLayerDiscoveryValue{} + + l.mu.RLock() + opts := l.advertiseOptions + l.mu.RUnlock() + + if opts == nil { + return r + } + + if opts.SysName != "" { + r = append(r, tlvStringValue(layers.LLDPTLVSysName, opts.SysName)) + } + + if opts.SysDescription != "" { + r = append(r, tlvStringValue(layers.LLDPTLVSysDescription, opts.SysDescription)) + } + + if len(opts.SysCapabilities) > 0 { + value := make([]byte, 4) + binary.BigEndian.PutUint16(value[0:2], toLLDPCapabilitiesBytes(opts.SysCapabilities)) + binary.BigEndian.PutUint16(value[2:4], toLLDPCapabilitiesBytes(opts.EnabledCapabilities)) + + r = append(r, layers.LinkLayerDiscoveryValue{ + Type: layers.LLDPTLVSysCapabilities, + Value: value, + Length: 4, + }) + } + + // EndTLV will be added by the serializer, we don't need to add it here + return r +} + +func (l *LLDP) setUpTx() error { + l.mu.Lock() + defer l.mu.Unlock() + // Check if already set up (double-check pattern to prevent duplicate setup) + if l.tPacketTx != nil { + return nil + } + + logger := l.l.With().Str("interface", l.interfaceName).Logger() + tPacketTx, err := afPacketNewTPacket(l.interfaceName) + if err != nil { + return err + } + logger.Info().Msg("created TPacket instance for sending LLDP packets") + + l.tPacketTx = tPacketTx + + return nil +} + +func (l *LLDP) sendTxPackets() error { + l.mu.RLock() + defer l.mu.RUnlock() + + logger := l.l.With().Str("interface", l.interfaceName).Logger() + iface, err := net.InterfaceByName(l.interfaceName) + if err != nil { + return err + } + + if l.tPacketTx == nil { + return fmt.Errorf("AFPacket not initialized") + } + + // create payload + ethFrame := layers.Ethernet{ + EthernetType: lldpEtherType, + SrcMAC: iface.HardwareAddr, + DstMAC: lldpDstMac, + } + + lldpFrame := layers.LinkLayerDiscovery{ + ChassisID: layers.LLDPChassisID{ + Subtype: layers.LLDPChassisIDSubTypeMACAddr, + ID: []byte(iface.HardwareAddr), + }, + PortID: layers.LLDPPortID{ + Subtype: layers.LLDPPortIDSubtypeIfaceName, + ID: []byte(iface.Name), + }, + TTL: uint16(3600), + Values: l.toPayloadValues(), + } + + buf := gopacket.NewSerializeBuffer() + if err := gopacket.SerializeLayers(buf, gopacket.SerializeOptions{ + FixLengths: true, + ComputeChecksums: true, + }, ðFrame, &lldpFrame); err != nil { + l.l.Error().Err(err).Msg("unable to serialize packet") + return err + } + + logger.Trace().Hex("packet", buf.Bytes()).Msg("sending LLDP packet") + + // send packet + if err := l.tPacketTx.WritePacketData(buf.Bytes()); err != nil { + l.l.Error().Err(err).Msg("unable to send packet") + return err + } + + return nil +} + +const txInterval = 30 * time.Second // Standard LLDP transmission interval + +func (l *LLDP) doSendPeriodically(logger *zerolog.Logger, txCtx context.Context) { + l.mu.Lock() + l.txRunning = true + l.mu.Unlock() + + defer func() { + l.mu.Lock() + l.txRunning = false + l.mu.Unlock() + }() + + ticker := time.NewTicker(txInterval) + defer ticker.Stop() + + // Send initial packet immediately + if err := l.sendTxPackets(); err != nil { + logger.Error().Err(err).Msg("error sending initial LLDP packet") + } + + for { + select { + case <-ticker.C: + if err := l.sendTxPackets(); err != nil { + logger.Error().Err(err).Msg("error sending LLDP packet") + } + case <-txCtx.Done(): + logger.Info().Msg("LLDP transmitter stopped") + return + } + } +} + +func (l *LLDP) startTx() error { + l.mu.RLock() + running := l.txRunning + enabled := l.enableTx + cancel := l.txCancel + l.mu.RUnlock() + + if running || !enabled { + return nil + } + + if cancel != nil { + cancel() + } + + l.txCtx, l.txCancel = context.WithCancel(context.Background()) + + if err := l.setUpTx(); err != nil { + return fmt.Errorf("failed to set up TX: %w", err) + } + + logger := l.l.With().Str("interface", l.interfaceName).Logger() + logger.Info().Msg("starting LLDP transmitter") + + go l.doSendPeriodically(&logger, l.txCtx) + + return nil +} + +func (l *LLDP) stopTx() error { + l.mu.Lock() + if !l.txRunning { + l.mu.Unlock() + return nil // Already stopped + } + + logger := l.l.With().Str("interface", l.interfaceName).Logger() + logger.Info().Msg("stopping LLDP transmitter") + + // Cancel context to signal stop + txCancel := l.txCancel + l.txRunning = false + l.mu.Unlock() + + // Cancel context (goroutine will handle cleanup) + if txCancel != nil { + txCancel() + } + + // Wait a bit for goroutine to finish + // Note: In a production system, you might want to use sync.WaitGroup + // for proper synchronization, but for now this is acceptable + time.Sleep(100 * time.Millisecond) + + return nil +} diff --git a/network.go b/network.go index e1792042..ed2a555f 100644 --- a/network.go +++ b/network.go @@ -163,13 +163,22 @@ func initNetwork() error { networkManager = nm - lldpService = lldp.NewLLDP(&lldp.LLDPOptions{ - InterfaceName: NetIfName, - EnableRx: nc.LLDPMode.String != "disabled", - EnableTx: nc.LLDPMode.String != "disabled", + advertiseOptions := &lldp.AdvertiseOptions{ + SysName: networkManager.Hostname(), + SysDescription: toLLDPSysDescription(nc), + SysCapabilities: []string{"other", "router", "wlanap"}, + EnabledCapabilities: []string{"other"}, + } + + lldpService = lldp.NewLLDP(&lldp.Options{ + InterfaceName: NetIfName, + EnableRx: nc.LLDPMode.String != "disabled", + EnableTx: nc.LLDPMode.String != "disabled", + AdvertiseOptions: advertiseOptions, OnChange: func(neighbors []lldp.Neighbor) { writeJSONRPCEvent("lldpNeighbors", neighbors, currentSession) }, + Logger: networkLogger, }) if err := lldpService.Start(); err != nil { networkLogger.Error().Err(err).Msg("failed to start LLDP service") @@ -178,6 +187,15 @@ func initNetwork() error { return nil } +func toLLDPSysDescription(nc *types.NetworkConfig) string { + systemVersion, appVersion, err := GetLocalVersion() + if err == nil { + return fmt.Sprintf("JetKVM (app: %s)", GetBuiltAppVersion()) + } + + return fmt.Sprintf("JetKVM (app: %s, system: %s)", appVersion.String(), systemVersion.String()) +} + func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { if nm == nil { return nil diff --git a/ui/src/components/LLDPNeigh.tsx b/ui/src/components/LLDPNeighborsCard.tsx similarity index 94% rename from ui/src/components/LLDPNeigh.tsx rename to ui/src/components/LLDPNeighborsCard.tsx index 507c16f2..762d1e88 100644 --- a/ui/src/components/LLDPNeigh.tsx +++ b/ui/src/components/LLDPNeighborsCard.tsx @@ -21,7 +21,7 @@ const LLDPDataLine = ({ label, value, className }: LLDPDataLineProps) => { ); } -export default function LLDPNeighCard({ +export default function LLDPNeighborsCard({ neighbors, }: { neighbors: LLDPNeighbor[]; @@ -49,7 +49,7 @@ export default function LLDPNeighCard({ )} {neighbor.system_description && ( - + )} {neighbor.chassis_id && ( @@ -65,7 +65,7 @@ export default function LLDPNeighCard({ )} {neighbor.management_address && ( - + )} {neighbor.mac && ( diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 6a03e53c..b87d2f4e 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -783,6 +783,14 @@ export interface IPv6StaticConfig { dns: string[]; } +export interface LLDPManagementAddress { + address_family: string; + address: string; + interface_subtype: string; + interface_number: number; + oid: string; +} + export interface LLDPNeighbor { mac: string; source: string; @@ -791,8 +799,9 @@ export interface LLDPNeighbor { port_description: string; system_name: string; system_description: string; + capabilities: string[]; ttl: number | null; - management_address: string | null; + management_address: LLDPManagementAddress | null; values: Record; } diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index a3f3ab72..aea5fbbe 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -23,14 +23,14 @@ import StaticIpv4Card from "@components/StaticIpv4Card"; import StaticIpv6Card from "@components/StaticIpv6Card"; import { useCopyToClipboard } from "@components/useCopyToClipBoard"; import { netMaskFromCidr4 } from "@/utils/ip"; -import { callJsonRpc, getNetworkSettings, getNetworkState } from "@/utils/jsonrpc"; +import { getNetworkSettings, getNetworkState, getLLDPNeighbors } from "@/utils/jsonrpc"; import notifications from "@/notifications"; import { m } from "@localizations/messages"; -import LLDPNeighCard from "@components/LLDPNeigh"; +import LLDPNeighborsCard from "@components/LLDPNeighborsCard"; dayjs.extend(relativeTime); -const isLLDPAvailable = false; // LLDP is not supported yet +const isLLDPAvailable = true; // LLDP is now supported const resolveOnRtcReady = () => { return new Promise(resolve => { @@ -100,14 +100,10 @@ export default function SettingsNetworkRoute() { const [lldpNeighbors, setLldpNeighbors] = useState([]); const fetchLLDPNeighbors = useCallback(async () => { - send("getLLDPNeighbors", {}, (resp) => { - if ("error" in resp) { - // notifications.error(m.network_lldp_neighbors_fetch_failed({ error: neighbors.error.message || m.unknown_error() })); - } else { - setLldpNeighbors(resp.result as LLDPNeighbor[]); - } - }); - }, [setLldpNeighbors, send]); + const neighbors = await getLLDPNeighbors(); + setLldpNeighbors(neighbors); + }, [setLldpNeighbors]); + useEffect(() => { fetchLLDPNeighbors(); }, [fetchLLDPNeighbors]); @@ -475,11 +471,6 @@ export default function SettingsNetworkRoute() { /> -
- - - -
@@ -561,9 +552,10 @@ export default function SettingsNetworkRoute() {
- { isLLDPAvailable && - ( -
+ {isLLDPAvailable && + ( +
+
- ) + + + +
+ ) }
diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index 18659f00..848e8f7c 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -1,4 +1,4 @@ -import { useRTCStore } from "@/hooks/stores"; +import { LLDPNeighbor, useRTCStore } from "@/hooks/stores"; import { sleep } from "@/utils"; // JSON-RPC utility for use outside of React components @@ -170,6 +170,14 @@ export async function getNetworkState() { return response.result; } +export async function getLLDPNeighbors() { + const response = await callJsonRpc({ method: "getLLDPNeighbors" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + export async function renewDHCPLease() { const response = await callJsonRpc({ method: "renewDHCPLease" }); if (response.error) {