diff --git a/.golangci.yml b/.golangci.yml index dd8a0794..efbc5391 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,6 +29,9 @@ linters: - linters: - gochecknoinits path: internal/logging/sse.go + - linters: + - govet + path: internal/lldp/bpf.go paths: - third_party$ - builtin$ diff --git a/go.mod b/go.mod index 404215b0..2e5600cf 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,8 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/google/gopacket v1.1.19 // indirect + github.com/jellydator/ttlcache/v3 v3.4.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/native v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 1cb90138..6eb6f99a 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x 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= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ= @@ -70,6 +72,8 @@ github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8 github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e h1:nu5z6Kg+gMNW6tdqnVjg/QEJ8Nw71IJQqOtWj00XHEU= github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +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.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= @@ -206,14 +210,23 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +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-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.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-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= @@ -224,8 +237,11 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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= 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 new file mode 100644 index 00000000..c3921372 --- /dev/null +++ b/internal/lldp/afpacket.go @@ -0,0 +1,96 @@ +package lldp + +import ( + "fmt" + "net" + "os" + "syscall" + "unsafe" + + "github.com/google/gopacket/afpacket" + "golang.org/x/sys/unix" +) + +const ( + afPacketBufferSize = 2 // in MiB + afPacketSnaplen = 9216 +) + +func afPacketComputeSize( + targetSizeMb int, + snaplen int, + pageSize int, +) ( + frameSize int, + blockSize int, + numBlocks int, + err error, +) { + if snaplen < pageSize { + frameSize = pageSize / (pageSize / snaplen) + } else { + frameSize = (snaplen/pageSize + 1) * pageSize + } + + // 128 is the default from the gopacket library so just use that + blockSize = frameSize * 128 + numBlocks = (targetSizeMb * 1024 * 1024) / blockSize + + if numBlocks == 0 { + return 0, 0, 0, fmt.Errorf("interface buffersize is too small") + } + + return frameSize, blockSize, numBlocks, nil +} + +func afPacketNewTPacket(ifName string) (*afpacket.TPacket, error) { + szFrame, szBlock, numBlocks, err := afPacketComputeSize( + afPacketBufferSize, + afPacketSnaplen, + os.Getpagesize(), + ) + if err != nil { + return nil, err + } + + return afpacket.NewTPacket( + afpacket.OptInterface(ifName), + afpacket.OptFrameSize(szFrame), + afpacket.OptBlockSize(szBlock), + afpacket.OptNumBlocks(numBlocks), + afpacket.OptAddVLANHeader(false), + afpacket.SocketRaw, + afpacket.TPacketVersion3, + ) +} + +type ifreq struct { + ifrName [IFNAMSIZ]byte + ifrHwaddr syscall.RawSockaddr +} + +func addMulticastAddr(ifName string, addr net.HardwareAddr) error { + fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, 0) + if err != nil { + return err + } + defer syscall.Close(fd) + + var name [IFNAMSIZ]byte + copy(name[:], []byte(ifName)) + + ifr := &ifreq{ + ifrName: name, + ifrHwaddr: toRawSockaddr(addr), + } + + _, _, ep := unix.Syscall( + unix.SYS_IOCTL, uintptr(fd), + unix.SIOCADDMULTI, uintptr(unsafe.Pointer(ifr)), + ) + + if ep != 0 { + return syscall.Errno(ep) + } + return nil +} diff --git a/internal/lldp/afpacket_arm.go b/internal/lldp/afpacket_arm.go new file mode 100644 index 00000000..470d571c --- /dev/null +++ b/internal/lldp/afpacket_arm.go @@ -0,0 +1,15 @@ +//go:build arm && linux + +package lldp + +import ( + "net" + "syscall" +) + +func toRawSockaddr(mac net.HardwareAddr) (sockaddr syscall.RawSockaddr) { + for i, n := range mac { + sockaddr.Data[i] = uint8(n) + } + return +} diff --git a/internal/lldp/afpacket_nonarm.go b/internal/lldp/afpacket_nonarm.go new file mode 100644 index 00000000..2f11c823 --- /dev/null +++ b/internal/lldp/afpacket_nonarm.go @@ -0,0 +1,15 @@ +//go:build !arm && linux + +package lldp + +import ( + "net" + "syscall" +) + +func toRawSockaddr(mac net.HardwareAddr) (sockaddr syscall.RawSockaddr) { + for i, n := range mac { + sockaddr.Data[i] = int8(n) + } + return +} diff --git a/internal/lldp/bpf.go b/internal/lldp/bpf.go new file mode 100644 index 00000000..a1027f3a --- /dev/null +++ b/internal/lldp/bpf.go @@ -0,0 +1,21 @@ +package lldp + +import "golang.org/x/net/bpf" + +// from lldpd +// https://github.com/lldpd/lldpd/blob/9034c9332cca0c8b1a20e1287f0e5fed81f7eb2a/src/daemon/lldpd.h#L246 +var bpfFilter = []bpf.RawInstruction{ + {0x30, 0, 0, 0x00000000}, {0x54, 0, 0, 0x00000001}, {0x15, 0, 16, 0x00000001}, + {0x28, 0, 0, 0x0000000c}, {0x15, 0, 6, 0x000088cc}, + {0x20, 0, 0, 0x00000002}, {0x15, 2, 0, 0xc200000e}, + {0x15, 1, 0, 0xc2000003}, {0x15, 0, 2, 0xc2000000}, + {0x28, 0, 0, 0x00000000}, {0x15, 12, 13, 0x00000180}, + {0x20, 0, 0, 0x00000002}, {0x15, 0, 2, 0x52cccccc}, + {0x28, 0, 0, 0x00000000}, {0x15, 8, 9, 0x000001e0}, + {0x15, 1, 0, 0x0ccccccc}, {0x15, 0, 2, 0x81000100}, + {0x28, 0, 0, 0x00000000}, {0x15, 4, 5, 0x00000100}, + {0x20, 0, 0, 0x00000002}, {0x15, 0, 3, 0x2b000000}, + {0x28, 0, 0, 0x00000000}, {0x15, 0, 1, 0x000000e0}, + {0x6, 0, 0, 0x00040000}, + {0x6, 0, 0, 0x00000000}, +} diff --git a/internal/lldp/lldp.go b/internal/lldp/lldp.go new file mode 100644 index 00000000..4e114df7 --- /dev/null +++ b/internal/lldp/lldp.go @@ -0,0 +1,74 @@ +package lldp + +import ( + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/afpacket" + "github.com/jellydator/ttlcache/v3" + "github.com/jetkvm/kvm/internal/logging" + "github.com/rs/zerolog" +) + +var defaultLogger = logging.GetSubsystemLogger("lldp") + +type LLDP struct { + l *zerolog.Logger + tPacket *afpacket.TPacket + pktSource *gopacket.PacketSource + + enableRx bool + enableTx bool + + packets chan gopacket.Packet + interfaceName string + stop chan struct{} + onChange func(neighbors []Neighbor) + + neighbors *ttlcache.Cache[string, Neighbor] +} + +type LLDPOptions struct { + InterfaceName string + EnableRx bool + EnableTx bool + OnChange func(neighbors []Neighbor) + Logger *zerolog.Logger +} + +func NewLLDP(opts *LLDPOptions) *LLDP { + if opts.Logger == nil { + opts.Logger = defaultLogger + } + + if opts.InterfaceName == "" { + opts.Logger.Fatal().Msg("InterfaceName is required") + } + + return &LLDP{ + interfaceName: opts.InterfaceName, + enableRx: opts.EnableRx, + enableTx: opts.EnableTx, + l: opts.Logger, + neighbors: ttlcache.New(ttlcache.WithTTL[string, Neighbor](1 * time.Hour)), + } +} + +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 + } + + if err := l.startCapture(); err != nil { + l.l.Error().Err(err).Msg("unable to start capture") + return err + } + } + + go l.neighbors.Start() + + return nil +} diff --git a/internal/lldp/neigh.go b/internal/lldp/neigh.go new file mode 100644 index 00000000..d73c6f6c --- /dev/null +++ b/internal/lldp/neigh.go @@ -0,0 +1,55 @@ +package lldp + +import "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"` +} + +func (l *LLDP) addNeighbor(mac string, neighbor Neighbor, ttl time.Duration) { + logger := l.l.With(). + Str("mac", mac). + Interface("neighbor", neighbor). + Logger() + + current_neigh := l.neighbors.Get(mac) + if current_neigh != nil { + current_source := current_neigh.Value().Source + if current_source == "lldp" && neighbor.Source != "lldp" { + logger.Info().Msg("skip updating neighbor, as LLDP has higher priority") + return + } + } + + logger.Info().Msg("adding neighbor") + l.neighbors.Set(mac, neighbor, ttl) +} + +func (l *LLDP) deleteNeighbor(mac string) { + logger := l.l.With(). + Str("mac", mac). + Logger() + + logger.Info().Msg("deleting neighbor") + l.neighbors.Delete(mac) +} + +func (l *LLDP) GetNeighbors() []Neighbor { + items := l.neighbors.Items() + neighbors := make([]Neighbor, 0, len(items)) + + for _, item := range items { + neighbors = append(neighbors, item.Value()) + } + + return neighbors +} diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go new file mode 100644 index 00000000..6c51f28e --- /dev/null +++ b/internal/lldp/rx.go @@ -0,0 +1,235 @@ +package lldp + +import ( + "fmt" + "net" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/rs/zerolog" +) + +const IFNAMSIZ = 16 + +var ( + lldpDefaultTTL = 120 * time.Second + cdpDefaultTTL = 180 * time.Second +) + +var multicastAddrs = []string{ + // LLDP + "01:80:C2:00:00:00", + "01:80:C2:00:00:03", + "01:80:C2:00:00:0E", + // CDP + "01:00:0C:CC:CC:CC", +} + +func (l *LLDP) setUpCapture() error { + logger := l.l.With().Str("interface", l.interfaceName).Logger() + tPacket, err := afPacketNewTPacket(l.interfaceName) + if err != nil { + return err + } + logger.Info().Msg("created TPacket") + + // set up multicast addresses + // otherwise the kernel might discard the packets + // another workaround would be to enable promiscuous mode but that's too tricky + for _, mac := range multicastAddrs { + hwAddr, err := net.ParseMAC(mac) + if err != nil { + logger.Error().Msgf("unable to parse MAC address %s: %s", mac, err) + continue + } + + if err := addMulticastAddr(l.interfaceName, hwAddr); err != nil { + logger.Error().Msgf("unable to add multicast address %s: %s", mac, err) + continue + } + + logger.Info(). + MACAddr("hwaddr", hwAddr). + Msgf("added multicast address") + } + + if err = tPacket.SetBPF(bpfFilter); err != nil { + logger.Error().Msgf("unable to set BPF filter: %s", err) + tPacket.Close() + return err + } + logger.Info().Msg("BPF filter set") + + l.pktSource = gopacket.NewPacketSource(tPacket, layers.LayerTypeEthernet) + l.tPacket = tPacket + + return nil +} + +func (l *LLDP) startCapture() error { + logger := l.l.With().Str("interface", l.interfaceName).Logger() + if l.tPacket == nil { + return fmt.Errorf("AFPacket not initialized") + } + + if l.pktSource == nil { + return fmt.Errorf("packet source not initialized") + } + + go func() { + 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) + } + } + }() + + return nil +} + +func (l *LLDP) handlePacket(packet gopacket.Packet, logger *zerolog.Logger) error { + linkLayer := packet.LinkLayer() + if linkLayer == nil { + return fmt.Errorf("no link layer") + } + + srcMac := linkLayer.LinkFlow().Src().String() + dstMac := linkLayer.LinkFlow().Dst().String() + + logger.Trace(). + Str("src_mac", srcMac). + Str("dst_mac", dstMac). + Int("length", len(packet.Data())). + Hex("data", packet.Data()). + Msg("received packet") + + lldpRaw := packet.Layer(layers.LayerTypeLinkLayerDiscovery) + if lldpRaw != nil { + logger.Trace().Msgf("Found LLDP Frame") + + lldpInfo := packet.Layer(layers.LayerTypeLinkLayerDiscoveryInfo) + if lldpInfo == nil { + return fmt.Errorf("no LLDP info layer") + } + + return l.handlePacketLLDP( + srcMac, + lldpRaw.(*layers.LinkLayerDiscovery), + lldpInfo.(*layers.LinkLayerDiscoveryInfo), + ) + } + + cdpRaw := packet.Layer(layers.LayerTypeCiscoDiscovery) + if cdpRaw != nil { + logger.Trace().Msgf("Found CDP Frame") + + cdpInfo := packet.Layer(layers.LayerTypeCiscoDiscoveryInfo) + if cdpInfo == nil { + return fmt.Errorf("no CDP info layer") + } + + return l.handlePacketCDP( + srcMac, + cdpRaw.(*layers.CiscoDiscovery), + cdpInfo.(*layers.CiscoDiscoveryInfo), + ) + } + + return nil +} + +func (l *LLDP) handlePacketLLDP(mac string, raw *layers.LinkLayerDiscovery, info *layers.LinkLayerDiscoveryInfo) error { + n := &Neighbor{ + Values: make(map[string]string), + Source: "lldp", + Mac: mac, + } + gotEnd := false + + ttl := lldpDefaultTTL + + for _, v := range raw.Values { + switch v.Type { + case layers.LLDPTLVEnd: + gotEnd = true + case layers.LLDPTLVChassisID: + n.ChassisID = string(raw.ChassisID.ID) + n.Values["chassis_id"] = n.ChassisID + case layers.LLDPTLVPortID: + n.PortID = string(raw.PortID.ID) + n.Values["port_id"] = n.PortID + case layers.LLDPTLVPortDescription: + n.PortDescription = info.PortDescription + n.Values["port_description"] = n.PortDescription + case layers.LLDPTLVSysName: + n.SystemName = info.SysName + n.Values["system_name"] = n.SystemName + case layers.LLDPTLVSysDescription: + n.SystemDescription = info.SysDescription + n.Values["system_description"] = n.SystemDescription + case layers.LLDPTLVMgmtAddress: + // n.ManagementAddress = info.MgmtAddress.Address + case layers.LLDPTLVTTL: + n.TTL = uint16(raw.TTL) + ttl = time.Duration(n.TTL) * time.Second + n.Values["ttl"] = fmt.Sprintf("%d", n.TTL) + case layers.LLDPTLVOrgSpecific: + for _, org := range info.OrgTLVs { + n.Values[fmt.Sprintf("org_specific_%d", org.OUI)] = string(org.Info) + } + } + } + + if gotEnd || ttl < 1*time.Second { + l.deleteNeighbor(mac) + } else { + l.addNeighbor(mac, *n, ttl) + } + + return nil +} + +func (l *LLDP) handlePacketCDP(mac string, raw *layers.CiscoDiscovery, info *layers.CiscoDiscoveryInfo) error { + // TODO: implement full CDP parsing + n := &Neighbor{ + Values: make(map[string]string), + Source: "cdp", + Mac: mac, + } + + ttl := cdpDefaultTTL + + n.ChassisID = info.DeviceID + n.PortID = info.PortID + n.SystemName = info.SysName + n.SystemDescription = info.Platform + n.TTL = uint16(raw.TTL) + + if n.TTL > 1 { + ttl = time.Duration(n.TTL) * time.Second + } + + if len(info.MgmtAddresses) > 0 { + n.ManagementAddress = string(info.MgmtAddresses[0]) + } + + l.addNeighbor(mac, *n, ttl) + + return nil +} + +func (l *LLDP) shutdownCapture() error { + if l.tPacket != nil { + l.tPacket.Close() + l.tPacket = nil + } + + if l.pktSource != nil { + l.pktSource = nil + } + + return nil +} diff --git a/jsonrpc.go b/jsonrpc.go index 5ed90a7a..f6073b55 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1248,4 +1248,5 @@ var rpcHandlers = map[string]RPCHandler{ "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, + "getLLDPNeighbors": {Func: rpcGetLLDPNeighbors}, } diff --git a/network.go b/network.go index 846f41f1..e1792042 100644 --- a/network.go +++ b/network.go @@ -6,6 +6,7 @@ import ( "reflect" "github.com/jetkvm/kvm/internal/confparser" + "github.com/jetkvm/kvm/internal/lldp" "github.com/jetkvm/kvm/internal/mdns" "github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/pkg/nmlite" @@ -17,6 +18,7 @@ const ( var ( networkManager *nmlite.NetworkManager + lldpService *lldp.LLDP ) type RpcNetworkSettings struct { @@ -161,6 +163,18 @@ func initNetwork() error { networkManager = nm + lldpService = lldp.NewLLDP(&lldp.LLDPOptions{ + InterfaceName: NetIfName, + EnableRx: nc.LLDPMode.String != "disabled", + EnableTx: nc.LLDPMode.String != "disabled", + OnChange: func(neighbors []lldp.Neighbor) { + writeJSONRPCEvent("lldpNeighbors", neighbors, currentSession) + }, + }) + if err := lldpService.Start(); err != nil { + networkLogger.Error().Err(err).Msg("failed to start LLDP service") + } + return nil } @@ -312,3 +326,10 @@ func rpcToggleDHCPClient() error { return rpcReboot(true) } + +func rpcGetLLDPNeighbors() []lldp.Neighbor { + if lldpService == nil { + return []lldp.Neighbor{} + } + return lldpService.GetNeighbors() +} diff --git a/ui/src/components/LLDPNeigh.tsx b/ui/src/components/LLDPNeigh.tsx new file mode 100644 index 00000000..507c16f2 --- /dev/null +++ b/ui/src/components/LLDPNeigh.tsx @@ -0,0 +1,87 @@ +import { cx } from "@/cva.config"; + +import { LLDPNeighbor } from "../hooks/stores"; + +import { GridCard } from "./Card"; + + +interface LLDPDataLineProps { + label: string; + value: string; + className?: string; +} +const LLDPDataLine = ({ label, value, className }: LLDPDataLineProps) => { + return ( +
+ + {label} + + {value} +
+ ); +} + +export default function LLDPNeighCard({ + neighbors, +}: { + neighbors: LLDPNeighbor[]; +}) { + return ( + +
+
+

+ LLDP Neighbors +

+ +
+ {neighbors.map(neighbor => { + const displayName = neighbor.system_name || neighbor.port_description || neighbor.mac; + return
+

{displayName}

+
+
+ + {neighbor.system_name && ( + + )} + + {neighbor.system_description && ( + + )} + + {neighbor.chassis_id && ( + + )} + + {neighbor.port_id && ( + + )} + + {neighbor.port_description && ( + + )} + + {neighbor.management_address && ( + + )} + + {neighbor.mac && ( + + )} + + {neighbor.source && ( + + )} +
+
+
+ })} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 06f29582..6a03e53c 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -761,7 +761,7 @@ export type IPv6Mode = | "link_local" | "unknown"; export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown"; -export type LLDPMode = "disabled" | "basic" | "all" | "unknown"; +export type LLDPMode = "disabled" | "basic" | "all" | "tx_only" | "rx_only" | "unknown"; export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown"; export type TimeSyncMode = | "ntp_only" @@ -783,6 +783,19 @@ export interface IPv6StaticConfig { dns: string[]; } +export interface LLDPNeighbor { + mac: string; + source: string; + chassis_id: string; + port_id: string; + port_description: string; + system_name: string; + system_description: string; + ttl: number | null; + management_address: string | null; + values: Record; +} + export interface NetworkSettings { dhcp_client: string; hostname: string | null; diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 20958764..a3f3ab72 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -6,7 +6,7 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import validator from "validator"; -import { NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@hooks/stores"; +import { LLDPNeighbor, NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@hooks/stores"; import { useJsonRpc } from "@hooks/useJsonRpc"; import AutoHeight from "@components/AutoHeight"; import { Button } from "@components/Button"; @@ -23,9 +23,10 @@ import StaticIpv4Card from "@components/StaticIpv4Card"; import StaticIpv6Card from "@components/StaticIpv6Card"; import { useCopyToClipboard } from "@components/useCopyToClipBoard"; import { netMaskFromCidr4 } from "@/utils/ip"; -import { getNetworkSettings, getNetworkState } from "@/utils/jsonrpc"; +import { callJsonRpc, getNetworkSettings, getNetworkState } from "@/utils/jsonrpc"; import notifications from "@/notifications"; import { m } from "@localizations/messages"; +import LLDPNeighCard from "@components/LLDPNeigh"; dayjs.extend(relativeTime); @@ -97,6 +98,20 @@ export default function SettingsNetworkRoute() { { label: string; from: string; to: string }[] >([]); + 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]); + useEffect(() => { + fetchLLDPNeighbors(); + }, [fetchLLDPNeighbors]); + const fetchNetworkData = useCallback(async () => { try { console.log("Fetching network data..."); @@ -460,6 +475,12 @@ export default function SettingsNetworkRoute() { /> +
+ + + +
+
{formState.isLoading ? (