m/n/core/network: implement LLDP transmission
Implements simple LLDP transmission-only support for Monogon OS. It
advertises enough to do topology discovery through LLDP. Currently it
supports MAC address, interface name, node name and product
name/version. More information can be added in the future.
Tested using Wireshark on launch-cluster node/switch traffic.
Change-Id: If5777bc042ef87bd8d26c548324c6de6f14f7270
Reviewed-on: https://review.monogon.dev/c/monogon/+/4282
Tested-by: Jenkins CI
Reviewed-by: Tim Windelschmidt <tim@monogon.tech>
diff --git a/build/bazel/go.MODULE.bazel b/build/bazel/go.MODULE.bazel
index e0178c7..d3104eb 100644
--- a/build/bazel/go.MODULE.bazel
+++ b/build/bazel/go.MODULE.bazel
@@ -45,6 +45,7 @@
"com_github_mdlayher_ethtool",
"com_github_mdlayher_genetlink",
"com_github_mdlayher_kobject",
+ "com_github_mdlayher_lldp",
"com_github_mdlayher_netlink",
"com_github_mdlayher_packet",
"com_github_miekg_dns",
diff --git a/cloud/agent/takeover/BUILD.bazel b/cloud/agent/takeover/BUILD.bazel
index ee6f0ab..a884f6b 100644
--- a/cloud/agent/takeover/BUILD.bazel
+++ b/cloud/agent/takeover/BUILD.bazel
@@ -1,6 +1,7 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
load("//build/static_binary_tarball:def.bzl", "static_binary_tarball")
load("//osbase/build:def.bzl", "build_static_target")
+load("//osbase/build/genproductinfo:defs.bzl", "product_info")
load("//osbase/build/mkcpio:def.bzl", "node_initramfs")
go_library(
@@ -46,12 +47,21 @@
visibility = ["//visibility:public"],
)
+product_info(
+ name = "product_info",
+ os_id = "monogon-cloud-agent",
+ os_name = "Monogon Cloud Agent",
+ out_os_release = ":product_info_os_release",
+ stamp_var = "STABLE_MONOGON_cloud_version",
+)
+
node_initramfs(
name = "initramfs",
files = {
"/init": "//cloud/agent:agent",
"/etc/resolv.conf": "//osbase/net/dns:resolv.conf",
"/etc/ssl/cert.pem": "@cacerts//file",
+ "/etc/product-info.json": ":product_info",
},
fsspecs = [
"//osbase/build:earlydev.fsspec",
diff --git a/go.mod b/go.mod
index 99ef13e..9b49723 100644
--- a/go.mod
+++ b/go.mod
@@ -89,6 +89,7 @@
github.com/mdlayher/ethtool v0.2.0
github.com/mdlayher/genetlink v1.3.2
github.com/mdlayher/kobject v0.0.0-20200520190114-19ca17470d7d
+ github.com/mdlayher/lldp v0.0.0-20150915211757-afd9f83164c5
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42
github.com/mdlayher/packet v1.1.2
github.com/miekg/dns v1.1.58
diff --git a/go.sum b/go.sum
index 0be3b8e..09387ae 100644
--- a/go.sum
+++ b/go.sum
@@ -2429,6 +2429,8 @@
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/kobject v0.0.0-20200520190114-19ca17470d7d h1:JmrZTpS0GAyMV4ZQVVH/AS0Y6r2PbnYNSRUuRX+HOLA=
github.com/mdlayher/kobject v0.0.0-20200520190114-19ca17470d7d/go.mod h1:+SexPO1ZvdbbWUdUnyXEWv3+4NwHZjKhxOmQqHY4Pqc=
+github.com/mdlayher/lldp v0.0.0-20150915211757-afd9f83164c5 h1:i4JJtLb5iDVsncU7splD9ZCQXvxN13tGDUWihfKOq18=
+github.com/mdlayher/lldp v0.0.0-20150915211757-afd9f83164c5/go.mod h1:IZAsRpRUv/4B6NhGzofHK/+I+N31NTUz/hrEm4ssUwA=
github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA=
github.com/mdlayher/netlink v0.0.0-20190516121005-0087c778e469/go.mod h1:gOrA34zDL0K3RsACQe54bDYLF/CeFspQ9m5DOycycQ8=
github.com/mdlayher/netlink v0.0.0-20190828143259-340058475d09/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M=
diff --git a/metropolis/node/core/network/BUILD.bazel b/metropolis/node/core/network/BUILD.bazel
index 103f033..22efbb4 100644
--- a/metropolis/node/core/network/BUILD.bazel
+++ b/metropolis/node/core/network/BUILD.bazel
@@ -3,6 +3,8 @@
go_library(
name = "network",
srcs = [
+ "linkstate.go",
+ "lldp.go",
"main.go",
"neigh.go",
"quirks.go",
@@ -16,6 +18,7 @@
"//metropolis/node",
"//metropolis/node/core/network/dhcp4c",
"//metropolis/node/core/network/dhcp4c/callback",
+ "//metropolis/node/core/productinfo",
"//osbase/event/memory",
"//osbase/net/dns",
"//osbase/net/dns/forward",
@@ -29,6 +32,8 @@
"@com_github_mdlayher_arp//:arp",
"@com_github_mdlayher_ethernet//:ethernet",
"@com_github_mdlayher_ethtool//:ethtool",
+ "@com_github_mdlayher_lldp//:lldp",
+ "@com_github_mdlayher_packet//:packet",
"@com_github_vishvananda_netlink//:netlink",
"@org_golang_x_sys//unix",
],
diff --git a/metropolis/node/core/network/linkstate.go b/metropolis/node/core/network/linkstate.go
new file mode 100644
index 0000000..d8969f9
--- /dev/null
+++ b/metropolis/node/core/network/linkstate.go
@@ -0,0 +1,77 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package network
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/vishvananda/netlink"
+ "golang.org/x/sys/unix"
+
+ "source.monogon.dev/osbase/supervisor"
+)
+
+func (s *Service) runLinkState(ctx context.Context) error {
+ l := supervisor.Logger(ctx)
+ linkUpdates := make(chan netlink.LinkUpdate, 10)
+ options := netlink.LinkSubscribeOptions{
+ ErrorCallback: func(err error) {
+ l.Errorf("netlink subscription error: %v", err)
+ },
+ }
+ if err := netlink.LinkSubscribeWithOptions(linkUpdates, ctx.Done(), options); err != nil {
+ return fmt.Errorf("while subscribing to netlink link updates: %w", err)
+ }
+ supervisor.Signal(ctx, supervisor.SignalHealthy)
+ lastIfState := make(map[string]bool)
+ ifContexts := make(map[string]context.CancelFunc)
+ for {
+ select {
+ case u, ok := <-linkUpdates:
+ if !ok {
+ return fmt.Errorf("link update channel closed")
+ }
+ attrs := u.Link.Attrs()
+ if u.Link.Type() == "veth" {
+ // Virtual links are not managed by this.
+ continue
+ }
+ before := lastIfState[attrs.Name]
+ now := attrs.RawFlags&unix.IFF_RUNNING != 0
+ lastIfState[attrs.Name] = now
+
+ if !before && now {
+ ifCtx, cancel := context.WithCancel(ctx)
+ ifContexts[attrs.Name] = cancel
+ // Announces all configured IPs marked as permanent via ARP
+ // every time an interface comes up.
+ if err := sendARPAnnouncements(u.Link); err != nil {
+ l.Warningf("Failed sending ARP announcements for interface %q: %v", attrs.Name, err)
+ }
+ // Send a second one after 10s if the network infrastructure is
+ // slow to configure itself after link up or the first one got
+ // lost.
+ time.AfterFunc(10*time.Second, func() {
+ if err := sendARPAnnouncements(u.Link); err != nil {
+ l.Warningf("Failed sending repeated ARP announcements for interface %q: %v", attrs.Name, err)
+ }
+ })
+ if err := runLLDP(ifCtx, netlinkLinkToNetInterface(u.Link)); err != nil {
+ l.Warningf("Failed running LLDP for interface %q: %v")
+ }
+ l.Infof("Interface %q is up", attrs.Name)
+ } else if before && !now {
+ if cancel, ok := ifContexts[attrs.Name]; ok {
+ cancel()
+ delete(ifContexts, attrs.Name)
+ }
+ l.Infof("Interface %q is down", attrs.Name)
+ }
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+}
diff --git a/metropolis/node/core/network/lldp.go b/metropolis/node/core/network/lldp.go
new file mode 100644
index 0000000..ebd590e
--- /dev/null
+++ b/metropolis/node/core/network/lldp.go
@@ -0,0 +1,117 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package network
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "net"
+ "os"
+ "time"
+
+ "github.com/mdlayher/lldp"
+ "github.com/mdlayher/packet"
+
+ "source.monogon.dev/metropolis/node/core/productinfo"
+ "source.monogon.dev/osbase/supervisor"
+)
+
+var lldpTxAddr = packet.Addr{HardwareAddr: net.HardwareAddr{0x01, 0x80, 0xC2, 0x00, 0x00, 0x0E}}
+
+// System capabilitiy bits. See 802.1AB-2016 Table 8-4.
+const capRouter = 1 << 4
+
+type caps struct {
+ Supported uint16
+ Enabled uint16
+}
+
+// runLLDP transmits LLDP frames with standard timings advertising the
+// Monogon node.
+func runLLDP(ctx context.Context, iface *net.Interface) error {
+ conn, err := packet.Listen(iface, packet.Datagram, int(lldp.EtherType), nil)
+ if err != nil {
+ return fmt.Errorf("while setting up LLDP listener: %w", err)
+ }
+
+ go func() {
+ rxBuf := make([]byte, 9000)
+ for {
+ // Clear the RX queue, but do nothing with received frames.
+ // These will need to be processed at some point.
+ _, _, err := conn.ReadFrom(rxBuf)
+ if err != nil {
+ return
+ }
+ }
+ }()
+ go func() {
+ for {
+ var txFrame lldp.Frame
+ txFrame.ChassisID = &lldp.ChassisID{
+ // Using an interface MAC here is what most network gear does.
+ // There isn't really anything better.
+ Subtype: lldp.ChassisIDSubtypeMACAddress,
+ ID: []byte(iface.HardwareAddr),
+ }
+ txFrame.PortID = &lldp.PortID{
+ // This is also a bit suboptimal, but getting better data requires
+ // parsing and handling lots of VPD and DMI/SMBUS data.
+ Subtype: lldp.PortIDSubtypeInterfaceAlias,
+ ID: []byte(iface.Name),
+ }
+ hostname, err := os.Hostname()
+ if err != nil {
+ // Should never happen, but if it does, we'll just use a generic string.
+ hostname = "<unknown>"
+ }
+ txFrame.Optional = append(txFrame.Optional, &lldp.TLV{
+ Type: lldp.TLVTypeSystemName,
+ Length: uint16(len(hostname)),
+ Value: []byte(hostname),
+ })
+ systemDesc := productinfo.Get().Info.Name + " " + productinfo.Get().VersionString
+ txFrame.Optional = append(txFrame.Optional, &lldp.TLV{
+ Type: lldp.TLVTypeSystemDescription,
+ Length: uint16(len(systemDesc)),
+ Value: []byte(systemDesc),
+ })
+ capsRaw, err := binary.Append(nil, binary.BigEndian, caps{
+ Supported: capRouter,
+ Enabled: capRouter,
+ })
+ if err != nil {
+ // All inputs hardcoded
+ panic(err)
+ }
+ txFrame.Optional = append(txFrame.Optional, &lldp.TLV{
+ Type: lldp.TLVTypeSystemCapabilities,
+ Length: uint16(len(capsRaw)),
+ Value: capsRaw,
+ })
+
+ // Use standard timings (30s interval / 120s TTL)
+ txFrame.TTL = 120 * time.Second
+ txBuf, err := txFrame.MarshalBinary()
+ if err == nil {
+ // Make sure this makes progress if an interface dies.
+ conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ conn.WriteTo(txBuf, &lldpTxAddr)
+ } else {
+ supervisor.Logger(ctx).Warningf("Failed to marshal LLDP frame (interface %v): %v", iface.Name, err)
+ }
+
+ select {
+ case <-time.After(30 * time.Second):
+ case <-ctx.Done():
+ // Do not send a zero-TTL clear frame as the context is only
+ // canceled if the interface is already down, making it pointless.
+ conn.Close()
+ return
+ }
+ }
+ }()
+ return nil
+}
diff --git a/metropolis/node/core/network/main.go b/metropolis/node/core/network/main.go
index f9fa010..58af75a 100644
--- a/metropolis/node/core/network/main.go
+++ b/metropolis/node/core/network/main.go
@@ -239,7 +239,7 @@
logger.Errorf("Failed to bring up loopback interface: %v", err)
}
- supervisor.Run(ctx, "announce", s.runNeighborAnnounce)
+ supervisor.Run(ctx, "linkstate", s.runLinkState)
// Choose between autoconfig and static config runnables
if s.StaticConfig == nil {
diff --git a/metropolis/node/core/network/neigh.go b/metropolis/node/core/network/neigh.go
index 6058d0e..d86dab7 100644
--- a/metropolis/node/core/network/neigh.go
+++ b/metropolis/node/core/network/neigh.go
@@ -4,7 +4,6 @@
package network
import (
- "context"
"fmt"
"net"
"net/netip"
@@ -17,64 +16,17 @@
"golang.org/x/sys/unix"
"source.monogon.dev/metropolis/node/core/network/dhcp4c"
- "source.monogon.dev/osbase/supervisor"
)
var ethernetNull = net.HardwareAddr{0, 0, 0, 0, 0, 0}
-// runNeighborAnnounce announces all configured IPs marked as permanent via ARP
-// every time an interface comes up. Non-permanent IPs are handled via
-// arpAnnounceCB. This is done to update ARP tables on all attached hosts,
-// which commonly has very large (hours) timeouts otherwise. The packets are
-// crafted to bypass EVPN ARP suppression to ensure every attached host gets
-// the update.
-func (s *Service) runNeighborAnnounce(ctx context.Context) error {
- l := supervisor.Logger(ctx)
- linkUpdates := make(chan netlink.LinkUpdate, 10)
- options := netlink.LinkSubscribeOptions{
- ErrorCallback: func(err error) {
- l.Errorf("netlink subscription error: %v", err)
- },
- }
- if err := netlink.LinkSubscribeWithOptions(linkUpdates, ctx.Done(), options); err != nil {
- return fmt.Errorf("while subscribing to netlink link updates: %w", err)
- }
- lastIfState := make(map[string]bool)
- for {
- select {
- case u, ok := <-linkUpdates:
- if !ok {
- return fmt.Errorf("link update channel closed")
- }
- attrs := u.Link.Attrs()
- before := lastIfState[attrs.Name]
- now := attrs.RawFlags&unix.IFF_RUNNING != 0
- lastIfState[attrs.Name] = now
-
- if !before && now {
- if err := sendARPAnnouncements(u.Link); err != nil {
- l.Warningf("Failed sending ARP announcements for interface %q: %v", attrs.Name, err)
- }
- // Send a second one after 10s if the network infrastructure is
- // slow to configure itself after link up or the first one got
- // lost.
- time.AfterFunc(10*time.Second, func() {
- if err := sendARPAnnouncements(u.Link); err != nil {
- l.Warningf("Failed sending repeated ARP announcements for interface %q: %v", attrs.Name, err)
- }
- })
- l.Infof("Interface %q is up", attrs.Name)
- } else if before && !now {
- l.Infof("Interface %q is down", attrs.Name)
- }
- case <-ctx.Done():
- return ctx.Err()
- }
- }
-}
-
// sendARPAnnouncements sends an ARP announcement (a form of gratuitous ARP)
// for every permanent IPv4 address configured on the interface.
+// Non-permanent IPs are handled via arpAnnounceCB. This is done to update ARP
+// tables on all attached hosts, which commonly has very large (hours) timeouts
+// otherwise.
+// The packets are crafted to bypass EVPN ARP suppression to ensure every
+// attached host gets the update.
func sendARPAnnouncements(l netlink.Link) error {
ac, err := arp.Dial(netlinkLinkToNetInterface(l))
if err != nil {