| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package client |
| 4 | |
| 5 | import ( |
| 6 | "crypto/md5" |
| 7 | "crypto/sha256" |
| 8 | "encoding/json" |
| 9 | "fmt" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "path" |
| 13 | "sync" |
| 14 | |
| 15 | "github.com/netdata/netdata/go/plugins/pkg/web" |
| 16 | ) |
| 17 | |
| 18 | // New creates a new PowerVault MCI REST API client. |
| 19 | func New(client web.ClientConfig, request web.RequestConfig, digest string) (*Client, error) { |
| 20 | httpClient, err := web.NewHTTPClient(client) |
| 21 | if err != nil { |
| 22 | return nil, err |
| 23 | } |
| 24 | |
| 25 | return &Client{ |
| 26 | request: request, |
| 27 | httpClient: httpClient, |
| 28 | digest: digest, |
| 29 | }, nil |
| 30 | } |
| 31 | |
| 32 | // Client represents a Dell PowerVault MCI API client. |
| 33 | type Client struct { |
| 34 | request web.RequestConfig |
| 35 | httpClient *http.Client |
| 36 | digest string // "sha256" or "md5" |
| 37 | |
| 38 | mu sync.Mutex // protects sessionKey reads/writes |
| 39 | sessionKey string |
| 40 | authMu sync.Mutex // serializes re-authentication |
| 41 | } |
| 42 | |
| 43 | // Login authenticates with the PowerVault API. |
| 44 | // Hashes "username_password" with SHA-256 or MD5, then GET /api/login/<hash>. |
| 45 | func (c *Client) Login() error { |
| 46 | key, err := c.login() |
| 47 | if err != nil { |
| 48 | return err |
| 49 | } |
| 50 | c.setSessionKey(key) |
| 51 | return nil |
| 52 | } |
| 53 | |
| 54 | // login authenticates and returns the session key without publishing it. |
| 55 | func (c *Client) login() (string, error) { |
| 56 | hash := c.authHash() |
| 57 | |
| 58 | req := c.newRequest("/api/login/" + hash) |
| 59 | |
| 60 | resp, err := c.doOK(req) |
| 61 | defer web.CloseBody(resp) |
| 62 | if err != nil { |
| 63 | return "", fmt.Errorf("login failed: %v", err) |
| 64 | } |
| 65 | |
| 66 | var result struct { |
| 67 | Status []StatusResponse `json:"status"` |
| 68 | } |
| 69 | if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { |
| 70 | return "", fmt.Errorf("login: error decoding response: %v", err) |
| 71 | } |
| 72 | if len(result.Status) == 0 || result.Status[0].ResponseType != "Success" { |
| 73 | if len(result.Status) > 0 { |
| 74 | return "", fmt.Errorf("login: authentication failed: %s (rc=%d)", result.Status[0].Response, result.Status[0].ReturnCode) |
| 75 | } |
| 76 | return "", fmt.Errorf("login: authentication failed: empty status response") |
| 77 | } |
| 78 | |
| 79 | return result.Status[0].Response, nil |
| 80 | } |
| 81 | |
| 82 | func (c *Client) setSessionKey(key string) { |
| 83 | c.mu.Lock() |
| 84 | c.sessionKey = key |
| 85 | c.mu.Unlock() |
| 86 | } |
| 87 | |
| 88 | // SetLocale sets the CLI output to English to prevent locale-dependent parsing. |
| 89 | func (c *Client) SetLocale() error { |
| 90 | req := c.newSessionRequest("/api/set/cli-parameters/locale/English") |
| 91 | resp, err := c.doOK(req) |
| 92 | web.CloseBody(resp) |
| 93 | return err |
| 94 | } |
| 95 | |
| 96 | // setLocale sets the CLI locale to English using the given session key. |
| 97 | func (c *Client) setLocale(sessionKey string) error { |
| 98 | req := c.newRequest("/api/set/cli-parameters/locale/English") |
| 99 | if sessionKey != "" { |
| 100 | req.Headers["sessionKey"] = sessionKey |
| 101 | } |
| 102 | resp, err := c.doOK(req) |
| 103 | web.CloseBody(resp) |
| 104 | return err |
| 105 | } |
| 106 | |
| 107 | // System returns system information. |
| 108 | func (c *Client) System() ([]SystemInfo, error) { |
| 109 | return doShow[SystemInfo](c, "/api/show/system", "system") |
| 110 | } |
| 111 | |
| 112 | // Controllers returns all controllers. |
| 113 | func (c *Client) Controllers() ([]Controller, error) { |
| 114 | return doShow[Controller](c, "/api/show/controllers", "controllers") |
| 115 | } |
| 116 | |
| 117 | // Drives returns all drives. |
| 118 | func (c *Client) Drives() ([]Drive, error) { |
| 119 | return doShow[Drive](c, "/api/show/disks", "drives") |
| 120 | } |
| 121 | |
| 122 | // Fans returns all fans. |
| 123 | func (c *Client) Fans() ([]Fan, error) { |
| 124 | return doShow[Fan](c, "/api/show/fans", "fan") |
| 125 | } |
| 126 | |
| 127 | // PowerSupplies returns all power supplies. |
| 128 | func (c *Client) PowerSupplies() ([]PowerSupply, error) { |
| 129 | return doShow[PowerSupply](c, "/api/show/power-supplies", "power-supplies") |
| 130 | } |
| 131 | |
| 132 | // Sensors returns all sensor readings. |
| 133 | func (c *Client) Sensors() ([]Sensor, error) { |
| 134 | return doShow[Sensor](c, "/api/show/sensor-status", "sensors") |
| 135 | } |
| 136 | |
| 137 | // FRUs returns all Field Replaceable Units. |
| 138 | func (c *Client) FRUs() ([]FRU, error) { |
| 139 | return doShow[FRU](c, "/api/show/frus", "enclosure-fru") |
| 140 | } |
| 141 | |
| 142 | // Volumes returns all volumes. |
| 143 | func (c *Client) Volumes() ([]Volume, error) { |
| 144 | return doShow[Volume](c, "/api/show/volumes", "volumes") |
| 145 | } |
| 146 | |
| 147 | // Pools returns all storage pools. |
| 148 | func (c *Client) Pools() ([]Pool, error) { |
| 149 | return doShow[Pool](c, "/api/show/pools", "pools") |
| 150 | } |
| 151 | |
| 152 | // Ports returns all host ports. |
| 153 | func (c *Client) Ports() ([]Port, error) { |
| 154 | return doShow[Port](c, "/api/show/ports", "port") |
| 155 | } |
| 156 | |
| 157 | // ControllerStatistics returns performance stats for all controllers. |
| 158 | func (c *Client) ControllerStatistics() ([]ControllerStats, error) { |
| 159 | return doShow[ControllerStats](c, "/api/show/controller-statistics", "controller-statistics") |
| 160 | } |
| 161 | |
| 162 | // VolumeStatistics returns performance stats for all volumes. |
| 163 | func (c *Client) VolumeStatistics() ([]VolumeStats, error) { |
| 164 | return doShow[VolumeStats](c, "/api/show/volume-statistics", "volume-statistics") |
| 165 | } |
| 166 | |
| 167 | // PortStatistics returns I/O stats for all host ports. |
| 168 | func (c *Client) PortStatistics() ([]PortStats, error) { |
| 169 | return doShow[PortStats](c, "/api/show/host-port-statistics", "host-port-statistics") |
| 170 | } |
| 171 | |
| 172 | // PhyStatistics returns SAS PHY error stats. |
| 173 | func (c *Client) PhyStatistics() ([]PhyStats, error) { |
| 174 | return doShow[PhyStats](c, "/api/show/host-phy-statistics", "sas-host-phy-statistics") |
| 175 | } |
| 176 | |
| 177 | // doShow fetches a /api/show/<command> endpoint and extracts the named array from the response. |
| 178 | // Re-authenticates once on 401 (session expiry), including locale reset. |
| 179 | func doShow[T any](c *Client, urlPath, key string) ([]T, error) { |
| 180 | req := c.newSessionRequest(urlPath) |
| 181 | |
| 182 | resp, err := c.doOK(req) |
| 183 | if err != nil && resp != nil && resp.StatusCode == http.StatusUnauthorized { |
| 184 | if loginErr := c.reAuth(); loginErr != nil { |
| 185 | return nil, fmt.Errorf("%s: session expired and re-auth failed: %v", urlPath, loginErr) |
| 186 | } |
| 187 | req = c.newSessionRequest(urlPath) |
| 188 | resp, err = c.doOK(req) |
| 189 | } |
| 190 | defer web.CloseBody(resp) |
| 191 | if err != nil { |
| 192 | return nil, err |
| 193 | } |
| 194 | |
| 195 | var raw map[string]json.RawMessage |
| 196 | if err = json.NewDecoder(resp.Body).Decode(&raw); err != nil { |
| 197 | return nil, fmt.Errorf("%s: error decoding response: %v", urlPath, err) |
| 198 | } |
| 199 | |
| 200 | // Check for API-level errors in the status envelope. |
| 201 | if statusData, ok := raw["status"]; ok { |
| 202 | var statuses []StatusResponse |
| 203 | if json.Unmarshal(statusData, &statuses) == nil { |
| 204 | for _, s := range statuses { |
| 205 | if s.ResponseType == "Error" { |
| 206 | return nil, fmt.Errorf("%s: API error: %s (rc=%d)", urlPath, s.Response, s.ReturnCode) |
| 207 | } |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | data, ok := raw[key] |
| 213 | if !ok { |
| 214 | return nil, nil |
| 215 | } |
| 216 | |
| 217 | var result []T |
| 218 | if err = json.Unmarshal(data, &result); err != nil { |
| 219 | return nil, fmt.Errorf("%s: error decoding %q: %v", urlPath, key, err) |
| 220 | } |
| 221 | return result, nil |
| 222 | } |
| 223 | |
| 224 | // reAuth re-authenticates and restores session locale. |
| 225 | // Serialized so concurrent 401 retries don't race on session state. |
| 226 | // The new session key is published only after locale setup, preventing |
| 227 | // concurrent requests from seeing a session with non-English locale. |
| 228 | func (c *Client) reAuth() error { |
| 229 | c.authMu.Lock() |
| 230 | defer c.authMu.Unlock() |
| 231 | key, err := c.login() |
| 232 | if err != nil { |
| 233 | return err |
| 234 | } |
| 235 | if err := c.setLocale(key); err != nil { |
| 236 | return err |
| 237 | } |
| 238 | c.setSessionKey(key) |
| 239 | return nil |
| 240 | } |
| 241 | |
| 242 | func (c *Client) authHash() string { |
| 243 | cred := c.request.Username + "_" + c.request.Password |
| 244 | if c.digest == "md5" { |
| 245 | return fmt.Sprintf("%x", md5.Sum([]byte(cred))) |
| 246 | } |
| 247 | return fmt.Sprintf("%x", sha256.Sum256([]byte(cred))) |
| 248 | } |
| 249 | |
| 250 | func (c *Client) newRequest(urlPath string) web.RequestConfig { |
| 251 | req := c.request.Copy() |
| 252 | u, _ := url.Parse(req.URL) |
| 253 | u.Path = path.Join(u.Path, urlPath) |
| 254 | req.URL = u.String() |
| 255 | // Clear basic auth — MCI API uses hash-based auth, not HTTP Basic. |
| 256 | req.Username = "" |
| 257 | req.Password = "" |
| 258 | if req.Headers == nil { |
| 259 | req.Headers = make(map[string]string) |
| 260 | } |
| 261 | req.Headers["datatype"] = "json" |
| 262 | return req |
| 263 | } |
| 264 | |
| 265 | func (c *Client) newSessionRequest(urlPath string) web.RequestConfig { |
| 266 | req := c.newRequest(urlPath) |
| 267 | |
| 268 | c.mu.Lock() |
| 269 | key := c.sessionKey |
| 270 | c.mu.Unlock() |
| 271 | |
| 272 | if key != "" { |
| 273 | req.Headers["sessionKey"] = key |
| 274 | } |
| 275 | return req |
| 276 | } |
| 277 | |
| 278 | func (c *Client) doOK(req web.RequestConfig) (*http.Response, error) { |
| 279 | httpReq, err := web.NewHTTPRequest(req) |
| 280 | if err != nil { |
| 281 | return nil, fmt.Errorf("error creating request to %s: %v", req.URL, err) |
| 282 | } |
| 283 | resp, err := c.httpClient.Do(httpReq) |
| 284 | if err != nil { |
| 285 | return resp, err |
| 286 | } |
| 287 | if resp.StatusCode < 200 || resp.StatusCode >= 400 { |
| 288 | web.CloseBody(resp) |
| 289 | // Return resp (body closed) so callers can inspect StatusCode. |
| 290 | return resp, fmt.Errorf("%s: HTTP %d", req.URL, resp.StatusCode) |
| 291 | } |
| 292 | return resp, nil |
| 293 | } |