package collector import ( "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "path" "strconv" "strings" "time" "git.mchus.pro/mchus/logpile/internal/models" ) type RedfishConnector struct { timeout time.Duration } func NewRedfishConnector() *RedfishConnector { return &RedfishConnector{ timeout: 10 * time.Second, } } func (c *RedfishConnector) Protocol() string { return "redfish" } func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) { baseURL, err := c.baseURL(req) if err != nil { return nil, err } client := c.httpClient(req) if emit != nil { emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."}) } if _, err := c.getJSON(ctx, client, req, baseURL, "/redfish/v1"); err != nil { return nil, fmt.Errorf("redfish service root: %w", err) } if emit != nil { emit(Progress{Status: "running", Progress: 30, Message: "Redfish: чтение данных системы..."}) } systemDoc, err := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Systems/1") if err != nil { return nil, fmt.Errorf("system info: %w", err) } biosDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Bios") secureBootDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Systems/1/SecureBoot") if emit != nil { emit(Progress{Status: "running", Progress: 55, Message: "Redfish: чтение CPU/RAM/Storage..."}) } processors, _ := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Processors") memory, _ := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Memory") storageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Storage") storageDevices := c.collectStorage(ctx, client, req, baseURL, storageMembers) if emit != nil { emit(Progress{Status: "running", Progress: 80, Message: "Redfish: чтение сетевых и BMC настроек..."}) } nics := c.collectNICs(ctx, client, req, baseURL) managerDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Managers/1") networkProtocolDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Managers/1/NetworkProtocol") result := &models.AnalysisResult{ Events: make([]models.Event, 0), FRU: make([]models.FRUInfo, 0), Sensors: make([]models.SensorReading, 0), Hardware: &models.HardwareConfig{ BoardInfo: parseBoardInfo(systemDoc), CPUs: parseCPUs(processors), Memory: parseMemory(memory), Storage: storageDevices, NetworkAdapters: nics, Firmware: parseFirmware(systemDoc, biosDoc, managerDoc, secureBootDoc, networkProtocolDoc), }, } return result, nil } func (c *RedfishConnector) httpClient(req Request) *http.Client { transport := &http.Transport{} if req.TLSMode == "insecure" { transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec } return &http.Client{ Transport: transport, Timeout: c.timeout, } } func (c *RedfishConnector) baseURL(req Request) (string, error) { host := strings.TrimSpace(req.Host) if host == "" { return "", fmt.Errorf("empty host") } if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") { u, err := url.Parse(host) if err != nil { return "", fmt.Errorf("invalid host URL: %w", err) } u.Path = "" u.RawQuery = "" u.Fragment = "" return strings.TrimRight(u.String(), "/"), nil } scheme := "https" if req.TLSMode == "insecure" && req.Port == 80 { scheme = "http" } return fmt.Sprintf("%s://%s:%d", scheme, host, req.Port), nil } func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Client, req Request, baseURL string, storageMembers []map[string]interface{}) []models.Storage { var out []models.Storage for _, member := range storageMembers { drives, ok := member["Drives"].([]interface{}) if !ok { continue } for _, driveAny := range drives { driveRef, ok := driveAny.(map[string]interface{}) if !ok { continue } odata := asString(driveRef["@odata.id"]) if odata == "" { continue } driveDoc, err := c.getJSON(ctx, client, req, baseURL, odata) if err != nil { continue } out = append(out, parseDrive(driveDoc)) } } return out } func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client, req Request, baseURL string) []models.NetworkAdapter { adapterDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Chassis/1/NetworkAdapters") if err != nil { return nil } nics := make([]models.NetworkAdapter, 0, len(adapterDocs)) for _, doc := range adapterDocs { nics = append(nics, parseNIC(doc)) } return nics } func (c *RedfishConnector) getCollectionMembers(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string) ([]map[string]interface{}, error) { collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath) if err != nil { return nil, err } refs, ok := collection["Members"].([]interface{}) if !ok || len(refs) == 0 { return []map[string]interface{}{}, nil } out := make([]map[string]interface{}, 0, len(refs)) for _, refAny := range refs { ref, ok := refAny.(map[string]interface{}) if !ok { continue } memberPath := asString(ref["@odata.id"]) if memberPath == "" { continue } memberDoc, err := c.getJSON(ctx, client, req, baseURL, memberPath) if err != nil { continue } out = append(out, memberDoc) } return out, nil } func (c *RedfishConnector) getJSON(ctx context.Context, client *http.Client, req Request, baseURL, requestPath string) (map[string]interface{}, error) { rel := requestPath if rel == "" { rel = "/" } if !strings.HasPrefix(rel, "/") { rel = "/" + rel } u, err := url.Parse(baseURL) if err != nil { return nil, err } u.Path = path.Join(strings.TrimSuffix(u.Path, "/"), rel) httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } httpReq.Header.Set("Accept", "application/json") switch req.AuthType { case "password": httpReq.SetBasicAuth(req.Username, req.Password) case "token": httpReq.Header.Set("X-Auth-Token", req.Token) httpReq.Header.Set("Authorization", "Bearer "+req.Token) } resp, err := client.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("status %d from %s: %s", resp.StatusCode, requestPath, strings.TrimSpace(string(body))) } var doc map[string]interface{} dec := json.NewDecoder(resp.Body) dec.UseNumber() if err := dec.Decode(&doc); err != nil { return nil, err } return doc, nil } func parseBoardInfo(system map[string]interface{}) models.BoardInfo { return models.BoardInfo{ Manufacturer: asString(system["Manufacturer"]), ProductName: firstNonEmpty(asString(system["Model"]), asString(system["Name"])), SerialNumber: asString(system["SerialNumber"]), PartNumber: asString(system["PartNumber"]), UUID: asString(system["UUID"]), } } func parseCPUs(docs []map[string]interface{}) []models.CPU { cpus := make([]models.CPU, 0, len(docs)) for idx, doc := range docs { cpus = append(cpus, models.CPU{ Socket: idx, Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), Cores: asInt(doc["TotalCores"]), Threads: asInt(doc["TotalThreads"]), FrequencyMHz: asInt(doc["OperatingSpeedMHz"]), MaxFreqMHz: asInt(doc["MaxSpeedMHz"]), SerialNumber: asString(doc["SerialNumber"]), }) } return cpus } func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM { out := make([]models.MemoryDIMM, 0, len(docs)) for _, doc := range docs { slot := firstNonEmpty(asString(doc["DeviceLocator"]), asString(doc["Name"]), asString(doc["Id"])) present := true if strings.EqualFold(asString(doc["Status"]), "Absent") { present = false } if status, ok := doc["Status"].(map[string]interface{}); ok { state := asString(status["State"]) if strings.EqualFold(state, "Absent") || strings.EqualFold(state, "Disabled") { present = false } } out = append(out, models.MemoryDIMM{ Slot: slot, Location: slot, Present: present, SizeMB: asInt(doc["CapacityMiB"]), Type: firstNonEmpty(asString(doc["MemoryDeviceType"]), asString(doc["MemoryType"])), MaxSpeedMHz: asInt(doc["MaxSpeedMHz"]), CurrentSpeedMHz: asInt(doc["OperatingSpeedMhz"]), Manufacturer: asString(doc["Manufacturer"]), SerialNumber: asString(doc["SerialNumber"]), PartNumber: asString(doc["PartNumber"]), Status: mapStatus(doc["Status"]), }) } return out } func parseDrive(doc map[string]interface{}) models.Storage { sizeGB := asInt(doc["CapacityBytes"]) / (1024 * 1024 * 1024) if sizeGB == 0 { sizeGB = asInt(doc["CapacityGB"]) } return models.Storage{ Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])), Type: firstNonEmpty(asString(doc["MediaType"]), asString(doc["Protocol"])), Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), SizeGB: sizeGB, SerialNumber: asString(doc["SerialNumber"]), Manufacturer: asString(doc["Manufacturer"]), Firmware: asString(doc["Revision"]), Interface: asString(doc["Protocol"]), Present: true, } } func parseNIC(doc map[string]interface{}) models.NetworkAdapter { return models.NetworkAdapter{ Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])), Location: asString(doc["Location"]), Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"), Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), Vendor: asString(doc["Manufacturer"]), SerialNumber: asString(doc["SerialNumber"]), PartNumber: asString(doc["PartNumber"]), Status: mapStatus(doc["Status"]), } } func parseFirmware(system, bios, manager, secureBoot, networkProtocol map[string]interface{}) []models.FirmwareInfo { var out []models.FirmwareInfo appendFW := func(name, version string) { version = strings.TrimSpace(version) if version == "" { return } out = append(out, models.FirmwareInfo{DeviceName: name, Version: version}) } appendFW("BIOS", asString(system["BiosVersion"])) appendFW("BIOS", asString(bios["Version"])) appendFW("BMC", asString(manager["FirmwareVersion"])) appendFW("SecureBoot", asString(secureBoot["SecureBootCurrentBoot"])) appendFW("NetworkProtocol", asString(networkProtocol["Id"])) return out } func mapStatus(statusAny interface{}) string { if statusAny == nil { return "" } if statusMap, ok := statusAny.(map[string]interface{}); ok { health := asString(statusMap["Health"]) state := asString(statusMap["State"]) return firstNonEmpty(health, state) } return asString(statusAny) } func asString(v interface{}) string { switch value := v.(type) { case nil: return "" case string: return strings.TrimSpace(value) case json.Number: return value.String() default: return strings.TrimSpace(fmt.Sprintf("%v", value)) } } func asInt(v interface{}) int { switch value := v.(type) { case nil: return 0 case int: return value case int64: return int(value) case float64: return int(value) case json.Number: if i, err := value.Int64(); err == nil { return int(i) } if f, err := value.Float64(); err == nil { return int(f) } case string: if value == "" { return 0 } if i, err := strconv.Atoi(value); err == nil { return i } } return 0 } func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" }