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/.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 404215b0..5c391c62 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 @@ -62,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 1cb90138..f3167b6d 100644 --- a/go.sum +++ b/go.sum @@ -57,9 +57,13 @@ 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= +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,8 +74,11 @@ 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.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= @@ -99,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= @@ -206,14 +217,28 @@ 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-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= @@ -224,8 +249,12 @@ 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= +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 new file mode 100644 index 00000000..c60fc7d1 --- /dev/null +++ b/internal/lldp/afpacket.go @@ -0,0 +1,109 @@ +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 +) + +// 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, + pageSize int, +) ( + frameSize int, + blockSize int, + numBlocks int, + err error, +) { + 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 { + // 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 + 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..3c693707 --- /dev/null +++ b/internal/lldp/lldp.go @@ -0,0 +1,168 @@ +package lldp + +import ( + "context" + "fmt" + "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" +) + +var defaultLogger = logging.GetSubsystemLogger("lldp") + +type LLDP struct { + mu sync.RWMutex + + l *zerolog.Logger + tPacketRx *afpacket.TPacket + tPacketTx *afpacket.TPacket + pktSourceRx *gopacket.PacketSource + + enableRx bool + enableTx bool + + interfaceName string + advertiseOptions *AdvertiseOptions + onChange func(neighbors []Neighbor) + + neighbors *ttlcache.Cache[neighborCacheKey, Neighbor] + + // State tracking + txRunning bool + txCtx context.Context + txCancel context.CancelFunc + + rxRunning bool + rxWaitGroup *sync.WaitGroup + rxCtx context.Context + rxCancel context.CancelFunc +} + +type AdvertiseOptions struct { + SysName string + SysDescription string + PortDescription string + SysCapabilities []string + EnabledCapabilities []string +} + +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 + } + + if opts.InterfaceName == "" { + opts.Logger.Fatal().Msg("InterfaceName is required") + } + + return &LLDP{ + interfaceName: opts.InterfaceName, + advertiseOptions: opts.AdvertiseOptions, + enableRx: opts.EnableRx, + enableTx: opts.EnableTx, + rxWaitGroup: &sync.WaitGroup{}, + l: opts.Logger, + neighbors: ttlcache.New(ttlcache.WithTTL[neighborCacheKey, Neighbor](1 * time.Hour)), + 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) + } + } + + // 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() +} + +// 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 +} + +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 new file mode 100644 index 00000000..3a951c64 --- /dev/null +++ b/internal/lldp/neigh.go @@ -0,0 +1,83 @@ +package lldp + +import ( + "time" +) + +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"` +} + +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"` +} + +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() + + 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 + } + } + + logger.Trace().Msg("adding neighbor") + l.neighbors.Set(key, *neighbor, ttl) + + l.onChange(l.GetNeighbors()) +} + +func (l *LLDP) deleteNeighbor(neighbor *Neighbor) { + logger := l.l.With(). + Str("source", neighbor.Source). + Str("mac", neighbor.Mac). + Logger() + + logger.Info().Msg("deleting neighbor") + l.neighbors.Delete(neighbor.cacheKey()) + + l.onChange(l.GetNeighbors()) +} + +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..cf8fb543 --- /dev/null +++ b/internal/lldp/rx.go @@ -0,0 +1,409 @@ +package lldp + +import ( + "context" + "fmt" + "io" + "net" + "strings" + "syscall" + "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 { + l.mu.Lock() + defer l.mu.Unlock() + + if l.tPacketRx != nil { + return nil + } + + logger := l.l.With().Str("interface", l.interfaceName).Logger() + tPacketRx, err := afPacketNewTPacket(l.interfaceName) + if err != nil { + return err + } + logger.Info().Msg("created TPacketRx") + + // 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(). + 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(). + MACAddr("hwaddr", hwAddr). + Err(err). + Msg("unable to add multicast address") + continue + } + + logger.Info(). + MACAddr("hwaddr", hwAddr). + Msg("added multicast address") + } + + 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.pktSourceRx = gopacket.NewPacketSource(tPacketRx, layers.LayerTypeEthernet) + l.tPacketRx = tPacketRx + + return nil +} + +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 { + // 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). + 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 + } + + if err := l.handlePacket(packet, logger); err != nil { + logger.Error(). + Err(err). + Msg("error handling packet") + continue + } + } +} + +func (l *LLDP) startCapture() error { + 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.pktSourceRx == nil { + return fmt.Errorf("packet source not initialized") + } + + logger := l.l.With().Str("interface", l.interfaceName).Logger() + logger.Info().Msg("starting capture LLDP ethernet frames") + + // Create a new context for this instance + l.rxCtx, l.rxCancel = context.WithCancel(context.Background()) + l.rxRunning = true + + go l.doCapture(&logger) + + 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 { + l.l.Trace().Hex("packet", packet.Data()).Msg("received 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 { + l.l.Trace().Hex("packet", packet.Data()).Msg("received 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 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), + Source: "lldp", + Mac: mac, + } + + ttl := lldpDefaultTTL + + for _, v := range raw.Values { + switch v.Type { + 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 = &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 + 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) + } + } + } + + // 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) + } + + 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 { + 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(n, ttl) + + return nil +} + +func (l *LLDP) stopCapture() error { + l.mu.Lock() + defer l.mu.Unlock() + + if !l.rxRunning { + return nil // Already stopped + } + + 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 + + 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() + l.tPacketRx = nil + } + + if l.pktSourceRx != nil { + logger.Info().Msg("closing packet source") + l.pktSourceRx = nil + } + + 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/lldp/tx.go b/internal/lldp/tx.go new file mode 100644 index 00000000..658c9d4c --- /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 { + mask, ok := capabilityMap[capability] + if ok { + r |= mask + } + } + 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/internal/network/types/config.go b/internal/network/types/config.go index 364f8609..841eaafa 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,15 @@ 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 { + // 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 { + 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/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..4213b486 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 { @@ -142,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() @@ -161,9 +172,49 @@ func initNetwork() error { networkManager = nm + advertiseOptions := getLLDPAdvertiseOptions(nm) + lldpService = lldp.NewLLDP(&lldp.Options{ + InterfaceName: NetIfName, + EnableRx: nc.ShouldEnableLLDPReceive(), + EnableTx: nc.ShouldEnableLLDPTransmit(), + AdvertiseOptions: advertiseOptions, + OnChange: func(neighbors []lldp.Neighbor) { + // TODO: send deltas instead of the whole list + writeJSONRPCEvent("lldpNeighbors", neighbors, currentSession) + }, + Logger: networkLogger, + }) + if err := lldpService.Start(); err != nil { + networkLogger.Error().Err(err).Msg("failed to start LLDP service") + } + return nil } +func toLLDPSysDescription() 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 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 @@ -177,6 +228,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(). @@ -185,9 +242,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 } @@ -197,7 +255,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{ @@ -211,8 +269,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 && @@ -221,17 +282,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 @@ -256,6 +317,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) @@ -279,6 +342,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 @@ -312,3 +378,10 @@ func rpcToggleDHCPClient() error { return rpcReboot(true) } + +func rpcGetLLDPNeighbors() []lldp.Neighbor { + if lldpService == nil { + return []lldp.Neighbor{} + } + return lldpService.GetNeighbors() +} 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/components/LLDPNeighborsCard.tsx b/ui/src/components/LLDPNeighborsCard.tsx new file mode 100644 index 00000000..762d1e88 --- /dev/null +++ b/ui/src/components/LLDPNeighborsCard.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 LLDPNeighborsCard({ + 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 0be28425..64773eec 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -784,7 +784,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" | "rx_only" | "tx_only" | "rx_and_tx" | "unknown"; export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown"; export type TimeSyncMode = | "ntp_only" @@ -806,6 +806,28 @@ 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; + chassis_id: string; + port_id: string; + port_description: string; + system_name: string; + system_description: string; + capabilities: string[]; + ttl: number | null; + management_address: LLDPManagementAddress | null; + values: Record; +} + export interface NetworkSettings { dhcp_client: string; hostname: string | null; @@ -836,6 +858,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 20958764..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 { 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"; @@ -23,13 +23,14 @@ 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 { getNetworkSettings, getNetworkState, getLLDPNeighbors } from "@/utils/jsonrpc"; import notifications from "@/notifications"; import { m } from "@localizations/messages"; +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 => { @@ -97,6 +98,17 @@ export default function SettingsNetworkRoute() { { label: string; from: string; to: string }[] >([]); + const setLLDPNeighbors = useLLDPNeighborsStore(state => state.setNeighbors); + const lldpNeighbors = useLLDPNeighborsStore(state => state.neighbors); + const fetchLLDPNeighbors = useCallback(async () => { + const neighbors = await getLLDPNeighbors(); + setLLDPNeighbors(neighbors); + }, [setLLDPNeighbors]); + + useEffect(() => { + fetchLLDPNeighbors(); + }, [fetchLLDPNeighbors]); + const fetchNetworkData = useCallback(async () => { try { console.log("Fetching network data..."); @@ -460,6 +472,7 @@ export default function SettingsNetworkRoute() { /> +
{formState.isLoading ? ( @@ -540,9 +553,10 @@ export default function SettingsNetworkRoute() {
- { isLLDPAvailable && - ( -
+ {isLLDPAvailable && + ( +
+
- ) + {lldpNeighbors.length > 0 && + + } +
+ ) }
diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 1e2bb6b3..a431484b 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, @@ -613,6 +615,7 @@ export default function KvmIdRoute() { }, 10000); const { setNetworkState } = useNetworkStateStore(); + const { setNeighbors } = useLLDPNeighborsStore(); const { setHdmiState } = useVideoStore(); const { keyboardLedState, setKeyboardLedState, @@ -635,6 +638,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); diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index ae97be13..13f44716 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 @@ -203,6 +203,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) {