diff --git a/agent/agent.go b/agent/agent.go index e181aac6d..616428186 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -48,6 +48,7 @@ type Agent struct { keys []gossh.PublicKey // SSH public keys smartManager *SmartManager // Manages SMART data systemdManager *systemdManager // Manages systemd services + networkIO NetworkIOProvider // NetworkIOProvider implementation (e.g., SNMP-based for MikroTik) } // NewAgent creates a new agent with the given data directory for persisting data. @@ -121,6 +122,18 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { // initialize handler registry agent.handlerRegistry = NewHandlerRegistry() + // Initialize networkIO provider first (before initializeNetIoStats needs it) + if target, exists := utils.GetEnv("SNMP_TARGET"); exists { + slog.Info("Mikrotik device detected. Adding Mikrotik SNMP stats to network stats.") + agent.networkIO, err = SNMP_NetworkIO(target) + if err != nil { + slog.Error("Failed to initialize SNMP network IO", "err", err) + } + } else { + slog.Info("Non-Mikrotik device detected. Adding local network stats.") + agent.networkIO = &localNetworkIO{} + } + // initialize disk info agent.initializeDiskInfo() diff --git a/agent/network.go b/agent/network.go index 933ecc5e4..1a7d0aa99 100644 --- a/agent/network.go +++ b/agent/network.go @@ -81,12 +81,15 @@ func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats a.ensureNetInterfacesInitialized() a.ensureNetworkInterfacesMap(systemStats) - - if netIO, err := psutilNet.IOCounters(true); err == nil { - nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs) - totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats) - bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis) - a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond) + // [upload bytes, download bytes, total upload, total download] + netIO, err := a.networkIO.IOCounters() + if err == nil { + if netIO != nil { + nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs) + totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats) + bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis) + a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond) + } } } @@ -102,7 +105,9 @@ func (a *Agent) initializeNetIoStats() { } // get current network I/O stats and record valid interfaces - if netIO, err := psutilNet.IOCounters(true); err == nil { + // also if in here + netIO, err := a.networkIO.IOCounters() + if err == nil { for _, v := range netIO { if skipNetworkInterface(v, nicCfg) { continue @@ -159,6 +164,7 @@ func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, ne tracker.Cycle() for _, v := range netIO { + // fmt.Println("_", v) if _, exists := a.netInterfaces[v.Name]; !exists { continue } diff --git a/agent/snmp.go b/agent/snmp.go new file mode 100644 index 000000000..3b383df8e --- /dev/null +++ b/agent/snmp.go @@ -0,0 +1,316 @@ +package agent + +import ( + "encoding/hex" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/gosnmp/gosnmp" + "github.com/henrygd/beszel/agent/utils" + psutilNet "github.com/shirou/gopsutil/v4/net" +) + +// isIfPhysAddressOID returns true for ifPhysAddress (.1.3.6.1.2.1.2.2.1.6) +func isIfPhysAddressOID(oid string) bool { + return strings.HasPrefix(oid, ".1.3.6.1.2.1.2.2.1.6") || + strings.HasPrefix(oid, "1.3.6.1.2.1.2.2.1.6") +} + +// formatMAC converts a 6-byte slice to aa:bb:cc:dd:ee:ff +func formatMAC(b []byte) string { + if len(b) == 0 { + return "" + } + if len(b) == 6 { // common MAC length + parts := make([]string, 6) + for i := 0; i < 6; i++ { + parts[i] = fmt.Sprintf("%02x", b[i]) + } + return strings.Join(parts, ":") + } + // Fallback: hex string + return hex.EncodeToString(b) +} + +// PrettyValue returns a Go string you can print safely for a varbind. +func SNMP_PrettyValue(vb gosnmp.SnmpPDU) string { + switch vb.Type { + case gosnmp.OctetString: + b, _ := vb.Value.([]byte) + if isIfPhysAddressOID(vb.Name) { + return formatMAC(b) + } + // Heuristic: printable ASCII? else hex + printable := true + for _, c := range b { + if c < 0x09 || (c > 0x0d && c < 0x20) || c > 0x7e { + printable = false + break + } + } + if printable { + return string(b) + } + return hex.EncodeToString(b) + + case gosnmp.Integer: + fallthrough + case gosnmp.Counter32: + fallthrough + case gosnmp.Gauge32: + fallthrough + case gosnmp.TimeTicks: + fallthrough + case gosnmp.Uinteger32: + fallthrough + case gosnmp.Counter64: + return gosnmp.ToBigInt(vb.Value).String() + // return toBig(vb.Value).String() + + case gosnmp.IPAddress: + // gosnmp usually gives string for IP + if s, ok := vb.Value.(string); ok { + return s + } + // some agents may return []byte + if b, ok := vb.Value.([]byte); ok && len(b) == 4 { + return fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3]) + } + return fmt.Sprintf("%v", vb.Value) + + case gosnmp.ObjectIdentifier: + if s, ok := vb.Value.(string); ok { + return s + } + return fmt.Sprintf("%v", vb.Value) + + default: + // Fallback for Null, NoSuchInstance, EndOfMibView, etc. + return fmt.Sprintf("%v", vb.Value) + } +} + +type InterfacesFmt struct { + Name string + ActualMTU string + MACAddress string + AdminStatus string + OperStatus string + BytesIn string + PktsIn string + DiscardsIn string + ErrorsIn string + BytesOut string + PktsOut string + DiscardsOut string + ErrorsOut string + Map map[string]string +} +type SystemFmt struct { + Model string + Name string + Map map[string]string +} + +type Dictionary struct { + Interfaces InterfacesFmt + System SystemFmt +} + +func SNMP_OID() Dictionary { + iface := InterfacesFmt{ + Name: ".1.3.6.1.2.1.2.2.1.2.%v", + ActualMTU: ".1.3.6.1.2.1.2.2.1.4.%v", + MACAddress: ".1.3.6.1.2.1.2.2.1.6.%v", + AdminStatus: ".1.3.6.1.2.1.2.2.1.7.%v", + OperStatus: ".1.3.6.1.2.1.2.2.1.8.%v", + BytesIn: ".1.3.6.1.2.1.31.1.1.1.6.%v", + PktsIn: ".1.3.6.1.2.1.31.1.1.1.7.%v", + DiscardsIn: ".1.3.6.1.2.1.2.2.1.13.%v", + ErrorsIn: ".1.3.6.1.2.1.2.2.1.14.%v", + BytesOut: ".1.3.6.1.2.1.31.1.1.1.10.%v", + PktsOut: ".1.3.6.1.2.1.31.1.1.1.11.%v", + DiscardsOut: ".1.3.6.1.2.1.2.2.1.19.%v", + ErrorsOut: ".1.3.6.1.2.1.2.2.1.20.%v", + } + + // SNMP_OIDHelper(OIDHelper.Interfaces.Name, 1), // name + // SNMP_OIDHelper(OIDHelper.Interfaces.BytesIn, 1), // bytesRecv + // SNMP_OIDHelper(OIDHelper.Interfaces.BytesOut, 1), // bytesSent + // SNMP_OIDHelper(OIDHelper.Interfaces.PktsIn, 1), // packetsRecv + // SNMP_OIDHelper(OIDHelper.Interfaces.PktsOut, 1), // packetsSent + iface.Map = map[string]string{ + "name": iface.Name, + "actual-mtu": iface.ActualMTU, + "mac-address": iface.MACAddress, + "admin-status": iface.AdminStatus, + "oper-status": iface.OperStatus, + "bytesRecv": iface.BytesIn, // Real Value: bytes-in + "bytesSent": iface.BytesOut, // Real Value: bytes-out + "discards-in": iface.DiscardsIn, + "errors-in": iface.ErrorsIn, + "packetsRecv": iface.PktsIn, // Real Value: packets-in + "packetsSent": iface.PktsOut, // Real Value: packets-out + "discards-out": iface.DiscardsOut, + "errors-out": iface.ErrorsOut, + } + + system := SystemFmt{ + Model: ".1.3.6.1.2.1.1.1.0", + Name: ".1.3.6.1.2.1.1.5.0", + } + + system.Map = map[string]string{ + "model": system.Model, + "name": system.Name, + } + + return Dictionary{Interfaces: iface, System: system} +} + +// oid formats a %d-based template with an index. +func SNMP_OIDHelper(tmpl string, idx int) string { return fmt.Sprintf(tmpl, idx) } + +// ReverseLookup takes an OID like ".1.3.6.1.2.1.2.2.1.8.1" +// And returns e.g.: "oper-status", 1, true +func SNMP_ReverseOID(dict Dictionary, oid string) string { + for key, tmpl := range dict.Interfaces.Map { + // get prefix before %d + prefix := strings.Split(tmpl, "%v")[0] + if strings.HasPrefix(oid, prefix) { + return key + } + } + + return "unknown" +} + +func SNMP_Call(c *SNMPNetworkIO, oids []string) *gosnmp.SnmpPacket { + result, err := c.client.Get(oids) + if err != nil { + slog.Error("Get() err: ", "err", err) + } + return result +} + +func SNMP_getInterfaces(c *SNMPNetworkIO) []int { + Interfaces := []int{} + err := c.client.Walk(".1.3.6.1.2.1.2.2.1.1", func(pdu gosnmp.SnmpPDU) error { + idx := gosnmp.ToBigInt(pdu.Value).Int64() + Interfaces = append(Interfaces, int(idx)) + return nil + }) + if err != nil { + slog.Error("Walk() err: ", "err", err) + } + return Interfaces + +} + +func toUint64(s string) uint64 { + // return gosnmp.ToBigInt(s).Uint64() + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0 // or handle differently + } + return v +} + +// NetworkIOProvider abstracts network interface stats collection. +type NetworkIOProvider interface { + IOCounters() ([]psutilNet.IOCountersStat, error) +} + +// localNetworkIO reads from /proc via gopsutil (default). +type localNetworkIO struct{} + +// snmpNetworkIO reads interface stats from a remote SNMP target. +type SNMPNetworkIO struct { + client *gosnmp.GoSNMP +} + +func SNMP_NetworkIO(target string) (*SNMPNetworkIO, error) { + community, _ := utils.GetEnv("SNMP_COMMUNITY") + if community == "" { + community = "public" + } + port, _ := utils.GetEnv("SNMP_PORT") + if port == "" { + port = "161" + } + version, _ := utils.GetEnv("SNMP_VERSION") + snmpVersion := gosnmp.Version2c + switch version { + case "1": + snmpVersion = gosnmp.Version1 + case "2c": + snmpVersion = gosnmp.Version2c + case "3": + snmpVersion = gosnmp.Version3 + } + thisPort, _ := strconv.ParseUint(port, 10, 16) + client := &gosnmp.GoSNMP{ + Target: target, + Port: uint16(thisPort), + Community: community, + Version: snmpVersion, + Timeout: 2 * time.Second, + } + if err := client.Connect(); err != nil { + return nil, fmt.Errorf("snmp connect: %w", err) + } + return &SNMPNetworkIO{client: client}, nil +} +func (l *localNetworkIO) IOCounters() ([]psutilNet.IOCountersStat, error) { + return psutilNet.IOCounters(true) +} +func (c *SNMPNetworkIO) IOCounters() ([]psutilNet.IOCountersStat, error) { + // walk IF-MIB, + OIDHelper := SNMP_OID() + + // // Get System Name + // // oids := []string{"1.3.6.1.2.1.1.5.0"} + // Sample Input + // {"name":"utun0","bytesSent":525428373,"bytesRecv":12412570192,"packetsSent":2225169,"packetsRecv":10483780,"errin":0,"errout":0,"dropin":0,"dropout":0,"fifoin":0,"fifoout":0} + + InterfacesResult := []psutilNet.IOCountersStat{} + // Discover Interfaces + Interfaces := SNMP_getInterfaces(c) + + for _, pos := range Interfaces { + oids := []string{ + SNMP_OIDHelper(OIDHelper.Interfaces.Name, pos), // name + SNMP_OIDHelper(OIDHelper.Interfaces.BytesIn, pos), // bytesRecv + SNMP_OIDHelper(OIDHelper.Interfaces.BytesOut, pos), // bytesSent + SNMP_OIDHelper(OIDHelper.Interfaces.PktsIn, pos), // packetsRecv + SNMP_OIDHelper(OIDHelper.Interfaces.PktsOut, pos), // packetsSent + } + result := SNMP_Call(c, oids) + thisInterface := psutilNet.IOCountersStat{} + for _, vb := range result.Variables { + + key := SNMP_ReverseOID(OIDHelper, vb.Name) + val := SNMP_PrettyValue(vb) + switch key { + case "name": + thisInterface.Name = val + case "bytesRecv": + thisInterface.BytesRecv = toUint64(val) + case "bytesSent": + thisInterface.BytesSent = toUint64(val) + case "packetsRecv": + thisInterface.PacketsRecv = toUint64(val) + case "packetsSent": + thisInterface.PacketsSent = toUint64(val) + } + } + InterfacesResult = append(InterfacesResult, thisInterface) + } + // for _, v := range InterfacesResult { + // fmt.Println("_", v) + // } + return InterfacesResult, nil +} diff --git a/go.mod b/go.mod index d88318835..fffc6813e 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 github.com/gliderlabs/ssh v0.3.8 github.com/google/uuid v1.6.0 + github.com/gosnmp/gosnmp v1.43.2 github.com/lxzan/gws v1.9.1 github.com/nicholas-fedor/shoutrrr v0.14.3 github.com/pocketbase/dbx v1.12.0 @@ -20,6 +21,7 @@ require ( github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.49.0 golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 + golang.org/x/net v0.52.0 golang.org/x/sys v0.42.0 gopkg.in/yaml.v3 v3.0.1 howett.net/plist v1.0.1 @@ -56,7 +58,6 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/image v0.38.0 // indirect - golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/term v0.41.0 // indirect diff --git a/go.sum b/go.sum index 9cfd55e2c..f1b32b892 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gosnmp/gosnmp v1.43.2 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw= +github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=