diff --git a/internal/confparser/confparser_test.go b/internal/confparser/confparser_test.go index 07d057e..d96f2b1 100644 --- a/internal/confparser/confparser_test.go +++ b/internal/confparser/confparser_test.go @@ -39,7 +39,7 @@ type testNetworkConfig struct { IPv6Mode null.String `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` IPv6Static *testIPv6StaticConfig `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,enabled" default:"enabled"` 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"` diff --git a/internal/lldp/lldp.go b/internal/lldp/lldp.go index 2a76004..81bf32c 100644 --- a/internal/lldp/lldp.go +++ b/internal/lldp/lldp.go @@ -1,6 +1,8 @@ package lldp import ( + "context" + "sync" "time" "github.com/google/gopacket" @@ -16,6 +18,9 @@ type LLDP struct { l *zerolog.Logger tPacket *afpacket.TPacket pktSource *gopacket.PacketSource + rxCtx context.Context + rxCancel context.CancelFunc + rxLock sync.Mutex enableRx bool enableTx bool @@ -53,6 +58,16 @@ func NewLLDP(opts *LLDPOptions) *LLDP { } func (l *LLDP) Start() error { + l.rxLock.Lock() + defer l.rxLock.Unlock() + + if l.rxCtx != nil { + l.l.Info().Msg("LLDP already started") + return nil + } + + l.rxCtx, l.rxCancel = context.WithCancel(context.Background()) + if l.enableRx { l.l.Info().Msg("setting up AF_PACKET") if err := l.setUpCapture(); err != nil { @@ -66,3 +81,23 @@ func (l *LLDP) Start() error { return nil } + +func (l *LLDP) Stop() error { + l.rxLock.Lock() + defer l.rxLock.Unlock() + + if l.rxCancel != nil { + l.rxCancel() + l.rxCancel = nil + l.rxCtx = nil + } + + if l.enableRx { + l.shutdownCapture() + } + + l.neighbors.Stop() + l.neighbors.DeleteAll() + + return nil +} diff --git a/internal/lldp/neigh.go b/internal/lldp/neigh.go index d73c6f6..f144238 100644 --- a/internal/lldp/neigh.go +++ b/internal/lldp/neigh.go @@ -51,5 +51,7 @@ func (l *LLDP) GetNeighbors() []Neighbor { neighbors = append(neighbors, item.Value()) } + l.l.Info().Interface("neighbors", neighbors).Msg("neighbors") + return neighbors } diff --git a/internal/lldp/rx.go b/internal/lldp/rx.go index ccaf70a..909806c 100644 --- a/internal/lldp/rx.go +++ b/internal/lldp/rx.go @@ -99,11 +99,18 @@ func (l *LLDP) startCapture() error { 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) + for { + select { + case <-l.rxCtx.Done(): + logger.Info().Msg("shutting down LLDP capture") + return + case packet := <-l.pktSource.Packets(): + if err := l.handlePacket(packet, &logger); err != nil { + logger.Error().Msgf("error handling packet: %s", err) + } } } + }() return nil @@ -242,11 +249,13 @@ func (l *LLDP) handlePacketCDP(mac string, raw *layers.CiscoDiscovery, info *lay func (l *LLDP) shutdownCapture() error { if l.tPacket != nil { + l.l.Info().Msg("closing TPacket") l.tPacket.Close() l.tPacket = nil } if l.pktSource != nil { + l.l.Info().Msg("closing packet source") l.pktSource = nil } diff --git a/internal/network/config.go b/internal/network/config.go index 74ddf19..69f16d5 100644 --- a/internal/network/config.go +++ b/internal/network/config.go @@ -41,7 +41,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,basic,all,enabled" default:"enabled"` 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"` diff --git a/internal/network/lldp.go b/internal/network/lldp.go new file mode 100644 index 0000000..df342ff --- /dev/null +++ b/internal/network/lldp.go @@ -0,0 +1,46 @@ +package network + +import ( + "errors" + + "github.com/jetkvm/kvm/internal/lldp" +) + +func (s *NetworkInterfaceState) shouldStartLLDP() bool { + if s.lldp == nil { + s.l.Trace().Msg("LLDP not initialized") + return false + } + + s.l.Trace().Msgf("LLDP mode: %s", s.config.LLDPMode.String) + + if s.config.LLDPMode.String == "disabled" { + return false + } + + return true +} + +func (s *NetworkInterfaceState) startLLDP() { + if !s.shouldStartLLDP() || s.lldp == nil { + return + } + + s.l.Trace().Msg("starting LLDP") + s.lldp.Start() +} + +func (s *NetworkInterfaceState) stopLLDP() { + if s.lldp == nil { + return + } + s.l.Trace().Msg("stopping LLDP") + s.lldp.Stop() +} + +func (s *NetworkInterfaceState) GetLLDPNeighbors() ([]lldp.Neighbor, error) { + if s.lldp == nil { + return nil, errors.New("lldp not initialized") + } + return s.lldp.GetNeighbors(), nil +} diff --git a/internal/network/netif.go b/internal/network/netif.go index c5db806..e20d24d 100644 --- a/internal/network/netif.go +++ b/internal/network/netif.go @@ -6,6 +6,7 @@ import ( "sync" "github.com/jetkvm/kvm/internal/confparser" + "github.com/jetkvm/kvm/internal/lldp" "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/udhcpc" "github.com/rs/zerolog" @@ -29,6 +30,8 @@ type NetworkInterfaceState struct { config *NetworkConfig dhcpClient *udhcpc.DHCPClient + lldp *lldp.LLDP + defaultHostname string currentHostname string currentFqdn string @@ -96,8 +99,16 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS }, }) - s.dhcpClient = dhcpClient + // create the lldp service + lldpClient := lldp.NewLLDP(&lldp.LLDPOptions{ + InterfaceName: opts.InterfaceName, + EnableRx: true, + EnableTx: true, + Logger: l, + }) + s.dhcpClient = dhcpClient + s.lldp = lldpClient return s, nil } @@ -310,14 +321,30 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { } if initialCheck { - s.onInitialCheck(s) + s.handleInitialCheck() } else if changed { - s.onStateChange(s) + s.handleStateChange() } return dhcpTargetState, nil } +func (s *NetworkInterfaceState) handleInitialCheck() { + if s.IsUp() { + s.startLLDP() + } + s.onInitialCheck(s) +} + +func (s *NetworkInterfaceState) handleStateChange() { + if s.IsUp() { + s.startLLDP() + } else { + s.stopLLDP() + } + s.onStateChange(s) +} + func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { dhcpTargetState, err := s.update() if err != nil { diff --git a/jsonrpc.go b/jsonrpc.go index 258828a..95474b3 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1104,4 +1104,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 fdcd149..92afca8 100644 --- a/network.go +++ b/network.go @@ -32,16 +32,6 @@ func networkStateChanged() { func initNetwork() error { ensureConfigLoaded() - lldp := lldp.NewLLDP(&lldp.LLDPOptions{ - InterfaceName: NetIfName, - EnableRx: true, - EnableTx: true, - Logger: networkLogger, - }) - if err := lldp.Start(); err != nil { - return err - } - state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{ DefaultHostname: GetDefaultHostname(), InterfaceName: NetIfName, @@ -116,3 +106,7 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet func rpcRenewDHCPLease() error { return networkState.RpcRenewDHCPLease() } + +func rpcGetLLDPNeighbors() ([]lldp.Neighbor, error) { + return networkState.GetLLDPNeighbors() +} diff --git a/ui/src/components/LLDPNeighCard.tsx b/ui/src/components/LLDPNeighCard.tsx new file mode 100644 index 0000000..fe95af9 --- /dev/null +++ b/ui/src/components/LLDPNeighCard.tsx @@ -0,0 +1,84 @@ +import { LLDPNeighbor } from "../hooks/stores"; +import { LifeTimeLabel } from "../routes/devices.$id.settings.network"; + +import { GridCard } from "./Card"; + +export default function LLDPNeighCard({ + neighbors, +}: { + neighbors: LLDPNeighbor[]; +}) { + return ( + +
+
+

+ LLDP Neighbors +

+ +
+ {neighbors.map(neighbor => ( +
+

{neighbor.mac}

+
+
+
+ + Interface + + {neighbor.port_description} +
+ + {neighbor.system_name && ( +
+ + System Name + + {neighbor.system_name} +
+ )} + + {neighbor.system_description && ( +
+ + System Description + + {neighbor.system_description} +
+ )} + + + {neighbor.port_id && ( +
+ + Port ID + + + {neighbor.port_id} + +
+ )} + + + {neighbor.port_description && ( +
+ + Port Description + + + {neighbor.port_description} + +
+ )} +
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 6bc7e17..625741c 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -741,7 +741,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" @@ -761,6 +761,19 @@ export interface NetworkSettings { time_sync_mode: TimeSyncMode; } +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 const useNetworkStateStore = create((set, get) => ({ setNetworkState: (state: NetworkState) => set(state), setDhcpLease: (lease: NetworkState["dhcp_lease"]) => set({ dhcp_lease: lease }), diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 0905db5..b486084 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -7,6 +7,7 @@ import { IPv4Mode, IPv6Mode, LLDPMode, + LLDPNeighbor, mDNSMode, NetworkSettings, NetworkState, @@ -29,6 +30,7 @@ import AutoHeight from "../components/AutoHeight"; import DhcpLeaseCard from "../components/DhcpLeaseCard"; import { SettingsItem } from "./devices.$id.settings"; +import LLDPNeighCard from "../components/LLDPNeighCard"; dayjs.extend(relativeTime); @@ -88,6 +90,14 @@ export default function SettingsNetworkRoute() { const [customDomain, setCustomDomain] = useState(""); const [selectedDomainOption, setSelectedDomainOption] = useState("dhcp"); + const [lldpNeighbors, setLldpNeighbors] = useState(undefined); + useEffect(() => { + send("getLLDPNeighbors", {}, resp => { + if ("error" in resp) return; + setLldpNeighbors(resp.result as LLDPNeighbor[]); + }); + }, [send]); + useEffect(() => { if (networkSettings.domain && networkSettingsLoaded) { // Check if the domain is one of the predefined options @@ -130,7 +140,7 @@ export default function SettingsNetworkRoute() { if ("error" in resp) { notifications.error( "Failed to save network settings: " + - (resp.error.data ? resp.error.data : resp.error.message), + (resp.error.data ? resp.error.data : resp.error.message), ); setNetworkSettingsLoaded(true); return; @@ -402,7 +412,7 @@ export default function SettingsNetworkRoute() { {!networkSettingsLoaded && - !(networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0) ? ( + !(networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0) ? (
@@ -428,22 +438,49 @@ export default function SettingsNetworkRoute() { )}
-
- + +
+ handleLldpModeChange(e.target.value)} options={filterUnknown([ { value: "disabled", label: "Disabled" }, - { value: "basic", label: "Basic" }, - { value: "all", label: "All" }, + { value: "tx_only", label: "Tx only" }, + { value: "rx_only", label: "Rx only" }, + { value: "basic", label: "Tx Minimal + Rx" }, + { value: "all", label: "Tx Detailed + Rx" }, + { value: "enabled", label: "Enabled" }, ])} /> + + {lldpNeighbors === undefined ? ( + +
+
+

+ LLDP Neighbors +

+
+
+
+
+
+
+
+ + ) : lldpNeighbors.length > 0 ? ( + + ) : ( + + )} +