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 {