From f0595fff405e0e396565804869b9f05883207507 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 5 Nov 2025 15:45:34 +0000 Subject: [PATCH 01/13] fix: await sleep needs to be called inside async function --- ui/src/routes/devices.$id.settings.advanced.tsx | 3 ++- ui/src/routes/devices.$id.settings.general.reboot.tsx | 3 ++- ui/src/routes/devices.$id.settings.general.update.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index dbc98a75..4c3c9e94 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -12,6 +12,7 @@ import { TextAreaWithLabel } from "@components/TextArea"; import { isOnDevice } from "@/main"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; +import { sleep } from "@/utils"; export default function SettingsAdvancedRoute() { const { send } = useJsonRpc(); @@ -311,7 +312,7 @@ export default function SettingsAdvancedRoute() { size="SM" theme="light" text={m.advanced_reset_config_button()} - onClick={() => { + onClick={async () => { handleResetConfig(); // Add 2s delay between resetting the configuration and calling reload() to prevent reload from interrupting the RPC call to reset things. await sleep(2000); diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index 5a4474e6..fc0feeaa 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -4,12 +4,13 @@ import { useNavigate } from "react-router"; import { useJsonRpc } from "@hooks/useJsonRpc"; import { Button } from "@components/Button"; import { m } from "@localizations/messages.js"; +import { sleep } from "@/utils"; export default function SettingsGeneralRebootRoute() { const navigate = useNavigate(); const { send } = useJsonRpc(); - const onClose = useCallback(() => { + const onClose = useCallback(async () => { navigate(".."); // back to the devices.$id.settings page // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation. await sleep(1000); diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 3a52eee2..285ce940 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -21,7 +21,7 @@ export default function SettingsGeneralUpdateRoute() { const { setModalView, otaState } = useUpdateStore(); const { send } = useJsonRpc(); - const onClose = useCallback(() => { + const onClose = useCallback(async () => { navigate(".."); // back to the devices.$id.settings page // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation. await sleep(1000); From 366f7f35430688024644acb7c7e8ab7c14759139 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 5 Nov 2025 15:54:03 +0000 Subject: [PATCH 02/13] feat: add LLDP support --- .golangci.yml | 3 + go.mod | 2 + go.sum | 16 ++ internal/lldp/afpacket.go | 96 +++++++ internal/lldp/afpacket_arm.go | 15 ++ internal/lldp/afpacket_nonarm.go | 15 ++ internal/lldp/bpf.go | 21 ++ internal/lldp/lldp.go | 74 ++++++ internal/lldp/neigh.go | 55 ++++ internal/lldp/rx.go | 235 ++++++++++++++++++ jsonrpc.go | 1 + network.go | 21 ++ ui/src/components/LLDPNeigh.tsx | 87 +++++++ ui/src/hooks/stores.ts | 15 +- .../routes/devices.$id.settings.network.tsx | 25 +- 15 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 internal/lldp/afpacket.go create mode 100644 internal/lldp/afpacket_arm.go create mode 100644 internal/lldp/afpacket_nonarm.go create mode 100644 internal/lldp/bpf.go create mode 100644 internal/lldp/lldp.go create mode 100644 internal/lldp/neigh.go create mode 100644 internal/lldp/rx.go create mode 100644 ui/src/components/LLDPNeigh.tsx 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 ? ( From fd44ff49fd9b637ea88b533bb6f98b07ee2b9df4 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Thu, 6 Nov 2025 14:12:49 +0000 Subject: [PATCH 03/13] feat: add LLDP transmit support --- .vscode/settings.json | 3 +- go.mod | 1 + go.sum | 13 + internal/lldp/afpacket.go | 23 +- internal/lldp/lldp.go | 129 +++++++-- internal/lldp/neigh.go | 63 ++-- internal/lldp/rx.go | 199 ++++++++++--- internal/lldp/tx.go | 270 ++++++++++++++++++ network.go | 26 +- .../{LLDPNeigh.tsx => LLDPNeighborsCard.tsx} | 6 +- ui/src/hooks/stores.ts | 11 +- .../routes/devices.$id.settings.network.tsx | 36 ++- ui/src/utils/jsonrpc.ts | 10 +- 13 files changed, 673 insertions(+), 117 deletions(-) create mode 100644 internal/lldp/tx.go rename ui/src/components/{LLDPNeigh.tsx => LLDPNeighborsCard.tsx} (94%) 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) { From 3e39361aa7b30120f56e461c164f2978e1fe9a36 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Thu, 6 Nov 2025 14:46:59 +0000 Subject: [PATCH 04/13] feat: add LLDP neighbors store and update network settings --- internal/lldp/lldp.go | 41 +++++++++--- internal/lldp/neigh.go | 13 ++-- internal/lldp/rx.go | 20 ++++-- internal/network/types/config.go | 10 ++- network.go | 67 ++++++++++++++----- ui/localization/messages/en.json | 5 +- ui/src/hooks/stores.ts | 14 +++- .../routes/devices.$id.settings.network.tsx | 5 +- ui/src/routes/devices.$id.tsx | 9 +++ 9 files changed, 139 insertions(+), 45 deletions(-) diff --git a/internal/lldp/lldp.go b/internal/lldp/lldp.go index 11f17dd9..450e06d2 100644 --- a/internal/lldp/lldp.go +++ b/internal/lldp/lldp.go @@ -116,16 +116,6 @@ func (l *LLDP) startRx() error { 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() @@ -143,3 +133,34 @@ func (l *LLDP) SetAdvertiseOptions(opts *AdvertiseOptions) error { return nil } + +func (l *LLDP) SetRxAndTx(rx, tx bool) error { + l.mu.Lock() + l.enableRx = rx + l.enableTx = tx + l.mu.Unlock() + + // if rx is enabled, start the RX + if rx { + if err := l.startRx(); err != nil { + return fmt.Errorf("failed to start RX: %w", err) + } + } else { + if err := l.stopRx(); err != nil { + return fmt.Errorf("failed to stop RX: %w", err) + } + } + + // if tx is enabled, start the TX + if tx { + if err := l.startTx(); err != nil { + return fmt.Errorf("failed to start TX: %w", err) + } + } else { + if err := l.stopTx(); err != nil { + return fmt.Errorf("failed to stop TX: %w", err) + } + } + + return nil +} diff --git a/internal/lldp/neigh.go b/internal/lldp/neigh.go index 5291e1fa..b3460738 100644 --- a/internal/lldp/neigh.go +++ b/internal/lldp/neigh.go @@ -2,8 +2,6 @@ package lldp import ( "fmt" - "sort" - "strings" "time" ) @@ -50,8 +48,10 @@ func (l *LLDP) addNeighbor(neighbor *Neighbor, ttl time.Duration) { } } - logger.Info().Msg("adding neighbor") + logger.Trace().Msg("adding neighbor") l.neighbors.Set(key, *neighbor, ttl) + + l.onChange(l.GetNeighbors()) } func (l *LLDP) deleteNeighbor(neighbor *Neighbor) { @@ -61,6 +61,8 @@ func (l *LLDP) deleteNeighbor(neighbor *Neighbor) { logger.Info().Msg("deleting neighbor") l.neighbors.Delete(neighbor.cacheKey()) + + l.onChange(l.GetNeighbors()) } func (l *LLDP) GetNeighbors() []Neighbor { @@ -71,10 +73,5 @@ 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 b822e832..1ea35156 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -166,8 +166,7 @@ func (l *LLDP) handlePacket(packet gopacket.Packet, logger *zerolog.Logger) erro lldpRaw := packet.Layer(layers.LayerTypeLinkLayerDiscovery) if lldpRaw != nil { - logger.Trace().Msg("Found LLDP Frame") - l.l.Info().Hex("packet", packet.Data()).Msg("received packet") + l.l.Trace().Hex("packet", packet.Data()).Msg("received LLDP frame") lldpInfo := packet.Layer(layers.LayerTypeLinkLayerDiscoveryInfo) if lldpInfo == nil { @@ -183,7 +182,7 @@ func (l *LLDP) handlePacket(packet gopacket.Packet, logger *zerolog.Logger) erro cdpRaw := packet.Layer(layers.LayerTypeCiscoDiscovery) if cdpRaw != nil { - logger.Trace().Msg("Found CDP Frame") + l.l.Trace().Hex("packet", packet.Data()).Msg("received CDP frame") cdpInfo := packet.Layer(layers.LayerTypeCiscoDiscoveryInfo) if cdpInfo == nil { @@ -351,6 +350,9 @@ func (l *LLDP) stopCapture() error { l.rxCancel = nil } + // Wait a bit for goroutine to finish + time.Sleep(1000 * time.Millisecond) + if l.tPacketRx != nil { l.tPacketRx.Close() l.tPacketRx = nil @@ -360,7 +362,17 @@ func (l *LLDP) stopCapture() error { l.pktSourceRx = nil } - time.Sleep(100 * time.Millisecond) + return nil +} + +func (l *LLDP) stopRx() error { + if err := l.stopCapture(); err != nil { + return err + } + + // clean up the neighbors table + l.neighbors.DeleteAll() + l.onChange([]Neighbor{}) return nil } diff --git a/internal/network/types/config.go b/internal/network/types/config.go index 364f8609..bc6a6900 100644 --- a/internal/network/types/config.go +++ b/internal/network/types/config.go @@ -42,7 +42,7 @@ type NetworkConfig struct { IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"` - LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` + LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,rx_and_tx,basic,all" default:"rx_and_tx"` LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` @@ -53,6 +53,14 @@ type NetworkConfig struct { TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"` } +func (c *NetworkConfig) ShouldEnableLLDPTransmit() bool { + return c.LLDPMode.String != "rx_only" && c.LLDPMode.String != "disabled" +} + +func (c *NetworkConfig) ShouldEnableLLDPReceive() bool { + return c.LLDPMode.String != "tx_only" && c.LLDPMode.String != "disabled" +} + // GetMDNSMode returns the MDNS mode configuration func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions { mode := c.MDNSMode.String diff --git a/network.go b/network.go index ed2a555f..30c76f53 100644 --- a/network.go +++ b/network.go @@ -144,6 +144,15 @@ func validateNetworkConfig() { } } +func getLLDPAdvertiseOptions(nm *nmlite.NetworkManager) *lldp.AdvertiseOptions { + return &lldp.AdvertiseOptions{ + SysName: nm.Hostname(), + SysDescription: toLLDPSysDescription(), + SysCapabilities: []string{"other", "router", "wlanap"}, + EnabledCapabilities: []string{"other"}, + } +} + func initNetwork() error { ensureConfigLoaded() @@ -163,17 +172,11 @@ func initNetwork() error { networkManager = nm - advertiseOptions := &lldp.AdvertiseOptions{ - SysName: networkManager.Hostname(), - SysDescription: toLLDPSysDescription(nc), - SysCapabilities: []string{"other", "router", "wlanap"}, - EnabledCapabilities: []string{"other"}, - } - + advertiseOptions := getLLDPAdvertiseOptions(nm) lldpService = lldp.NewLLDP(&lldp.Options{ InterfaceName: NetIfName, - EnableRx: nc.LLDPMode.String != "disabled", - EnableTx: nc.LLDPMode.String != "disabled", + EnableRx: nc.ShouldEnableLLDPReceive(), + EnableTx: nc.ShouldEnableLLDPTransmit(), AdvertiseOptions: advertiseOptions, OnChange: func(neighbors []lldp.Neighbor) { writeJSONRPCEvent("lldpNeighbors", neighbors, currentSession) @@ -187,7 +190,7 @@ func initNetwork() error { return nil } -func toLLDPSysDescription(nc *types.NetworkConfig) string { +func toLLDPSysDescription() string { systemVersion, appVersion, err := GetLocalVersion() if err == nil { return fmt.Sprintf("JetKVM (app: %s)", GetBuiltAppVersion()) @@ -196,6 +199,21 @@ func toLLDPSysDescription(nc *types.NetworkConfig) string { return fmt.Sprintf("JetKVM (app: %s, system: %s)", appVersion.String(), systemVersion.String()) } +func updateLLDPOptions(nc *types.NetworkConfig) { + if lldpService == nil { + return + } + + if err := lldpService.SetRxAndTx(nc.ShouldEnableLLDPReceive(), nc.ShouldEnableLLDPTransmit()); err != nil { + networkLogger.Error().Err(err).Msg("failed to set LLDP RX and TX") + } + + advertiseOptions := getLLDPAdvertiseOptions(networkManager) + if err := lldpService.SetAdvertiseOptions(advertiseOptions); err != nil { + networkLogger.Error().Err(err).Msg("failed to set LLDP advertise options") + } +} + func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { if nm == nil { return nil @@ -209,6 +227,12 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { } func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) { + rebootReasons := []string{} + defer func() { + if len(rebootReasons) > 0 { + networkLogger.Info().Strs("reasons", rebootReasons).Msg("reboot required") + } + }() oldDhcpClient := oldConfig.DHCPClient.String l := networkLogger.With(). @@ -217,9 +241,10 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re Logger() // DHCP client change always requires reboot - if newConfig.DHCPClient.String != oldDhcpClient { + newDhcpClient := newConfig.DHCPClient.String + if newDhcpClient != oldDhcpClient { rebootRequired = true - l.Info().Msg("DHCP client changed, reboot required") + rebootReasons = append(rebootReasons, fmt.Sprintf("DHCP client changed from %s to %s", oldDhcpClient, newDhcpClient)) return rebootRequired, postRebootAction } @@ -229,7 +254,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re // IPv4 mode change requires reboot if newIPv4Mode != oldIPv4Mode { rebootRequired = true - l.Info().Msg("IPv4 mode changed with udhcpc, reboot required") + rebootReasons = append(rebootReasons, fmt.Sprintf("IPv4 mode changed from %s to %s", oldIPv4Mode, newIPv4Mode)) if newIPv4Mode == "static" && oldIPv4Mode != "static" { postRebootAction = &PostRebootAction{ @@ -243,8 +268,11 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re } // IPv4 static config changes require reboot - if !reflect.DeepEqual(oldConfig.IPv4Static, newConfig.IPv4Static) { + // but if it's not activated, don't care about the changes + if !reflect.DeepEqual(oldConfig.IPv4Static, newConfig.IPv4Static) && newIPv4Mode == "static" { rebootRequired = true + // TODO: do not restart if it's just the DNS servers that changed + rebootReasons = append(rebootReasons, "IPv4 static config changed") // Handle IP change for redirect (only if both are not nil and IP changed) if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil && @@ -253,17 +281,17 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), } - - l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 static config changed, reboot required") } return rebootRequired, postRebootAction } // IPv6 mode change requires reboot when using udhcpc + oldIPv6Mode := oldConfig.IPv6Mode.String + newIPv6Mode := newConfig.IPv6Mode.String if newConfig.IPv6Mode.String != oldConfig.IPv6Mode.String && oldDhcpClient == "udhcpc" { rebootRequired = true - l.Info().Msg("IPv6 mode changed with udhcpc, reboot required") + rebootReasons = append(rebootReasons, fmt.Sprintf("IPv6 mode changed from %s to %s when using udhcpc", oldIPv6Mode, newIPv6Mode)) } return rebootRequired, postRebootAction @@ -288,6 +316,8 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er l.Debug().Msg("setting new config") + // TODO: do not restart everything if it's just the LLDP mode that changed + // Check if reboot is needed rebootRequired, postRebootAction := shouldRebootForNetworkChange(config.NetworkConfig, netConfig) @@ -311,6 +341,9 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er } config.NetworkConfig = newConfig + // update the LLDP advertise options + updateLLDPOptions(newConfig) + l.Debug().Msg("saving new config") if err := SaveConfig(); err != nil { return nil, err diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 0356e8e5..61f4136f 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -634,10 +634,11 @@ "network_ipv6_mode_title": "IPv6 Mode", "network_ipv6_prefix": "IP Prefix", "network_ipv6_prefix_invalid": "Prefix must be between 0 and 128", - "network_ll_dp_all": "All", - "network_ll_dp_basic": "Basic", "network_ll_dp_description": "Control which TLVs will be sent over Link Layer Discovery Protocol", "network_ll_dp_disabled": "Disabled", + "network_ll_dp_rx_only": "Receive only", + "network_ll_dp_tx_only": "Transmit only", + "network_ll_dp_rx_and_tx": "Receive and transmit", "network_ll_dp_title": "LLDP", "network_mac_address_copy_error": "Failed to copy MAC address", "network_mac_address_copy_success": "MAC address { mac } copied to clipboard", diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index b87d2f4e..3198b407 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" | "tx_only" | "rx_only" | "unknown"; +export type LLDPMode = "disabled" | "rx_only" | "tx_only" | "rx_and_tx" | "unknown"; export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown"; export type TimeSyncMode = | "ntp_only" @@ -835,6 +835,18 @@ export const useNetworkStateStore = create((set, get) => ({ }, })); + + +export interface LLDPNeighborsState { + neighbors: LLDPNeighbor[]; + setNeighbors: (neighbors: LLDPNeighbor[]) => void; +} + +export const useLLDPNeighborsStore = create((set) => ({ + neighbors: [], + setNeighbors: (neighbors: LLDPNeighbor[]) => set({ neighbors }), +})); + export interface KeySequenceStep { keys: string[]; modifiers: string[]; diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index aea5fbbe..d1b4a541 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -564,8 +564,9 @@ export default function SettingsNetworkRoute() { size="SM" options={[ { value: "disabled", label: m.network_ll_dp_disabled() }, - { value: "basic", label: m.network_ll_dp_basic() }, - { value: "all", label: m.network_ll_dp_all() }, + { value: "rx_only", label: m.network_ll_dp_rx_only() }, + { value: "tx_only", label: m.network_ll_dp_tx_only() }, + { value: "rx_and_tx", label: m.network_ll_dp_rx_and_tx() }, ]} {...register("lldp_mode")} /> diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index bae8faa6..e7ed6b27 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -21,11 +21,13 @@ import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { KeyboardLedState, KeysDownState, + LLDPNeighbor, NetworkState, OtaState, PostRebootAction, USBStates, useHidStore, + useLLDPNeighborsStore, useNetworkStateStore, User, useRTCStore, @@ -612,6 +614,7 @@ export default function KvmIdRoute() { }, 10000); const { setNetworkState } = useNetworkStateStore(); + const { setNeighbors } = useLLDPNeighborsStore(); const { setHdmiState } = useVideoStore(); const { keyboardLedState, setKeyboardLedState, @@ -634,6 +637,12 @@ export default function KvmIdRoute() { setUsbState(usbState); } + if (resp.method === "lldpNeighbors") { + const neighbors = resp.params as LLDPNeighbor[]; + console.debug("Setting LLDP neighbors", neighbors); + setNeighbors(neighbors); + } + if (resp.method === "videoInputState") { const hdmiState = resp.params as Parameters[0]; console.debug("Setting HDMI state", hdmiState); From 9f4acce27912699b48351534d39b451fb79de51a Mon Sep 17 00:00:00 2001 From: Siyuan Date: Thu, 6 Nov 2025 15:27:36 +0000 Subject: [PATCH 05/13] fix: crash when stopping LLDP receiver --- internal/lldp/lldp.go | 1 - internal/lldp/rx.go | 63 ++++++++++++------- internal/network/types/config.go | 3 +- network.go | 1 + .../routes/devices.$id.settings.network.tsx | 13 ++-- 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/internal/lldp/lldp.go b/internal/lldp/lldp.go index 450e06d2..e9c24986 100644 --- a/internal/lldp/lldp.go +++ b/internal/lldp/lldp.go @@ -26,7 +26,6 @@ type LLDP struct { enableRx bool enableTx bool - packets chan gopacket.Packet interfaceName string advertiseOptions *AdvertiseOptions onChange func(neighbors []Neighbor) diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go index 1ea35156..fbb38b4e 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -3,7 +3,10 @@ package lldp import ( "context" "fmt" + "io" "net" + "strings" + "syscall" "time" "github.com/google/gopacket" @@ -42,13 +45,6 @@ func (l *LLDP) setUpCapture() error { } 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 // another workaround would be to enable promiscuous mode but that's too tricky @@ -98,23 +94,44 @@ func (l *LLDP) doCapture(logger *zerolog.Logger, rxCtx context.Context) { 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 { + // TODO: use a channel to handle the packets + // PacketSource.Packets() is not reliable and can cause panics and the upstream hasn't fixed it yet + for rxCtx.Err() == nil { + if l.pktSourceRx == nil || l.tPacketRx == nil { + logger.Error().Msg("packet source or TPacketRx not initialized") + break + } + + packet, err := l.pktSourceRx.NextPacket() + if err == nil { + if handleErr := l.handlePacket(packet, logger); handleErr != nil { logger.Error(). - Err(err). + Err(handleErr). Msg("error handling packet") } - case <-rxCtx.Done(): - logger.Info().Msg("LLDP receiver stopped") - return + continue } + + // Immediately retry for temporary network errors and EAGAIN + // temporary has been deprecated and most cases are timeouts + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + if err == syscall.EAGAIN { + continue + } + + // Immediately break for known unrecoverable errors + if err == io.EOF || err == io.ErrUnexpectedEOF || + err == io.ErrNoProgress || err == io.ErrClosedPipe || err == io.ErrShortBuffer || + err == syscall.EBADF || + strings.Contains(err.Error(), "use of closed file") { + break + } + + logger.Error(). + Err(err). + Msg("error receiving LLDP packet") } } @@ -348,17 +365,21 @@ func (l *LLDP) stopCapture() error { if rxCancel != nil { rxCancel() l.rxCancel = nil + + logger.Info().Msg("cancelled RX context, waiting for goroutine to finish") } // Wait a bit for goroutine to finish - time.Sleep(1000 * time.Millisecond) + time.Sleep(500 * time.Millisecond) if l.tPacketRx != nil { + logger.Info().Msg("closing TPacketRx") l.tPacketRx.Close() l.tPacketRx = nil } if l.pktSourceRx != nil { + logger.Info().Msg("closing packet source") l.pktSourceRx = nil } diff --git a/internal/network/types/config.go b/internal/network/types/config.go index bc6a6900..841eaafa 100644 --- a/internal/network/types/config.go +++ b/internal/network/types/config.go @@ -54,7 +54,8 @@ type NetworkConfig struct { } func (c *NetworkConfig) ShouldEnableLLDPTransmit() bool { - return c.LLDPMode.String != "rx_only" && c.LLDPMode.String != "disabled" + // backwards compatibility: `basic` mode will be `rx_only` due to privacy concerns + return c.LLDPMode.String != "rx_only" && c.LLDPMode.String != "disabled" && c.LLDPMode.String != "basic" } func (c *NetworkConfig) ShouldEnableLLDPReceive() bool { diff --git a/network.go b/network.go index 30c76f53..4213b486 100644 --- a/network.go +++ b/network.go @@ -179,6 +179,7 @@ func initNetwork() error { EnableTx: nc.ShouldEnableLLDPTransmit(), AdvertiseOptions: advertiseOptions, OnChange: func(neighbors []lldp.Neighbor) { + // TODO: send deltas instead of the whole list writeJSONRPCEvent("lldpNeighbors", neighbors, currentSession) }, Logger: networkLogger, diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index d1b4a541..fda192b8 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 { LLDPNeighbor, NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@hooks/stores"; +import { NetworkSettings, NetworkState, useLLDPNeighborsStore, useNetworkStateStore, useRTCStore } from "@hooks/stores"; import { useJsonRpc } from "@hooks/useJsonRpc"; import AutoHeight from "@components/AutoHeight"; import { Button } from "@components/Button"; @@ -98,11 +98,12 @@ export default function SettingsNetworkRoute() { { label: string; from: string; to: string }[] >([]); - const [lldpNeighbors, setLldpNeighbors] = useState([]); + const setLLDPNeighbors = useLLDPNeighborsStore(state => state.setNeighbors); + const lldpNeighbors = useLLDPNeighborsStore(state => state.neighbors); const fetchLLDPNeighbors = useCallback(async () => { const neighbors = await getLLDPNeighbors(); - setLldpNeighbors(neighbors); - }, [setLldpNeighbors]); + setLLDPNeighbors(neighbors); + }, [setLLDPNeighbors]); useEffect(() => { fetchLLDPNeighbors(); @@ -572,9 +573,9 @@ export default function SettingsNetworkRoute() { />
- + {lldpNeighbors.length > 0 && - + }
) } From 03f781f7e106185c3a8a9bbadce60b87e8282720 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Thu, 6 Nov 2025 15:52:37 +0000 Subject: [PATCH 06/13] chore: skip TLV end check for LLDP shutdown frame --- internal/lldp/rx.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go index fbb38b4e..7ccb1033 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -248,14 +248,11 @@ func (l *LLDP) handlePacketLLDP(mac string, raw *layers.LinkLayerDiscovery, info 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 @@ -292,7 +289,10 @@ func (l *LLDP) handlePacketLLDP(mac string, raw *layers.LinkLayerDiscovery, info } } - if gotEnd || ttl < 1*time.Second { + // delete the neighbor if the TTL is less than 1 second + // LLDP shutdown frame should have a TTL of 0 and contains mandatory TLVs only + // but we will simply ignore the TLVs check for now + if ttl < 1*time.Second { l.deleteNeighbor(n) } else { l.addNeighbor(n, ttl) From 621be3c1d9a37aead171261813f3760c09b9fc93 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Thu, 6 Nov 2025 21:54:56 +0000 Subject: [PATCH 07/13] fix: PR issues --- internal/lldp/lldp.go | 13 ++++++---- internal/lldp/neigh.go | 20 +++++++++------ internal/lldp/rx.go | 56 ++++++++++++++++++++---------------------- internal/lldp/tx.go | 6 ++--- 4 files changed, 50 insertions(+), 45 deletions(-) diff --git a/internal/lldp/lldp.go b/internal/lldp/lldp.go index e9c24986..3c693707 100644 --- a/internal/lldp/lldp.go +++ b/internal/lldp/lldp.go @@ -30,15 +30,17 @@ type LLDP struct { advertiseOptions *AdvertiseOptions onChange func(neighbors []Neighbor) - neighbors *ttlcache.Cache[string, Neighbor] + neighbors *ttlcache.Cache[neighborCacheKey, Neighbor] // State tracking - rxRunning bool txRunning bool txCtx context.Context txCancel context.CancelFunc - rxCtx context.Context - rxCancel context.CancelFunc + + rxRunning bool + rxWaitGroup *sync.WaitGroup + rxCtx context.Context + rxCancel context.CancelFunc } type AdvertiseOptions struct { @@ -72,8 +74,9 @@ func NewLLDP(opts *Options) *LLDP { advertiseOptions: opts.AdvertiseOptions, enableRx: opts.EnableRx, enableTx: opts.EnableTx, + rxWaitGroup: &sync.WaitGroup{}, l: opts.Logger, - neighbors: ttlcache.New(ttlcache.WithTTL[string, Neighbor](1 * time.Hour)), + neighbors: ttlcache.New(ttlcache.WithTTL[neighborCacheKey, Neighbor](1 * time.Hour)), onChange: opts.OnChange, } } diff --git a/internal/lldp/neigh.go b/internal/lldp/neigh.go index b3460738..3a951c64 100644 --- a/internal/lldp/neigh.go +++ b/internal/lldp/neigh.go @@ -1,7 +1,6 @@ package lldp import ( - "fmt" "time" ) @@ -27,22 +26,28 @@ type Neighbor struct { Values map[string]string `json:"values"` } -func (n *Neighbor) cacheKey() string { - return fmt.Sprintf("%s-%s", n.Mac, n.Source) +type neighborCacheKey struct { + mac string + source string +} + +func (n *Neighbor) cacheKey() neighborCacheKey { + return neighborCacheKey{mac: n.Mac, source: n.Source} } func (l *LLDP) addNeighbor(neighbor *Neighbor, ttl time.Duration) { logger := l.l.With(). + Str("source", neighbor.Source). Str("mac", neighbor.Mac). Interface("neighbor", neighbor). Logger() 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" { + currentNeighbor := l.neighbors.Get(key) + if currentNeighbor != nil { + currentSource := currentNeighbor.Value().Source + if currentSource == "lldp" && neighbor.Source != "lldp" { logger.Info().Msg("skip updating neighbor, as LLDP has higher priority") return } @@ -56,6 +61,7 @@ func (l *LLDP) addNeighbor(neighbor *Neighbor, ttl time.Duration) { func (l *LLDP) deleteNeighbor(neighbor *Neighbor) { logger := l.l.With(). + Str("source", neighbor.Source). Str("mac", neighbor.Mac). Logger() diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go index 7ccb1033..a903ebbd 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -92,46 +92,40 @@ func (l *LLDP) doCapture(logger *zerolog.Logger, rxCtx context.Context) { l.mu.Lock() l.rxRunning = false l.mu.Unlock() + + logger.Info().Msg("RX goroutine finished") + + l.rxWaitGroup.Done() }() + l.rxWaitGroup.Add(1) + // TODO: use a channel to handle the packets // PacketSource.Packets() is not reliable and can cause panics and the upstream hasn't fixed it yet for rxCtx.Err() == nil { - if l.pktSourceRx == nil || l.tPacketRx == nil { - logger.Error().Msg("packet source or TPacketRx not initialized") - break - } - packet, err := l.pktSourceRx.NextPacket() - if err == nil { - if handleErr := l.handlePacket(packet, logger); handleErr != nil { - logger.Error(). - Err(handleErr). - Msg("error handling packet") + if err != nil { + logger.Error(). + Err(err). + Msg("error getting next packet") + + // Immediately break for known unrecoverable errors + if err == io.EOF || err == io.ErrUnexpectedEOF || + err == io.ErrNoProgress || err == io.ErrClosedPipe || err == io.ErrShortBuffer || + err == syscall.EBADF || + strings.Contains(err.Error(), "use of closed file") { + return } + continue } - // Immediately retry for temporary network errors and EAGAIN - // temporary has been deprecated and most cases are timeouts - if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + if err := l.handlePacket(packet, logger); err != nil { + logger.Error(). + Err(err). + Msg("error handling packet") continue } - if err == syscall.EAGAIN { - continue - } - - // Immediately break for known unrecoverable errors - if err == io.EOF || err == io.ErrUnexpectedEOF || - err == io.ErrNoProgress || err == io.ErrClosedPipe || err == io.ErrShortBuffer || - err == syscall.EBADF || - strings.Contains(err.Error(), "use of closed file") { - break - } - - logger.Error(). - Err(err). - Msg("error receiving LLDP packet") } } @@ -369,8 +363,10 @@ func (l *LLDP) stopCapture() error { logger.Info().Msg("cancelled RX context, waiting for goroutine to finish") } - // Wait a bit for goroutine to finish - time.Sleep(500 * time.Millisecond) + // wait for the goroutine to finish + start := time.Now() + l.rxWaitGroup.Wait() + logger.Info().Dur("duration", time.Since(start)).Msg("RX goroutine finished") if l.tPacketRx != nil { logger.Info().Msg("closing TPacketRx") diff --git a/internal/lldp/tx.go b/internal/lldp/tx.go index 745414ca..658c9d4c 100644 --- a/internal/lldp/tx.go +++ b/internal/lldp/tx.go @@ -60,10 +60,10 @@ var ( func toLLDPCapabilitiesBytes(capabilities []string) uint16 { r := uint16(0) for _, capability := range capabilities { - if _, ok := capabilityMap[capability]; !ok { - continue + mask, ok := capabilityMap[capability] + if ok { + r |= mask } - r |= capabilityMap[capability] } return r } From b49d67c87d3e92fcd3c3e7787e2faf761606cd2f Mon Sep 17 00:00:00 2001 From: Siyuan Date: Thu, 6 Nov 2025 22:40:44 +0000 Subject: [PATCH 08/13] fix: RX goroutine cleanup --- internal/lldp/rx.go | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go index a903ebbd..cf8fb543 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -87,23 +87,25 @@ func (l *LLDP) setUpCapture() error { return nil } -func (l *LLDP) doCapture(logger *zerolog.Logger, rxCtx context.Context) { - defer func() { - l.mu.Lock() - l.rxRunning = false - l.mu.Unlock() - - logger.Info().Msg("RX goroutine finished") - - l.rxWaitGroup.Done() - }() - +func (l *LLDP) doCapture(logger *zerolog.Logger) { l.rxWaitGroup.Add(1) + defer l.rxWaitGroup.Done() // TODO: use a channel to handle the packets // PacketSource.Packets() is not reliable and can cause panics and the upstream hasn't fixed it yet - for rxCtx.Err() == nil { + for { + // check if the context is done before blocking call + select { + case <-l.rxCtx.Done(): + logger.Info().Msg("RX context cancelled") + return + default: + } + + logger.Trace().Msg("waiting for next packet") packet, err := l.pktSourceRx.NextPacket() + logger.Trace().Interface("packet", packet).Err(err).Msg("got next packet") + if err != nil { logger.Error(). Err(err). @@ -152,9 +154,7 @@ func (l *LLDP) startCapture() error { l.rxCtx, l.rxCancel = context.WithCancel(context.Background()) l.rxRunning = true - // Capture context in closure - rxCtx := l.rxCtx - go l.doCapture(&logger, rxCtx) + go l.doCapture(&logger) return nil } @@ -363,11 +363,25 @@ func (l *LLDP) stopCapture() error { logger.Info().Msg("cancelled RX context, waiting for goroutine to finish") } + // stop the TPacketRx + go func() { + if l.tPacketRx == nil { + return + } + + // write an empty packet to the TPacketRx to interrupt the blocking read + // it's a shitty workaround until https://github.com/google/gopacket/pull/777 is merged, + // or we have a better solution, see https://github.com/google/gopacket/issues/1064 + l.tPacketRx.WritePacketData([]byte{}) + }() + // wait for the goroutine to finish start := time.Now() l.rxWaitGroup.Wait() logger.Info().Dur("duration", time.Since(start)).Msg("RX goroutine finished") + l.rxRunning = false + if l.tPacketRx != nil { logger.Info().Msg("closing TPacketRx") l.tPacketRx.Close() From 19bc958689eac5b49c82042dfaf140211756d2a1 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 07:56:28 +0000 Subject: [PATCH 09/13] chore: update submit button loading state --- ui/src/routes/devices.$id.settings.network.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index fda192b8..98525d08 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -169,7 +169,10 @@ export default function SettingsNetworkRoute() { const { register, handleSubmit, watch, formState, reset } = formMethods; + const [isSubmitting, setIsSubmitting] = useState(false); + const onSubmit = useCallback(async (settings: NetworkSettings) => { + if (settings.ipv4_static?.address?.includes("/")) { const parts = settings.ipv4_static.address.split("/"); const cidrNotation = Number.parseInt(parts[1]); @@ -180,6 +183,7 @@ export default function SettingsNetworkRoute() { settings.ipv4_static.address = parts[0]; } + setIsSubmitting(true); send("setNetworkSettings", { settings }, async (resp) => { if ("error" in resp) { notifications.error(m.network_save_settings_failed({ error: resp.error.message || m.unknown_error() })); @@ -197,10 +201,10 @@ export default function SettingsNetworkRoute() { } catch (error) { console.error("Failed to fetch network data:", error); } - notifications.success(m.network_dhcp_lease_renew_success()); + setIsSubmitting(false); } }); - }, [fetchNetworkData, reset, send]); + }, [fetchNetworkData, reset, send, setIsSubmitting]); const onSubmitGate = useCallback(async (data: FieldValues) => { const settings = prepareSettings(data); @@ -326,7 +330,7 @@ export default function SettingsNetworkRoute() { size="SM" theme="primary" disabled={!(formState.isDirty || formState.isSubmitting)} - loading={formState.isSubmitting} + loading={formState.isSubmitting || isSubmitting} type="submit" text={formState.isSubmitting ? m.saving() : m.network_save_settings()} /> From 8957a65caeaf1e21dda89abd74708c3931eb2c0e Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 10 Nov 2025 16:43:29 +0000 Subject: [PATCH 10/13] feat(lldp): support multiple management address TLVs --- internal/lldp/lldp.go | 12 +-- internal/lldp/neigh.go | 97 +++++++++++++++++-------- internal/lldp/rx.go | 51 +++++-------- internal/lldp/tx.go | 69 +++++------------- network.go | 50 +++++++++++-- ui/src/components/LLDPNeighborsCard.tsx | 6 +- ui/src/hooks/stores.ts | 2 +- 7 files changed, 156 insertions(+), 131 deletions(-) diff --git a/internal/lldp/lldp.go b/internal/lldp/lldp.go index 3c693707..e1d61233 100644 --- a/internal/lldp/lldp.go +++ b/internal/lldp/lldp.go @@ -3,12 +3,11 @@ package lldp import ( "context" "fmt" + "net" "sync" - "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" ) @@ -30,7 +29,8 @@ type LLDP struct { advertiseOptions *AdvertiseOptions onChange func(neighbors []Neighbor) - neighbors *ttlcache.Cache[neighborCacheKey, Neighbor] + neighbors map[neighborCacheKey]Neighbor + neighborsMu sync.RWMutex // State tracking txRunning bool @@ -47,6 +47,8 @@ type AdvertiseOptions struct { SysName string SysDescription string PortDescription string + IPv4Address *net.IP + IPv6Address *net.IP SysCapabilities []string EnabledCapabilities []string } @@ -76,14 +78,12 @@ func NewLLDP(opts *Options) *LLDP { enableTx: opts.EnableTx, rxWaitGroup: &sync.WaitGroup{}, l: opts.Logger, - neighbors: ttlcache.New(ttlcache.WithTTL[neighborCacheKey, Neighbor](1 * time.Hour)), + neighbors: make(map[neighborCacheKey]Neighbor), onChange: opts.OnChange, } } func (l *LLDP) Start() error { - go l.neighbors.Start() - if l.enableRx { if err := l.startRx(); err != nil { return fmt.Errorf("failed to start RX: %w", err) diff --git a/internal/lldp/neigh.go b/internal/lldp/neigh.go index 3a951c64..7e2af084 100644 --- a/internal/lldp/neigh.go +++ b/internal/lldp/neigh.go @@ -13,26 +13,48 @@ type ManagementAddress struct { } 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"` + 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"` + ManagementAddresses []ManagementAddress `json:"management_addresses"` + Capabilities []string `json:"capabilities"` + Values map[string]string `json:"values"` + cacheTTL time.Time + cacheKey neighborCacheKey } +const ( + NeighborSourceLLDP uint8 = 0x1 + NeighborSourceCDP = 0x2 +) + +var ( + NeighborSourceMap = map[uint8]string{ + NeighborSourceLLDP: "lldp", + NeighborSourceCDP: "cdp", + } +) + type neighborCacheKey struct { - mac string - source string + Mac string + Source uint8 } -func (n *Neighbor) cacheKey() neighborCacheKey { - return neighborCacheKey{mac: n.Mac, source: n.Source} +func newNeighbor(mac string, source uint8) *Neighbor { + return &Neighbor{ + Mac: mac, + Source: NeighborSourceMap[source], + Values: make(map[string]string), + cacheKey: neighborCacheKey{ + Mac: mac, + Source: source, + }, + } } func (l *LLDP) addNeighbor(neighbor *Neighbor, ttl time.Duration) { @@ -42,19 +64,18 @@ func (l *LLDP) addNeighbor(neighbor *Neighbor, ttl time.Duration) { Interface("neighbor", neighbor). Logger() - key := neighbor.cacheKey() + l.neighborsMu.RLock() - currentNeighbor := l.neighbors.Get(key) - if currentNeighbor != nil { - currentSource := currentNeighbor.Value().Source - if currentSource == "lldp" && neighbor.Source != "lldp" { - logger.Info().Msg("skip updating neighbor, as LLDP has higher priority") - return - } + _, ok := l.neighbors[neighbor.cacheKey] + if ok { + logger.Trace().Msg("neighbor already exists, updating it") } logger.Trace().Msg("adding neighbor") - l.neighbors.Set(key, *neighbor, ttl) + neighbor.cacheTTL = time.Now().Add(ttl) + l.neighbors[neighbor.cacheKey] = *neighbor + + l.neighborsMu.RUnlock() l.onChange(l.GetNeighbors()) } @@ -66,17 +87,33 @@ func (l *LLDP) deleteNeighbor(neighbor *Neighbor) { Logger() logger.Info().Msg("deleting neighbor") - l.neighbors.Delete(neighbor.cacheKey()) + + l.neighborsMu.Lock() + delete(l.neighbors, neighbor.cacheKey) + l.neighborsMu.Unlock() l.onChange(l.GetNeighbors()) } -func (l *LLDP) GetNeighbors() []Neighbor { - items := l.neighbors.Items() - neighbors := make([]Neighbor, 0, len(items)) +func (l *LLDP) flushNeighbors() { + l.neighborsMu.Lock() + defer l.neighborsMu.Unlock() - for _, item := range items { - neighbors = append(neighbors, item.Value()) + l.neighbors = make(map[neighborCacheKey]Neighbor) +} + +func (l *LLDP) GetNeighbors() []Neighbor { + l.neighborsMu.Lock() + defer l.neighborsMu.Unlock() + + neighbors := make([]Neighbor, 0) + + for key, neighbor := range l.neighbors { + if time.Now().After(neighbor.cacheTTL) { + delete(l.neighbors, key) + continue + } + neighbors = append(neighbors, neighbor) } return neighbors diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go index cf8fb543..d6523ba7 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -237,11 +237,7 @@ func capabilitiesToString(capabilities layers.LLDPCapabilities) []string { } func (l *LLDP) handlePacketLLDP(mac string, raw *layers.LinkLayerDiscovery, info *layers.LinkLayerDiscoveryInfo) error { - n := &Neighbor{ - Values: make(map[string]string), - Source: "lldp", - Mac: mac, - } + n := newNeighbor(mac, NeighborSourceLLDP) ttl := lldpDefaultTTL @@ -263,12 +259,12 @@ 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 = &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, + mgmtAddress := parseTlvMgmtAddress(v) + if mgmtAddress != nil { + n.ManagementAddresses = append( + n.ManagementAddresses, + lldpMgmtAddressToSerializable(mgmtAddress), + ) } case layers.LLDPTLVSysCapabilities: n.Capabilities = capabilitiesToString(info.SysCapabilities.EnabledCap) @@ -297,11 +293,7 @@ func (l *LLDP) handlePacketLLDP(mac string, raw *layers.LinkLayerDiscovery, info 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, - } + n := newNeighbor(mac, NeighborSourceCDP) ttl := cdpDefaultTTL @@ -315,27 +307,18 @@ func (l *LLDP) handlePacketCDP(mac string, raw *layers.CiscoDiscovery, info *lay ttl = time.Duration(n.TTL) * time.Second } - if len(info.MgmtAddresses) > 0 { - ip := info.MgmtAddresses[0] - ipFamily := "ipv4" - if ip.To4() == nil { - ipFamily = "ipv6" + for _, addr := range info.MgmtAddresses { + addrFamily := "ipv4" + if addr.To4() == nil { + addrFamily = "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(), + n.ManagementAddresses = append(n.ManagementAddresses, ManagementAddress{ + AddressFamily: addrFamily, + Address: addr.String(), InterfaceSubtype: "if_name", InterfaceNumber: 0, OID: "", - } + }) } l.addNeighbor(n, ttl) @@ -402,7 +385,7 @@ func (l *LLDP) stopRx() error { } // clean up the neighbors table - l.neighbors.DeleteAll() + l.flushNeighbors() l.onChange([]Neighbor{}) return nil diff --git a/internal/lldp/tx.go b/internal/lldp/tx.go index 658c9d4c..0fd66b1e 100644 --- a/internal/lldp/tx.go +++ b/internal/lldp/tx.go @@ -17,57 +17,6 @@ var ( 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 { - mask, ok := capabilityMap[capability] - if ok { - r |= mask - } - } - return r -} - func (l *LLDP) toPayloadValues() []layers.LinkLayerDiscoveryValue { // See also: layers.LinkLayerDiscovery.SerializeTo() r := []layers.LinkLayerDiscoveryValue{} @@ -88,6 +37,24 @@ func (l *LLDP) toPayloadValues() []layers.LinkLayerDiscoveryValue { r = append(r, tlvStringValue(layers.LLDPTLVSysDescription, opts.SysDescription)) } + if opts.IPv4Address != nil { + r = append(r, tlvMgmtAddress(&layers.LLDPMgmtAddress{ + Subtype: layers.IANAAddressFamilyIPV4, + Address: opts.IPv4Address.To4(), + InterfaceSubtype: layers.LLDPInterfaceSubtypeifIndex, + InterfaceNumber: 0, + })) + } + + if opts.IPv6Address != nil { + r = append(r, tlvMgmtAddress(&layers.LLDPMgmtAddress{ + Subtype: layers.IANAAddressFamilyIPV6, + Address: opts.IPv6Address.To16(), + InterfaceSubtype: layers.LLDPInterfaceSubtypeifIndex, + InterfaceNumber: 0, + })) + } + if len(opts.SysCapabilities) > 0 { value := make([]byte, 4) binary.BigEndian.PutUint16(value[0:2], toLLDPCapabilitiesBytes(opts.SysCapabilities)) diff --git a/network.go b/network.go index 4213b486..c06e772b 100644 --- a/network.go +++ b/network.go @@ -3,6 +3,7 @@ package kvm import ( "context" "fmt" + "net" "reflect" "github.com/jetkvm/kvm/internal/confparser" @@ -119,6 +120,11 @@ func networkStateChanged(_ string, state types.InterfaceState) { triggerTimeSyncOnNetworkStateChange() } + // update the LLDP advertise options + if lldpService != nil { + _ = lldpService.SetAdvertiseOptions(getLLDPAdvertiseOptions(&state)) + } + // always restart mDNS when the network state changes if mDNS != nil { restartMdns() @@ -144,13 +150,29 @@ func validateNetworkConfig() { } } -func getLLDPAdvertiseOptions(nm *nmlite.NetworkManager) *lldp.AdvertiseOptions { - return &lldp.AdvertiseOptions{ - SysName: nm.Hostname(), +func getLLDPAdvertiseOptions(state *types.InterfaceState) *lldp.AdvertiseOptions { + a := &lldp.AdvertiseOptions{ SysDescription: toLLDPSysDescription(), SysCapabilities: []string{"other", "router", "wlanap"}, EnabledCapabilities: []string{"other"}, } + if state == nil { + return a + } + + a.SysName = state.Hostname + ip4String := state.IPv4Address + if ip4String != "" { + ip4 := net.ParseIP(ip4String) + a.IPv4Address = &ip4 + } + ip6String := state.IPv6Address + if ip6String != "" { + ip6 := net.ParseIP(ip6String) + a.IPv6Address = &ip6 + } + networkLogger.Info().Interface("advertiseOptions", a).Msg("LLDP advertise options") + return a } func initNetwork() error { @@ -172,7 +194,12 @@ func initNetwork() error { networkManager = nm - advertiseOptions := getLLDPAdvertiseOptions(nm) + ifState, err := nm.GetInterfaceState(NetIfName) + if err != nil { + networkLogger.Warn().Err(err).Msg("failed to get interface state, LLDP will use the default options") + } + + advertiseOptions := getLLDPAdvertiseOptions(ifState) lldpService = lldp.NewLLDP(&lldp.Options{ InterfaceName: NetIfName, EnableRx: nc.ShouldEnableLLDPReceive(), @@ -200,7 +227,7 @@ func toLLDPSysDescription() string { return fmt.Sprintf("JetKVM (app: %s, system: %s)", appVersion.String(), systemVersion.String()) } -func updateLLDPOptions(nc *types.NetworkConfig) { +func updateLLDPOptions(nc *types.NetworkConfig, ifState *types.InterfaceState) { if lldpService == nil { return } @@ -209,7 +236,16 @@ func updateLLDPOptions(nc *types.NetworkConfig) { networkLogger.Error().Err(err).Msg("failed to set LLDP RX and TX") } - advertiseOptions := getLLDPAdvertiseOptions(networkManager) + if ifState == nil { + newIfState, err := networkManager.GetInterfaceState(NetIfName) + if err != nil { + networkLogger.Warn().Err(err).Msg("failed to get interface state, LLDP will use the default options") + return + } + ifState = newIfState + } + + advertiseOptions := getLLDPAdvertiseOptions(ifState) if err := lldpService.SetAdvertiseOptions(advertiseOptions); err != nil { networkLogger.Error().Err(err).Msg("failed to set LLDP advertise options") } @@ -343,7 +379,7 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er config.NetworkConfig = newConfig // update the LLDP advertise options - updateLLDPOptions(newConfig) + updateLLDPOptions(newConfig, nil) l.Debug().Msg("saving new config") if err := SaveConfig(); err != nil { diff --git a/ui/src/components/LLDPNeighborsCard.tsx b/ui/src/components/LLDPNeighborsCard.tsx index 762d1e88..fe845d2c 100644 --- a/ui/src/components/LLDPNeighborsCard.tsx +++ b/ui/src/components/LLDPNeighborsCard.tsx @@ -64,8 +64,10 @@ export default function LLDPNeighborsCard({ )} - {neighbor.management_address && ( - + {neighbor.management_addresses && neighbor.management_addresses.length > 0 && ( + neighbor.management_addresses.map((address, index) => ( + + )) )} {neighbor.mac && ( diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 3198b407..7b27e9d7 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -801,7 +801,7 @@ export interface LLDPNeighbor { system_description: string; capabilities: string[]; ttl: number | null; - management_address: LLDPManagementAddress | null; + management_addresses: LLDPManagementAddress[]; values: Record; } From 15484f889e90b2297169c4f7b12f0645c6aa5748 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 10 Nov 2025 16:45:52 +0000 Subject: [PATCH 11/13] fix(ui): use source in LLDP neighbor key --- internal/lldp/tlv.go | 131 ++++++++++++++++++++++++ ui/src/components/LLDPNeighborsCard.tsx | 3 +- 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 internal/lldp/tlv.go diff --git a/internal/lldp/tlv.go b/internal/lldp/tlv.go new file mode 100644 index 00000000..022e304f --- /dev/null +++ b/internal/lldp/tlv.go @@ -0,0 +1,131 @@ +package lldp + +import ( + "encoding/binary" + "fmt" + "net" + + "github.com/google/gopacket/layers" +) + +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 tlvMgmtAddressToBytes(m *layers.LLDPMgmtAddress) []byte { + var b []byte + b = append(b, byte(len(m.Address))+1) // TLV Length + b = append(b, byte(m.Subtype)) // Address Subtype + b = append(b, m.Address...) // Address + b = append(b, byte(m.InterfaceSubtype)) // Interface Subtype + + ifIndex := make([]byte, 4) // 4 bytes for the interface number + binary.BigEndian.PutUint32(ifIndex, m.InterfaceNumber) + b = append(b, ifIndex...) + + b = append(b, 0) // OID type + return b +} + +func tlvMgmtAddress(m *layers.LLDPMgmtAddress) layers.LinkLayerDiscoveryValue { + return layers.LinkLayerDiscoveryValue{ + Type: layers.LLDPTLVMgmtAddress, + Value: tlvMgmtAddressToBytes(m), + Length: uint16(len(tlvMgmtAddressToBytes(m))), + } +} + +// if err := checkLLDPTLVLen(v, 9); err != nil { +// return err +// } +// mlen := v.Value[0] +// if err := checkLLDPTLVLen(v, int(mlen+7)); err != nil { +// return err +// } +// info.MgmtAddress.Subtype = IANAAddressFamily(v.Value[1]) +// info.MgmtAddress.Address = v.Value[2 : mlen+1] +// info.MgmtAddress.InterfaceSubtype = LLDPInterfaceSubtype(v.Value[mlen+1]) +// info.MgmtAddress.InterfaceNumber = binary.BigEndian.Uint32(v.Value[mlen+2 : mlen+6]) +// olen := v.Value[mlen+6] +// if err := checkLLDPTLVLen(v, int(mlen+7+olen)); err != nil { +// return err +// } +// info.MgmtAddress.OID = string(v.Value[mlen+7 : mlen+7+olen]) + +func checkLLDPTLVLen(v layers.LinkLayerDiscoveryValue, l int) (err error) { + if len(v.Value) < l { + err = fmt.Errorf("invalid TLV %v length %d (wanted mimimum %v)", v.Type, len(v.Value), l) + } + return +} + +// parseTlvMgmtAddress parses the Management Address TLV and returns the Management Address +// structure. +// we don't parse the OID here, as it's not needed for the neighbor cache +func parseTlvMgmtAddress(v layers.LinkLayerDiscoveryValue) *layers.LLDPMgmtAddress { + if err := checkLLDPTLVLen(v, 9); err != nil { + return nil + } + + mlen := v.Value[0] + if err := checkLLDPTLVLen(v, int(mlen+7)); err != nil { + return nil + } + + return &layers.LLDPMgmtAddress{ + Subtype: layers.IANAAddressFamily(v.Value[1]), + Address: v.Value[2 : mlen+1], + InterfaceSubtype: layers.LLDPInterfaceSubtype(v.Value[mlen+1]), + InterfaceNumber: binary.BigEndian.Uint32(v.Value[mlen+2 : mlen+6]), + } +} + +func lldpMgmtAddressToSerializable(m *layers.LLDPMgmtAddress) ManagementAddress { + var addrString string + switch m.Subtype { + case layers.IANAAddressFamilyIPV4: + addrString = net.IP(m.Address).String() + case layers.IANAAddressFamilyIPV6: + addrString = net.IP(m.Address).String() + default: + addrString = string(m.Address) + } + + return ManagementAddress{ + AddressFamily: m.Subtype.String(), + Address: addrString, + InterfaceSubtype: m.InterfaceSubtype.String(), + InterfaceNumber: m.InterfaceNumber, + } +} + +func tlvStringValue(tlvType layers.LLDPTLVType, value string) layers.LinkLayerDiscoveryValue { + return layers.LinkLayerDiscoveryValue{ + Type: tlvType, + Value: []byte(value), + Length: uint16(len(value)), + } +} + +func toLLDPCapabilitiesBytes(capabilities []string) uint16 { + r := uint16(0) + for _, capability := range capabilities { + mask, ok := capabilityMap[capability] + if ok { + r |= mask + } + } + return r +} diff --git a/ui/src/components/LLDPNeighborsCard.tsx b/ui/src/components/LLDPNeighborsCard.tsx index fe845d2c..eec31570 100644 --- a/ui/src/components/LLDPNeighborsCard.tsx +++ b/ui/src/components/LLDPNeighborsCard.tsx @@ -37,7 +37,8 @@ export default function LLDPNeighborsCard({
{neighbors.map(neighbor => { const displayName = neighbor.system_name || neighbor.port_description || neighbor.mac; - return
+ const key = `${neighbor.mac}-${neighbor.source}`; + return

{displayName}

Date: Mon, 10 Nov 2025 16:55:26 +0000 Subject: [PATCH 12/13] fix(lldp): use mutexes to protect state --- internal/lldp/lldp.go | 4 ++-- internal/lldp/rx.go | 5 +++++ internal/lldp/tx.go | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/lldp/lldp.go b/internal/lldp/lldp.go index e1d61233..ebe54c3f 100644 --- a/internal/lldp/lldp.go +++ b/internal/lldp/lldp.go @@ -102,10 +102,10 @@ func (l *LLDP) Start() error { // StartRx starts the LLDP receiver if not already running func (l *LLDP) startRx() error { - l.mu.Lock() + l.mu.RLock() running := l.rxRunning enabled := l.enableRx - l.mu.Unlock() + l.mu.RUnlock() if running || !enabled { return nil diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go index d6523ba7..7953fbf8 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -88,6 +88,11 @@ func (l *LLDP) setUpCapture() error { } func (l *LLDP) doCapture(logger *zerolog.Logger) { + if l.pktSourceRx == nil || l.rxCtx == nil { + logger.Error().Msg("packet source or RX context not initialized") + return + } + l.rxWaitGroup.Add(1) defer l.rxWaitGroup.Done() diff --git a/internal/lldp/tx.go b/internal/lldp/tx.go index 0fd66b1e..13116f6b 100644 --- a/internal/lldp/tx.go +++ b/internal/lldp/tx.go @@ -194,7 +194,9 @@ func (l *LLDP) startTx() error { cancel() } + l.mu.Lock() l.txCtx, l.txCancel = context.WithCancel(context.Background()) + l.mu.Unlock() if err := l.setUpTx(); err != nil { return fmt.Errorf("failed to set up TX: %w", err) From 1e5184284f7dd9b0d46feb2277547d2c0a14f369 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 10 Nov 2025 16:56:43 +0000 Subject: [PATCH 13/13] fix(lldp): golangci-lint issues --- internal/lldp/neigh.go | 2 +- internal/lldp/rx.go | 2 +- internal/lldp/tlv.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/lldp/neigh.go b/internal/lldp/neigh.go index 7e2af084..0d85e9c7 100644 --- a/internal/lldp/neigh.go +++ b/internal/lldp/neigh.go @@ -30,7 +30,7 @@ type Neighbor struct { const ( NeighborSourceLLDP uint8 = 0x1 - NeighborSourceCDP = 0x2 + NeighborSourceCDP uint8 = 0x2 ) var ( diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go index 7953fbf8..f32a4f79 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -360,7 +360,7 @@ func (l *LLDP) stopCapture() error { // write an empty packet to the TPacketRx to interrupt the blocking read // it's a shitty workaround until https://github.com/google/gopacket/pull/777 is merged, // or we have a better solution, see https://github.com/google/gopacket/issues/1064 - l.tPacketRx.WritePacketData([]byte{}) + _ = l.tPacketRx.WritePacketData([]byte{}) }() // wait for the goroutine to finish diff --git a/internal/lldp/tlv.go b/internal/lldp/tlv.go index 022e304f..d4e23776 100644 --- a/internal/lldp/tlv.go +++ b/internal/lldp/tlv.go @@ -66,7 +66,7 @@ func tlvMgmtAddress(m *layers.LLDPMgmtAddress) layers.LinkLayerDiscoveryValue { func checkLLDPTLVLen(v layers.LinkLayerDiscoveryValue, l int) (err error) { if len(v.Value) < l { - err = fmt.Errorf("invalid TLV %v length %d (wanted mimimum %v)", v.Type, len(v.Value), l) + err = fmt.Errorf("invalid TLV %v length %d (wanted minimum %d)", v.Type, len(v.Value), l) } return }