feat(lldp): show neighbors in UI

This commit is contained in:
Siyuan Miao 2025-06-14 14:48:23 +02:00
parent 748bfe5477
commit cb7da61ab4
12 changed files with 276 additions and 28 deletions

View File

@ -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"` 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"` 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"` 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"` 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"` TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`

View File

@ -1,6 +1,8 @@
package lldp package lldp
import ( import (
"context"
"sync"
"time" "time"
"github.com/google/gopacket" "github.com/google/gopacket"
@ -16,6 +18,9 @@ type LLDP struct {
l *zerolog.Logger l *zerolog.Logger
tPacket *afpacket.TPacket tPacket *afpacket.TPacket
pktSource *gopacket.PacketSource pktSource *gopacket.PacketSource
rxCtx context.Context
rxCancel context.CancelFunc
rxLock sync.Mutex
enableRx bool enableRx bool
enableTx bool enableTx bool
@ -53,6 +58,16 @@ func NewLLDP(opts *LLDPOptions) *LLDP {
} }
func (l *LLDP) Start() error { 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 { if l.enableRx {
l.l.Info().Msg("setting up AF_PACKET") l.l.Info().Msg("setting up AF_PACKET")
if err := l.setUpCapture(); err != nil { if err := l.setUpCapture(); err != nil {
@ -66,3 +81,23 @@ func (l *LLDP) Start() error {
return nil 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
}

View File

@ -51,5 +51,7 @@ func (l *LLDP) GetNeighbors() []Neighbor {
neighbors = append(neighbors, item.Value()) neighbors = append(neighbors, item.Value())
} }
l.l.Info().Interface("neighbors", neighbors).Msg("neighbors")
return neighbors return neighbors
} }

View File

@ -99,11 +99,18 @@ func (l *LLDP) startCapture() error {
go func() { go func() {
logger.Info().Msg("starting capture LLDP ethernet frames") logger.Info().Msg("starting capture LLDP ethernet frames")
for packet := range l.pktSource.Packets() { 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 { if err := l.handlePacket(packet, &logger); err != nil {
logger.Error().Msgf("error handling packet: %s", err) logger.Error().Msgf("error handling packet: %s", err)
} }
} }
}
}() }()
return nil return nil
@ -242,11 +249,13 @@ func (l *LLDP) handlePacketCDP(mac string, raw *layers.CiscoDiscovery, info *lay
func (l *LLDP) shutdownCapture() error { func (l *LLDP) shutdownCapture() error {
if l.tPacket != nil { if l.tPacket != nil {
l.l.Info().Msg("closing TPacket")
l.tPacket.Close() l.tPacket.Close()
l.tPacket = nil l.tPacket = nil
} }
if l.pktSource != nil { if l.pktSource != nil {
l.l.Info().Msg("closing packet source")
l.pktSource = nil l.pktSource = nil
} }

View File

@ -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"` 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"` 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"` 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"` 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"` TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`

46
internal/network/lldp.go Normal file
View File

@ -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
}

View File

@ -6,6 +6,7 @@ import (
"sync" "sync"
"github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/lldp"
"github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/udhcpc" "github.com/jetkvm/kvm/internal/udhcpc"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@ -29,6 +30,8 @@ type NetworkInterfaceState struct {
config *NetworkConfig config *NetworkConfig
dhcpClient *udhcpc.DHCPClient dhcpClient *udhcpc.DHCPClient
lldp *lldp.LLDP
defaultHostname string defaultHostname string
currentHostname string currentHostname string
currentFqdn 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 return s, nil
} }
@ -310,14 +321,30 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
} }
if initialCheck { if initialCheck {
s.onInitialCheck(s) s.handleInitialCheck()
} else if changed { } else if changed {
s.onStateChange(s) s.handleStateChange()
} }
return dhcpTargetState, nil 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 { func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
dhcpTargetState, err := s.update() dhcpTargetState, err := s.update()
if err != nil { if err != nil {

View File

@ -1104,4 +1104,5 @@ var rpcHandlers = map[string]RPCHandler{
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
"getLLDPNeighbors": {Func: rpcGetLLDPNeighbors},
} }

View File

@ -32,16 +32,6 @@ func networkStateChanged() {
func initNetwork() error { func initNetwork() error {
ensureConfigLoaded() 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{ state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
DefaultHostname: GetDefaultHostname(), DefaultHostname: GetDefaultHostname(),
InterfaceName: NetIfName, InterfaceName: NetIfName,
@ -116,3 +106,7 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet
func rpcRenewDHCPLease() error { func rpcRenewDHCPLease() error {
return networkState.RpcRenewDHCPLease() return networkState.RpcRenewDHCPLease()
} }
func rpcGetLLDPNeighbors() ([]lldp.Neighbor, error) {
return networkState.GetLLDPNeighbors()
}

View File

@ -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 (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
LLDP Neighbors
</h3>
<div className="space-y-3 pt-2">
{neighbors.map(neighbor => (
<div className="space-y-3" key={neighbor.mac}>
<h4 className="text-sm font-semibold font-mono">{neighbor.mac}</h4>
<div
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-white p-4 pl-4 backdrop-blur-sm dark:bg-transparent"
>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Interface
</span>
<span className="text-sm font-medium">{neighbor.port_description}</span>
</div>
{neighbor.system_name && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
System Name
</span>
<span className="text-sm font-medium">{neighbor.system_name}</span>
</div>
)}
{neighbor.system_description && (
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
System Description
</span>
<span className="text-sm font-medium">{neighbor.system_description}</span>
</div>
)}
{neighbor.port_id && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Port ID
</span>
<span className="text-sm font-medium">
{neighbor.port_id}
</span>
</div>
)}
{neighbor.port_description && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Port Description
</span>
<span className="text-sm font-medium">
{neighbor.port_description}
</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -741,7 +741,7 @@ export type IPv6Mode =
| "link_local" | "link_local"
| "unknown"; | "unknown";
export type IPv4Mode = "disabled" | "static" | "dhcp" | "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 mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown";
export type TimeSyncMode = export type TimeSyncMode =
| "ntp_only" | "ntp_only"
@ -761,6 +761,19 @@ export interface NetworkSettings {
time_sync_mode: TimeSyncMode; 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<string, string>;
}
export const useNetworkStateStore = create<NetworkState>((set, get) => ({ export const useNetworkStateStore = create<NetworkState>((set, get) => ({
setNetworkState: (state: NetworkState) => set(state), setNetworkState: (state: NetworkState) => set(state),
setDhcpLease: (lease: NetworkState["dhcp_lease"]) => set({ dhcp_lease: lease }), setDhcpLease: (lease: NetworkState["dhcp_lease"]) => set({ dhcp_lease: lease }),

View File

@ -7,6 +7,7 @@ import {
IPv4Mode, IPv4Mode,
IPv6Mode, IPv6Mode,
LLDPMode, LLDPMode,
LLDPNeighbor,
mDNSMode, mDNSMode,
NetworkSettings, NetworkSettings,
NetworkState, NetworkState,
@ -29,6 +30,7 @@ import AutoHeight from "../components/AutoHeight";
import DhcpLeaseCard from "../components/DhcpLeaseCard"; import DhcpLeaseCard from "../components/DhcpLeaseCard";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
import LLDPNeighCard from "../components/LLDPNeighCard";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@ -88,6 +90,14 @@ export default function SettingsNetworkRoute() {
const [customDomain, setCustomDomain] = useState<string>(""); const [customDomain, setCustomDomain] = useState<string>("");
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("dhcp"); const [selectedDomainOption, setSelectedDomainOption] = useState<string>("dhcp");
const [lldpNeighbors, setLldpNeighbors] = useState<LLDPNeighbor[] | undefined>(undefined);
useEffect(() => {
send("getLLDPNeighbors", {}, resp => {
if ("error" in resp) return;
setLldpNeighbors(resp.result as LLDPNeighbor[]);
});
}, [send]);
useEffect(() => { useEffect(() => {
if (networkSettings.domain && networkSettingsLoaded) { if (networkSettings.domain && networkSettingsLoaded) {
// Check if the domain is one of the predefined options // Check if the domain is one of the predefined options
@ -428,22 +438,49 @@ export default function SettingsNetworkRoute() {
)} )}
</AutoHeight> </AutoHeight>
</div> </div>
<div className="hidden space-y-4">
<SettingsItem <div className="space-y-4">
title="LLDP" <SettingsItem title="LLDP" description="Configure the LLDP mode">
description="Control which TLVs will be sent over Link Layer Discovery Protocol"
>
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
value={networkSettings.lldp_mode} value={networkSettings.lldp_mode}
onChange={e => handleLldpModeChange(e.target.value)} onChange={e => handleLldpModeChange(e.target.value)}
options={filterUnknown([ options={filterUnknown([
{ value: "disabled", label: "Disabled" }, { value: "disabled", label: "Disabled" },
{ value: "basic", label: "Basic" }, { value: "tx_only", label: "Tx only" },
{ value: "all", label: "All" }, { value: "rx_only", label: "Rx only" },
{ value: "basic", label: "Tx Minimal + Rx" },
{ value: "all", label: "Tx Detailed + Rx" },
{ value: "enabled", label: "Enabled" },
])} ])}
/> />
</SettingsItem> </SettingsItem>
<AutoHeight>
{lldpNeighbors === undefined ? (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
LLDP Neighbors
</h3>
<div className="animate-pulse space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
</div>
</div>
</div>
</GridCard>
) : lldpNeighbors.length > 0 ? (
<LLDPNeighCard neighbors={lldpNeighbors} />
) : (
<EmptyCard
IconElm={LuEthernetPort}
headline="LLDP Neighbors"
description="No LLDP neighbors found"
/>
)}
</AutoHeight>
</div> </div>
</Fieldset> </Fieldset>
<ConfirmDialog <ConfirmDialog