package main import ( "fmt" "log/slog" "time" "unicode/utf8" "github.com/gosnmp/gosnmp" "github.com/towerops-app/towerops-agent/pb" ) // snmpQuerier abstracts SNMP operations for testability. type snmpQuerier interface { Get(oids []string) (*gosnmp.SnmpPacket, error) BulkWalkAll(rootOid string) ([]gosnmp.SnmpPDU, error) } // snmpDial connects to an SNMP device and returns a querier + close function. var snmpDial = func(dev *pb.SnmpDevice) (snmpQuerier, func(), error) { conn, err := newSnmpConn(dev) if err != nil { return nil, nil, err } return conn, func() { _ = conn.Conn.Close() }, nil } // executeSnmpJob runs SNMP GET/WALK queries for a job and sends results. func executeSnmpJob(job *pb.AgentJob, resultCh chan<- *pb.SnmpResult) { dev := job.SnmpDevice if dev == nil { slog.Error("job missing snmp device", "job_id", job.JobId) return } conn, closeFn, err := snmpDial(dev) if err != nil { slog.Error("snmp connect", "job_id", job.JobId, "device", dev.Ip, "error", err) return } defer closeFn() oidValues := make(map[string]string) for _, q := range job.Queries { switch q.QueryType { case pb.QueryType_GET: for _, oid := range q.Oids { result, err := conn.Get([]string{oid}) if err != nil { slog.Warn("snmp get failed", "device", dev.Ip, "oid", oid, "error", err) continue } for _, v := range result.Variables { if v.Type == gosnmp.NoSuchObject || v.Type == gosnmp.NoSuchInstance || v.Type == gosnmp.EndOfMibView { continue } oidValues[v.Name] = snmpValueToString(v) } } case pb.QueryType_WALK: for _, baseOID := range q.Oids { results, err := conn.BulkWalkAll(baseOID) if err != nil { slog.Warn("snmp walk failed", "device", dev.Ip, "oid", baseOID, "error", err) continue } for _, v := range results { if v.Type == gosnmp.NoSuchObject || v.Type == gosnmp.NoSuchInstance || v.Type == gosnmp.EndOfMibView { continue } oidValues[v.Name] = snmpValueToString(v) } } } } result := &pb.SnmpResult{ DeviceId: job.DeviceId, JobType: job.JobType, JobId: job.JobId, OidValues: oidValues, Timestamp: time.Now().Unix(), } slog.Info("snmp job complete", "job_id", job.JobId, "oids", len(oidValues)) select { case resultCh <- result: default: slog.Warn("result channel full", "job_id", job.JobId) } } // executeCredentialTest tests SNMP credentials by reading sysDescr.0. func executeCredentialTest(job *pb.AgentJob, resultCh chan<- *pb.CredentialTestResult) { dev := job.SnmpDevice if dev == nil { slog.Error("job missing snmp device", "job_id", job.JobId) return } conn, closeFn, err := snmpDial(dev) timestamp := time.Now().Unix() if err != nil { resultCh <- &pb.CredentialTestResult{ TestId: job.JobId, Success: false, ErrorMessage: fmt.Sprintf("connection failed: %v", err), Timestamp: timestamp, } return } defer closeFn() result, err := conn.Get([]string{"1.3.6.1.2.1.1.1.0"}) if err != nil { resultCh <- &pb.CredentialTestResult{ TestId: job.JobId, Success: false, ErrorMessage: fmt.Sprintf("SNMP test failed: %v", err), Timestamp: timestamp, } return } sysDescr := "" if len(result.Variables) > 0 { sysDescr = snmpValueToString(result.Variables[0]) } resultCh <- &pb.CredentialTestResult{ TestId: job.JobId, Success: true, SystemDescription: sysDescr, Timestamp: timestamp, } } // newSnmpConn creates a gosnmp.GoSNMP connection from protobuf device config. func newSnmpConn(dev *pb.SnmpDevice) (*gosnmp.GoSNMP, error) { conn := &gosnmp.GoSNMP{ Target: dev.Ip, Port: uint16(dev.Port), Timeout: 10 * time.Second, Retries: 2, MaxRepetitions: 10, } // Transport if dev.Transport == "tcp" { conn.Transport = "tcp" } // Version + auth switch dev.Version { case "1", "v1": conn.Version = gosnmp.Version1 conn.Community = dev.Community case "3", "v3": conn.Version = gosnmp.Version3 conn.SecurityModel = gosnmp.UserSecurityModel usmParams := &gosnmp.UsmSecurityParameters{ UserName: dev.V3Username, } switch dev.V3SecurityLevel { case "authPriv": conn.MsgFlags = gosnmp.AuthPriv usmParams.AuthenticationPassphrase = dev.V3AuthPassword usmParams.PrivacyPassphrase = dev.V3PrivPassword usmParams.AuthenticationProtocol = mapAuthProtocol(dev.V3AuthProtocol) usmParams.PrivacyProtocol = mapPrivProtocol(dev.V3PrivProtocol) case "authNoPriv": conn.MsgFlags = gosnmp.AuthNoPriv usmParams.AuthenticationPassphrase = dev.V3AuthPassword usmParams.AuthenticationProtocol = mapAuthProtocol(dev.V3AuthProtocol) default: // noAuthNoPriv conn.MsgFlags = gosnmp.NoAuthNoPriv } conn.SecurityParameters = usmParams default: // "2c", "v2c", "2", "" conn.Version = gosnmp.Version2c conn.Community = dev.Community } if err := conn.Connect(); err != nil { return nil, fmt.Errorf("snmp connect %s:%d: %w", dev.Ip, dev.Port, err) } return conn, nil } func mapAuthProtocol(p string) gosnmp.SnmpV3AuthProtocol { switch p { case "MD5": return gosnmp.MD5 case "SHA", "SHA-1": return gosnmp.SHA case "SHA-224": return gosnmp.SHA224 case "SHA-256": return gosnmp.SHA256 case "SHA-384": return gosnmp.SHA384 case "SHA-512": return gosnmp.SHA512 default: return gosnmp.SHA } } func mapPrivProtocol(p string) gosnmp.SnmpV3PrivProtocol { switch p { case "DES": return gosnmp.DES case "AES", "AES-128": return gosnmp.AES case "AES-192": return gosnmp.AES192 case "AES-256": return gosnmp.AES256 case "AES-192-C": return gosnmp.AES192C case "AES-256-C": return gosnmp.AES256C default: return gosnmp.AES } } // snmpValueToString converts a gosnmp PDU value to a string. func snmpValueToString(pdu gosnmp.SnmpPDU) string { switch pdu.Type { case gosnmp.Integer: return fmt.Sprintf("%d", gosnmp.ToBigInt(pdu.Value).Int64()) case gosnmp.OctetString: b := pdu.Value.([]byte) if !utf8.Valid(b) { return formatHex(b) } // Check for non-printable control chars for _, c := range b { if c < 0x20 && c != '\n' && c != '\r' && c != '\t' { return formatHex(b) } } return string(b) case gosnmp.ObjectIdentifier: return pdu.Value.(string) case gosnmp.Counter32: return fmt.Sprintf("%d", pdu.Value.(uint)) case gosnmp.Counter64: return fmt.Sprintf("%d", pdu.Value.(uint64)) case gosnmp.Gauge32: return fmt.Sprintf("%d", pdu.Value.(uint)) case gosnmp.TimeTicks: return fmt.Sprintf("%d", pdu.Value.(uint32)) case gosnmp.IPAddress: return pdu.Value.(string) case gosnmp.Null, gosnmp.NoSuchObject, gosnmp.NoSuchInstance, gosnmp.EndOfMibView: return "null" case gosnmp.Opaque: return formatHex(pdu.Value.([]byte)) default: return fmt.Sprintf("%v", pdu.Value) } } func formatHex(b []byte) string { if len(b) == 0 { return "" } parts := make([]string, len(b)) for i, v := range b { parts[i] = fmt.Sprintf("%02x", v) } result := "" for i, p := range parts { if i > 0 { result += ":" } result += p } return result }