Files
infix/src/yangerd/internal/monitor/monitor.go
T
2026-06-10 12:11:14 +02:00

691 lines
16 KiB
Go

package monitor
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"sync"
"syscall"
"github.com/kernelkit/infix/src/yangerd/internal/bridgebatch"
"github.com/kernelkit/infix/src/yangerd/internal/iface"
"github.com/kernelkit/infix/src/yangerd/internal/ipbatch"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
)
// treeKey is the single YANG module key where the complete
// ietf-interfaces document is stored.
const treeKey = "ietf-interfaces:interfaces"
// NLMonitor subscribes to netlink link/address/neighbor events and
// keeps interface operational data in the in-memory tree up to date.
// It is the central coordinator for all interface data — raw ip-json
// staging data is transformed via iface.Transform() and augmented
// with ethernet/wifi/bridge data before being stored as a single
// complete YANG document.
type NLMonitor struct {
batch *ipbatch.IPBatch
brBatch *bridgebatch.BridgeBatch
tree *tree.Tree
ethRefresh func(string)
log *slog.Logger
fc iface.FileChecker
// staging holds raw ip-json data used as input to iface.Transform().
// Protected by mu.
mu sync.Mutex
links json.RawMessage // ip -json -s -d link show (includes stats+details)
addrs json.RawMessage // ip -json addr show
fdb map[string]json.RawMessage
mdb map[string]json.RawMessage
ethernet map[string]json.RawMessage // ifname → ethtool JSON
wifi map[string]json.RawMessage // ifname → wifi JSON
lastOperStatus map[string]string
}
// New creates a netlink monitor backed by ip/bridge batch query workers.
func New(batch *ipbatch.IPBatch, brBatch *bridgebatch.BridgeBatch, t *tree.Tree, fc iface.FileChecker, log *slog.Logger) *NLMonitor {
return &NLMonitor{
batch: batch,
brBatch: brBatch,
tree: t,
fc: fc,
log: log,
fdb: make(map[string]json.RawMessage),
mdb: make(map[string]json.RawMessage),
ethernet: make(map[string]json.RawMessage),
wifi: make(map[string]json.RawMessage),
lastOperStatus: make(map[string]string),
}
}
// SetEthRefresh sets an optional callback used to refresh ethtool data
// when interface link events are received.
func (m *NLMonitor) SetEthRefresh(fn func(string)) {
m.ethRefresh = fn
}
// SetEthernetData updates the staged ethernet data for an interface
// and triggers a full rebuild of the YANG document.
func (m *NLMonitor) SetEthernetData(ifname string, data json.RawMessage) {
m.mu.Lock()
m.ethernet[ifname] = data
m.mu.Unlock()
m.rebuild()
}
// SetWifiData updates the staged wifi data for an interface
// and triggers a full rebuild of the YANG document.
func (m *NLMonitor) SetWifiData(ifname string, data json.RawMessage) {
m.mu.Lock()
m.wifi[ifname] = data
m.mu.Unlock()
m.rebuild()
}
// Run starts the netlink monitor loop and returns on context cancellation,
// channel closure, or subscription errors.
func (m *NLMonitor) Run(ctx context.Context) error {
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan struct{})
defer close(done)
errorCallback := func(err error) {
if err == nil {
return
}
m.log.Error("netlink subscription error", "err", err)
cancel()
}
linkCh := make(chan netlink.LinkUpdate, 64)
addrCh := make(chan netlink.AddrUpdate, 64)
neighCh := make(chan netlink.NeighUpdate, 64)
mdbCh := make(chan struct{}, 32)
if err := netlink.LinkSubscribeWithOptions(linkCh, done, netlink.LinkSubscribeOptions{
ErrorCallback: errorCallback,
}); err != nil {
return fmt.Errorf("subscribe link updates: %w", err)
}
if err := netlink.AddrSubscribeWithOptions(addrCh, done, netlink.AddrSubscribeOptions{
ErrorCallback: errorCallback,
}); err != nil {
return fmt.Errorf("subscribe addr updates: %w", err)
}
if err := netlink.NeighSubscribeWithOptions(neighCh, done, netlink.NeighSubscribeOptions{
ErrorCallback: errorCallback,
}); err != nil {
return fmt.Errorf("subscribe neigh updates: %w", err)
}
if err := m.subscribeBridgeMDB(runCtx, mdbCh, errorCallback); err != nil {
return fmt.Errorf("subscribe bridge mdb updates: %w", err)
}
if err := m.initialDump(); err != nil {
m.log.Error("initial dump failed", "err", err)
}
for {
select {
case <-runCtx.Done():
if ctx.Err() != nil {
return ctx.Err()
}
return runCtx.Err()
case lu, ok := <-linkCh:
if !ok {
return fmt.Errorf("link update channel closed")
}
m.handleLinkUpdate(lu)
case au, ok := <-addrCh:
if !ok {
return fmt.Errorf("addr update channel closed")
}
m.handleAddrUpdate(au)
case nu, ok := <-neighCh:
if !ok {
return fmt.Errorf("neigh update channel closed")
}
m.handleNeighUpdate(nu)
case _, ok := <-mdbCh:
if !ok {
return fmt.Errorf("bridge mdb update channel closed")
}
m.handleMDBUpdate()
}
}
}
func (m *NLMonitor) initialDump() error {
linkRaw, err := m.queryIP("link show")
if err != nil {
return err
}
addrRaw, err := m.queryIP("addr show")
if err != nil {
return err
}
m.mu.Lock()
m.links = linkRaw
m.addrs = addrRaw
for _, name := range interfaceNames(linkRaw) {
if st, ok := extractOperStatus(filterByIfName(linkRaw, name)); ok {
m.lastOperStatus[name] = st
}
}
m.mu.Unlock()
m.rebuild()
return nil
}
func (m *NLMonitor) handleLinkUpdate(update netlink.LinkUpdate) {
name, ok := linkNameFromUpdate(update)
if !ok || name == "" {
m.log.Warn("link update without interface name", "index", int(update.Index))
return
}
m.refreshInterface(name)
if m.ethRefresh != nil {
m.ethRefresh(name)
}
}
func (m *NLMonitor) handleAddrUpdate(update netlink.AddrUpdate) {
ifname, err := ifNameByIndex(update.LinkIndex)
if err != nil {
m.log.Warn("addr update: resolve interface", "index", update.LinkIndex, "err", err)
return
}
raw, err := m.queryIP("addr show dev " + ifname)
if err != nil {
return
}
m.mu.Lock()
m.addrs = replaceByIfName(m.addrs, ifname, raw)
m.mu.Unlock()
m.rebuild()
}
func (m *NLMonitor) handleNeighUpdate(update netlink.NeighUpdate) {
if isBridgeFDB(update) {
bridgeName, ok := bridgeNameFromNeigh(update)
if !ok {
m.log.Warn("fdb update: bridge name not found", "link-index", update.LinkIndex)
return
}
raw, err := m.queryBridge("fdb show br " + bridgeName)
if err != nil {
return
}
m.mu.Lock()
m.fdb[bridgeName] = raw
m.mu.Unlock()
m.rebuild()
return
}
}
func (m *NLMonitor) handleMDBUpdate() {
raw, err := m.queryBridge("mdb show")
if err != nil {
return
}
m.mu.Lock()
for _, bridgeName := range bridgeNames(raw) {
m.mdb[bridgeName] = filterByBridge(raw, bridgeName)
}
m.mu.Unlock()
m.rebuild()
}
func (m *NLMonitor) refreshInterface(name string) {
linkRaw, err := m.queryIP("link show dev " + name)
if err != nil {
return
}
addrRaw, err := m.queryIP("addr show dev " + name)
if err != nil {
addrRaw = nil
}
m.mu.Lock()
m.updateOperStatus(name, linkRaw)
m.links = replaceByIfName(m.links, name, linkRaw)
if addrRaw != nil {
m.addrs = replaceByIfName(m.addrs, name, addrRaw)
}
m.mu.Unlock()
m.rebuild()
}
// rebuild runs iface.Transform on all staged data, merges augments
// (ethernet, wifi, bridge fdb/mdb), and stores the result.
// Caller must NOT hold m.mu.
func (m *NLMonitor) rebuild() {
m.mu.Lock()
doc := iface.Transform(m.links, m.addrs, m.links, m.fc)
eth := copyStringMap(m.ethernet)
wfi := copyStringMap(m.wifi)
fdb := copyStringMap(m.fdb)
mdb := copyStringMap(m.mdb)
m.mu.Unlock()
doc = mergeAugments(doc, eth, wfi, fdb, mdb)
m.tree.Set(treeKey, doc)
}
// mergeAugments adds ethernet, wifi, and bridge data into the
// complete ietf-interfaces document produced by iface.Transform().
func mergeAugments(doc json.RawMessage, ethernet, wifi, fdb, mdb map[string]json.RawMessage) json.RawMessage {
if len(ethernet) == 0 && len(wifi) == 0 && len(fdb) == 0 && len(mdb) == 0 {
return doc
}
var root map[string]any
if err := json.Unmarshal(doc, &root); err != nil {
return doc
}
ifaceList, ok := root["interface"]
if !ok {
return doc
}
ifaceArr, ok := ifaceList.([]any)
if !ok {
return doc
}
for i, entry := range ifaceArr {
ifaceObj, ok := entry.(map[string]any)
if !ok {
continue
}
name, _ := ifaceObj["name"].(string)
if name == "" {
continue
}
if ethData, ok := ethernet[name]; ok {
var ethObj any
if err := json.Unmarshal(ethData, &ethObj); err == nil {
ifaceObj["ieee802-ethernet-interface:ethernet"] = ethObj
}
}
if wifiData, ok := wifi[name]; ok {
var wifiObj any
if err := json.Unmarshal(wifiData, &wifiObj); err == nil {
ifaceObj["infix-interfaces:wifi"] = wifiObj
}
}
if fdbData, ok := fdb[name]; ok {
bridgeObj := ensureBridgeAugment(ifaceObj)
var fdbObj any
if err := json.Unmarshal(fdbData, &fdbObj); err == nil {
bridgeObj["fdb"] = fdbObj
}
}
if mdbData, ok := mdb[name]; ok {
bridgeObj := ensureBridgeAugment(ifaceObj)
var mdbObj any
if err := json.Unmarshal(mdbData, &mdbObj); err == nil {
bridgeObj["mdb"] = mdbObj
}
}
ifaceArr[i] = ifaceObj
}
out, err := json.Marshal(root)
if err != nil {
return doc
}
return json.RawMessage(out)
}
// ensureBridgeAugment returns the bridge augment object within an
// interface, creating it if necessary.
func ensureBridgeAugment(ifaceObj map[string]any) map[string]any {
key := "infix-interfaces:bridge"
if existing, ok := ifaceObj[key]; ok {
if m, ok := existing.(map[string]any); ok {
return m
}
}
bridgeObj := map[string]any{}
ifaceObj[key] = bridgeObj
return bridgeObj
}
func copyStringMap(m map[string]json.RawMessage) map[string]json.RawMessage {
if len(m) == 0 {
return nil
}
cp := make(map[string]json.RawMessage, len(m))
for k, v := range m {
cp[k] = v
}
return cp
}
func (m *NLMonitor) updateOperStatus(ifname string, raw json.RawMessage) {
status, ok := extractOperStatus(raw)
if !ok {
return
}
prev, had := m.lastOperStatus[ifname]
m.lastOperStatus[ifname] = status
if had && prev != status {
m.log.Info("oper-status transition", "ifname", ifname, "from", prev, "to", status)
}
}
func (m *NLMonitor) queryIP(command string) (json.RawMessage, error) {
raw, err := m.batch.Query(command)
if err != nil {
if errors.Is(err, ipbatch.ErrBatchDead) {
m.log.Warn("ip batch dead", "command", command, "err", err)
return nil, err
}
m.log.Error("ip batch query failed", "command", command, "err", err)
return nil, err
}
return raw, nil
}
func (m *NLMonitor) queryBridge(command string) (json.RawMessage, error) {
raw, err := m.brBatch.Query(command)
if err != nil {
if errors.Is(err, bridgebatch.ErrBatchDead) {
m.log.Warn("bridge batch dead", "command", command, "err", err)
return nil, err
}
m.log.Error("bridge batch query failed", "command", command, "err", err)
return nil, err
}
return raw, nil
}
func (m *NLMonitor) subscribeBridgeMDB(ctx context.Context, ch chan<- struct{}, errorCallback func(error)) error {
sock, err := nl.Subscribe(syscall.NETLINK_ROUTE, 26)
if err != nil {
return err
}
go func() {
defer close(ch)
defer sock.Close()
for {
select {
case <-ctx.Done():
return
default:
}
msgs, _, err := sock.Receive()
if err != nil {
if ctx.Err() != nil {
return
}
errorCallback(err)
return
}
if len(msgs) == 0 {
continue
}
select {
case ch <- struct{}{}:
default:
}
}
}()
return nil
}
func ifNameByIndex(index int) (string, error) {
iface, err := net.InterfaceByIndex(index)
if err != nil {
return "", err
}
return iface.Name, nil
}
func linkNameFromUpdate(update netlink.LinkUpdate) (string, bool) {
if update.Link != nil && update.Link.Attrs() != nil && update.Link.Attrs().Name != "" {
return update.Link.Attrs().Name, true
}
if update.Index <= 0 {
return "", false
}
name, err := ifNameByIndex(int(update.Index))
if err != nil {
return "", false
}
return name, true
}
func isBridgeFDB(update netlink.NeighUpdate) bool {
if update.Family == syscall.AF_BRIDGE {
return true
}
if update.MasterIndex > 0 {
return true
}
if update.Flags&netlink.NTF_MASTER != 0 {
return true
}
return false
}
func bridgeNameFromNeigh(update netlink.NeighUpdate) (string, bool) {
if update.MasterIndex > 0 {
name, err := ifNameByIndex(update.MasterIndex)
if err == nil {
return name, true
}
}
if update.LinkIndex <= 0 {
return "", false
}
link, err := netlink.LinkByIndex(update.LinkIndex)
if err == nil && link != nil && link.Attrs() != nil && link.Attrs().MasterIndex > 0 {
name, err := ifNameByIndex(link.Attrs().MasterIndex)
if err == nil {
return name, true
}
}
return "", false
}
func extractOperStatus(raw json.RawMessage) (string, bool) {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil || len(rows) == 0 {
return "", false
}
stateRaw, ok := rows[0]["operstate"]
if !ok {
return "", false
}
var state string
if err := json.Unmarshal(stateRaw, &state); err != nil || state == "" {
return "", false
}
return state, true
}
func interfaceNames(raw json.RawMessage) []string {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return nil
}
names := make([]string, 0, len(rows))
seen := make(map[string]struct{}, len(rows))
for _, row := range rows {
ifnRaw, ok := row["ifname"]
if !ok {
continue
}
var ifname string
if err := json.Unmarshal(ifnRaw, &ifname); err != nil || ifname == "" {
continue
}
if _, ok := seen[ifname]; ok {
continue
}
seen[ifname] = struct{}{}
names = append(names, ifname)
}
return names
}
func filterByIfName(raw json.RawMessage, ifname string) json.RawMessage {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return json.RawMessage(`[]`)
}
filtered := make([]map[string]json.RawMessage, 0, 1)
for _, row := range rows {
ifnRaw, ok := row["ifname"]
if !ok {
continue
}
var name string
if err := json.Unmarshal(ifnRaw, &name); err != nil {
continue
}
if name == ifname {
filtered = append(filtered, row)
}
}
out, err := json.Marshal(filtered)
if err != nil {
return json.RawMessage(`[]`)
}
return json.RawMessage(out)
}
// replaceByIfName replaces all entries for ifname in the bulk array
// with entries from perIface, and returns the updated full array.
func replaceByIfName(bulk json.RawMessage, ifname string, perIface json.RawMessage) json.RawMessage {
var bulkRows []json.RawMessage
if err := json.Unmarshal(bulk, &bulkRows); err != nil {
return perIface
}
kept := make([]json.RawMessage, 0, len(bulkRows))
for _, row := range bulkRows {
var obj map[string]json.RawMessage
if err := json.Unmarshal(row, &obj); err != nil {
kept = append(kept, row)
continue
}
ifnRaw, ok := obj["ifname"]
if !ok {
kept = append(kept, row)
continue
}
var name string
if err := json.Unmarshal(ifnRaw, &name); err != nil || name != ifname {
kept = append(kept, row)
}
}
var newRows []json.RawMessage
if err := json.Unmarshal(perIface, &newRows); err == nil {
kept = append(kept, newRows...)
}
out, err := json.Marshal(kept)
if err != nil {
return bulk
}
return json.RawMessage(out)
}
func bridgeNames(raw json.RawMessage) []string {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return nil
}
names := make([]string, 0, len(rows))
seen := make(map[string]struct{}, len(rows))
for _, row := range rows {
brRaw, ok := row["br"]
if !ok {
continue
}
var name string
if err := json.Unmarshal(brRaw, &name); err != nil || name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
return names
}
func filterByBridge(raw json.RawMessage, bridgeName string) json.RawMessage {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return json.RawMessage(`[]`)
}
filtered := make([]map[string]json.RawMessage, 0, 1)
for _, row := range rows {
brRaw, ok := row["br"]
if !ok {
continue
}
var br string
if err := json.Unmarshal(brRaw, &br); err != nil {
continue
}
if br == bridgeName {
filtered = append(filtered, row)
}
}
out, err := json.Marshal(filtered)
if err != nil {
return json.RawMessage(`[]`)
}
return json.RawMessage(out)
}