diff --git a/.golangci.yml b/.golangci.yml index 058f79a5..57dc0699 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -8,7 +8,7 @@ linters: - dupl - errcheck - exportloopref - - goconst +# - goconst - gofmt - goimports - gosimple diff --git a/go.mod b/go.mod index 751ec3e7..69836f5f 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/golangci/golangci-lint v1.57.2 github.com/google/addlicense v1.1.1 github.com/google/uuid v1.6.0 + github.com/hashicorp/go-multierror v1.1.1 github.com/ironcore-dev/ipam v0.2.1 github.com/ironcore-dev/vgopath v0.1.4 github.com/onsi/ginkgo/v2 v2.17.1 @@ -16,6 +17,8 @@ require ( github.com/sethvargo/go-password v0.3.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 + github.com/stmcginnis/gofish v0.15.0 + golang.org/x/text v0.14.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.29.4 k8s.io/apimachinery v0.29.4 @@ -116,6 +119,7 @@ require ( github.com/gostaticanalysis/comment v1.4.2 // indirect github.com/gostaticanalysis/forcetypeassert v0.1.0 // indirect github.com/gostaticanalysis/nilerr v0.1.1 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect @@ -227,7 +231,6 @@ require ( golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.18.0 // indirect golang.org/x/term v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.19.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index b222c85f..7aff7038 100644 --- a/go.sum +++ b/go.sum @@ -322,6 +322,10 @@ github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.4.0 h1:nhdCmubdmDF6VEatUNjgUZBJKWRqugoISdUv3PPQgHY= github.com/gostaticanalysis/testutil v0.4.0/go.mod h1:bLIoPefWXrRi/ssLFWX1dx7Repi5x3CuviD3dgAZaBU= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -574,6 +578,8 @@ github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YE github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stbenjam/no-sprintf-host-port v0.1.1 h1:tYugd/yrm1O0dV+ThCbaKZh195Dfm07ysF0U6JQXczc= github.com/stbenjam/no-sprintf-host-port v0.1.1/go.mod h1:TLhvtIvONRzdmkFiio4O8LHsN9N74I+PhRquPsxpL0I= +github.com/stmcginnis/gofish v0.15.0 h1:8TG41+lvJk/0Nf8CIIYErxbMlQUy80W0JFRZP3Ld82A= +github.com/stmcginnis/gofish v0.15.0/go.mod h1:BLDSFTp8pDlf/xDbLZa+F7f7eW0E/CHCboggsu8CznI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= diff --git a/internal/bmc/bmc.go b/internal/bmc/bmc.go index e33b46fb..6694c6c7 100644 --- a/internal/bmc/bmc.go +++ b/internal/bmc/bmc.go @@ -3,7 +3,80 @@ package bmc +import ( + "context" + "fmt" + "regexp" + "time" + + "github.com/ironcore-dev/metal/internal/log" +) + +type BMC interface { + Type() string + Tags() map[string]string + Credentials() (Credentials, time.Time) + EnsureInitialCredentials(ctx context.Context, defaultCreds []Credentials, tempPassword string) error + Connect(ctx context.Context) error + CreateUser(ctx context.Context, creds Credentials, tempPassword string) error + DeleteUsers(ctx context.Context, regex *regexp.Regexp) error + ReadInfo(ctx context.Context) (Info, error) +} + +type LEDControl interface { + SetLocatorLED(ctx context.Context, state string) (string, error) +} + +type PowerControl interface { + PowerOn(ctx context.Context) error + PowerOff(ctx context.Context, immediate bool) error +} + +type ResetControl interface { + Reset(ctx context.Context, immediate bool) error +} + +type newBMCFunc func(tags map[string]string, host string, port int, creds Credentials, exp time.Time) BMC + +var ( + bmcs = make(map[string]newBMCFunc) +) + +func registerBMC(newFunc newBMCFunc) { + bmcs[newFunc(nil, "", 0, Credentials{}, time.Time{}).Type()] = newFunc +} + +func NewBMC(typ string, tags map[string]string, host string, port int, creds Credentials, exp time.Time) (BMC, error) { + newFunc, ok := bmcs[typ] + if !ok { + return nil, fmt.Errorf("BMC of type %s is not supported", typ) + } + + return newFunc(tags, host, port, creds, exp), nil +} + type Credentials struct { Username string `yaml:"username"` Password string `yaml:"password"` } + +type Info struct { + UUID string + Type string + Capabilities []string + SerialNumber string + SKU string + Manufacturer string + LocatorLED string + Power string + OS string + OSReason string + Console string + FWVersion string +} + +func must(ctx context.Context, err error) { + if err != nil { + log.Error(ctx, fmt.Errorf("impossible error (this should never happen lol): %w", err)) + } +} diff --git a/internal/bmc/ipmi.go b/internal/bmc/ipmi.go new file mode 100644 index 00000000..7478ebda --- /dev/null +++ b/internal/bmc/ipmi.go @@ -0,0 +1,402 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package bmc + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/hashicorp/go-multierror" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/ironcore-dev/metal/internal/log" +) + +func init() { + registerBMC(ipmiBMC) +} + +func ipmiBMC(tags map[string]string, host string, port int, creds Credentials, exp time.Time) BMC { + return &IPMIBMC{ + tags: tags, + host: host, + port: port, + creds: creds, + exp: exp, + } +} + +func (b *IPMIBMC) Type() string { + return "IPMI" +} + +func (b *IPMIBMC) Tags() map[string]string { + return b.tags +} + +func (b *IPMIBMC) PowerControl() PowerControl { + return b +} + +func (b *IPMIBMC) ResetControl() ResetControl { + return b +} + +func (b *IPMIBMC) Credentials() (Credentials, time.Time) { + return b.creds, b.exp +} + +type IPMIBMC struct { + tags map[string]string + host string + port int + creds Credentials + exp time.Time +} + +type IPMIUser struct { + id string + username string + enabled bool + //available bool +} + +// TODO: ipmi ciphers, should this also be tested? + +func outputmap(ml string, mi *map[string]string) { + for _, line := range strings.Split(ml, "\n") { + colon := strings.Index(line, ":") + if colon == -1 { + continue + } + k := strings.TrimSpace(line[:colon]) + v := strings.TrimSpace(line[colon+1:]) + if k == "" { + continue + } + (*mi)[k] = v + } +} + +func outputmapspace(ml string, mi *map[string]string) { + for _, line := range strings.Split(ml, "\n") { + line = strings.Join(strings.Fields(strings.TrimSpace(line)), " ") + if strings.HasPrefix(line, "#") { + continue + } + space := strings.Index(line, " ") + var k, v string + if space == -1 { + k = line + } else { + k = strings.TrimSpace(line[:space]) + v = strings.TrimSpace(line[space+1:]) + } + if k == "" { + continue + } + (*mi)[k] = v + } +} + +func ipmiFindWorkingCredentials(ctx context.Context, host string, port int, defaultCreds []Credentials, tempPassword string) (Credentials, error) { + if len(defaultCreds) == 0 { + return Credentials{}, fmt.Errorf("no default credentials to try") + } + + var merr error + for _, creds := range defaultCreds { + err := ipmiping(ctx, host, port, creds) + if err == nil { + return creds, nil + } + merr = multierror.Append(merr, err) + } + for _, creds := range defaultCreds { + err := ipmiping(ctx, host, port, Credentials{creds.Username, tempPassword}) + if err == nil { + return creds, nil + } + merr = multierror.Append(merr, err) + } + return Credentials{}, fmt.Errorf("cannot connect using any predefined credentials: %w", merr) +} + +func (b *IPMIBMC) EnsureInitialCredentials(ctx context.Context, defaultCreds []Credentials, tempPassword string) error { + creds, err := ipmiFindWorkingCredentials(ctx, b.host, b.port, defaultCreds, tempPassword) + if err != nil { + return err + } + + b.creds = creds + return nil +} + +func (b *IPMIBMC) Connect(ctx context.Context) error { + return ipmiping(ctx, b.host, b.port, b.creds) +} + +func (b *IPMIBMC) ReadInfo(ctx context.Context) (Info, error) { + log.Debug(ctx, "Reading BMC info", "host", b.host) + info := make(map[string]string) + + out, serr, err := ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "fru", "list", "0") + if err != nil { + return Info{}, fmt.Errorf("cannot get fru info, stderr: %s: %w", serr, err) + } + outputmap(out, &info) + + out, serr, err = ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "mc", "guid") + if err != nil { + return Info{}, fmt.Errorf("cannot get mc info, stderr: %s: %w", serr, err) + } + outputmap(out, &info) + + out, serr, err = ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmi-chassis", "--get-chassis-status") + if err != nil { + return Info{}, fmt.Errorf("cannot get chassis status, stderr: %s: %w", serr, err) + } + outputmap(out, &info) + + out, serr, err = ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "bmc", "info") + if err != nil { + return Info{}, fmt.Errorf("cannot get bmc info, stderr: %s: %w", serr, err) + } + outputmap(out, &info) + + uuid, ok := info["System GUID"] + if !ok { + return Info{}, fmt.Errorf("cannot determine uuid for machine") + } + uuid = strings.ToLower(uuid) + powerstate, ok := info["System Power"] + if !ok { + return Info{}, fmt.Errorf("cannot determine the power state for machine") + } + serial := info["Product Serial"] + sku := info["Product SKU"] + manufacturer := info["Manufacturer"] + //TODO: currently we can't handle this correctly as we can't read the state on most hardware + //led, ok := info["Chassis Identify State"] + led := "" + fw := info["Firmware Revision"] + + //TODO: properly detect if sol is supported + return Info{ + UUID: uuid, + Type: "BMC", + Capabilities: []string{"credentials", "power", "led", "console"}, + SerialNumber: serial, + SKU: sku, + Manufacturer: manufacturer, + LocatorLED: led, + Power: cases.Title(language.English).String(powerstate), + Console: "ipmi", + FWVersion: fw, + }, nil +} + +func ipmiGenerateCommand(ctx context.Context, host string, port int, creds Credentials, cmd ...string) ([]string, error) { + if port == 0 { + port = 623 + } + + var command []string + actualCmd := cmd[0] + params := cmd[1:] + log.Debug(ctx, "Executing IPMI command", "host", host, "command", actualCmd) + if actualCmd == "ipmitool" { + command = append(command, "/usr/bin/ipmitool", "-I", "lanplus", "-H", host, "-U", creds.Username, "-P", creds.Password, "-p", strconv.Itoa(port)) + command = append(command, params...) + } else if actualCmd == "ipmi-chassis" || actualCmd == "ipmi-config" { + // TODO: check how to provide custom port + command = append(command, "/usr/sbin/"+actualCmd, "-D", "LAN_2_0", "-h", host, "-u", creds.Username, "-p", creds.Password) + command = append(command, params...) + } else { + return command, fmt.Errorf("ipmi command not supported at the moment") + } + + return command, nil +} + +func ipmiExecuteCommand(ctx context.Context, host string, port int, creds Credentials, cmd ...string) (string, string, error) { + command, err := ipmiGenerateCommand(ctx, host, port, creds, cmd...) + if err != nil { + return "", "", err + } + actualCmd := command[0] + params := command[1:] + actualCmd, err = exec.LookPath(actualCmd) + if err != nil { + return "", "", fmt.Errorf("%s not found in the PATH: %w", actualCmd, err) + } + c := exec.Command(actualCmd, params...) + + var stdout, stderr bytes.Buffer + c.Stdout = &stdout + c.Stderr = &stderr + + // TODO: add timeout + err = c.Run() + if err != nil { + return "", "", fmt.Errorf("cannot excute command: %s, got: %w", cmd[0], err) + } + + return stdout.String(), stderr.String(), nil +} + +func ipmiping(ctx context.Context, host string, port int, creds Credentials) error { + // TODO: figure out a better way to test credentials + _, _, err := ipmiExecuteCommand(ctx, host, port, creds, "ipmitool", "lan", "print", "1") + if err != nil { + return fmt.Errorf("cannot use credentials for ipmi: %w", err) + } + return nil +} + +func ipmigetusers(ctx context.Context, host string, port int, creds Credentials) ([]IPMIUser, error) { + users := make([]IPMIUser, 0) + // TODO: determine amount of max slots before iterating + for i := 2; i <= 12; i++ { + info := make(map[string]string) + user := IPMIUser{} + id := strconv.Itoa(i) + out, _, err := ipmiExecuteCommand(ctx, host, port, creds, "ipmi-config", "--category=core", "--checkout", "-S", "User"+id) + if err != nil { + return nil, fmt.Errorf("cannot get IPMI users: %w", err) + } + outputmapspace(out, &info) + username, ok := info["Username"] + if !ok { + // TODO: maybe better to error out + continue + } else { + user.username = username + } + enabled, ok := info["Enable_User"] + if !ok { + return []IPMIUser{}, fmt.Errorf("cannot populate fields for user") + } + switch enabled { + case "Yes": + user.enabled = true + case "No": + user.enabled = false + default: + return []IPMIUser{}, fmt.Errorf("cannot populate fields for user / invalid Enable_User") + } + user.id = id + users = append(users, user) + } + return users, nil +} + +func ipmiGetFreeSlot(ctx context.Context, host string, port int, creds Credentials) (string, error) { + users, err := ipmigetusers(ctx, host, port, creds) + if err != nil { + return "", fmt.Errorf("cannot determine an empty slot: %w", err) + } + for _, user := range users { + if user.username == "" || !user.enabled { + return user.id, nil + } + } + return "", fmt.Errorf("no empty slot available") + +} +func (b *IPMIBMC) CreateUser(ctx context.Context, creds Credentials, _ string) error { + log.Debug(ctx, "Creating user", "host", b.host, "user", creds.Username) + slot, err := ipmiGetFreeSlot(ctx, b.host, b.port, b.creds) + if err != nil { + return fmt.Errorf("cannot create a new user: %w", err) + } + _, stderr, err := ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "user", "set", "name", slot, creds.Username) + if err != nil { + return fmt.Errorf("cannot rename user: %s, %w", stderr, err) + } + _, stderr, err = ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "user", "set", "password", slot, creds.Password) + if err != nil { + return fmt.Errorf("cannot set user password: %s, %w", stderr, err) + } + _, stderr, err = ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "channel", "setaccess", "1", slot, "link=on", "callin=on", "privilege=4") + if err != nil { + return fmt.Errorf("cannot set user privileges: %s, %w", stderr, err) + } + _, stderr, err = ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "user", "enable", slot) + if err != nil { + return fmt.Errorf("cannot enable user: %s, %w", stderr, err) + } + + b.creds = creds + b.exp = time.Time{} + return nil +} + +func (b *IPMIBMC) DeleteUsers(ctx context.Context, regex *regexp.Regexp) error { + users, err := ipmigetusers(ctx, b.host, b.port, b.creds) + if err != nil { + return fmt.Errorf("cannot delete users, %w", err) + } + + for _, user := range users { + if user.username != b.creds.Username && regex.MatchString(user.username) { + log.Debug(ctx, "Deleting user", "user", user.username) + _, _, err := ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "user", "disable", user.id) + if err != nil { + return fmt.Errorf("unable to disable user %s on host %s: %w", user.username, b.host, err) + } + _, _, err = ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "user", "set", "name", user.id, "") + if err != nil { + return fmt.Errorf("unable to reset the name of the user %s on host %s: %w", user.username, b.host, err) + } + } + } + return nil +} + +func (b *IPMIBMC) PowerOn(ctx context.Context) error { + log.Debug(ctx, "Powering on the machine") + _, _, err := ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "chassis", "power", "on") + if err != nil { + return fmt.Errorf("unable to power on the server %s: %w", b.host, err) + } + + return nil +} + +func (b *IPMIBMC) Reset(ctx context.Context, immediate bool) error { + //TODO: figure out a way of doing a graceful restart via IPMI + if !immediate { + return fmt.Errorf("unable to reset the server gracefully") + } + + _, _, err := ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "chassis", "power", "reset") + if err != nil { + return fmt.Errorf("unable to reset the server %s: %w", b.host, err) + } + return nil +} + +func (b *IPMIBMC) PowerOff(ctx context.Context, immediate bool) error { + log.Debug(ctx, "Powering off the machine") + how := "" + if immediate { + how = "off" + } else { + how = "soft" + } + _, _, err := ipmiExecuteCommand(ctx, b.host, b.port, b.creds, "ipmitool", "chassis", "power", how) + if err != nil { + return fmt.Errorf("unable to power off the system: %w", err) + } + + return nil +} diff --git a/internal/bmc/redfish.go b/internal/bmc/redfish.go new file mode 100644 index 00000000..63797cc3 --- /dev/null +++ b/internal/bmc/redfish.go @@ -0,0 +1,857 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package bmc + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish/common" + "github.com/stmcginnis/gofish/redfish" + + "github.com/ironcore-dev/metal/internal/log" +) + +func init() { + registerBMC(redfishBMC) +} + +func redfishBMC(tags map[string]string, host string, port int, creds Credentials, exp time.Time) BMC { + return &RedfishBMC{ + tags: tags, + host: host, + port: port, + creds: creds, + exp: exp, + } +} + +type RedfishBMC struct { + tags map[string]string + host string + port int + creds Credentials + exp time.Time +} + +func (b *RedfishBMC) Type() string { + return "Redfish" +} + +func (b *RedfishBMC) Tags() map[string]string { + return b.tags +} + +func (b *RedfishBMC) LEDControl() LEDControl { + return b +} + +func (b *RedfishBMC) PowerControl() PowerControl { + return b +} + +func (b *RedfishBMC) ResetControl() ResetControl { + return b +} + +func (b *RedfishBMC) Credentials() (Credentials, time.Time) { + return b.creds, b.exp +} + +var ( + userIdRegex = regexp.MustCompile(`/redfish/v1/AccountService/Accounts/([0-9]{1,2})`) +) + +// TODO: add specific fields for Lenovo/Dell +type redfishUser struct { + Oem *redfishUserOEM `json:"Oem,omitempty"` + UserName string `json:"UserName"` + RoleID string `json:"RoleId,omitempty"` + Password string `json:"Password"` + Enabled bool `json:"Enabled,omitempty"` +} + +type redfishUserOEM struct { + Hp struct { + LoginName string `json:"LoginName,omitempty"` + Privileges struct { + LoginPriv bool `json:"LoginPriv,omitempty"` + RemoteConsolePriv bool `json:"RemoteConsolePriv,omitempty"` + UserConfigPriv bool `json:"UserConfigPriv,omitempty"` + VirtualMediaPriv bool `json:"VirtualMediaPriv,omitempty"` + VirtualPowerAndResetPriv bool `json:"VirtualPowerAndResetPriv,omitempty"` + ILOConfigPriv bool `json:"iLOConfigPriv,omitempty"` + } `json:"Privileges,omitempty"` + } `json:"Hp,omitempty"` +} + +func redfishConnect(ctx context.Context, host string, port int, creds Credentials) (*gofish.APIClient, error) { + log.Debug(ctx, "Connecting", "host", host, "user", creds.Username) + + if port == 0 { + port = 443 + } + + hostAndPort := net.JoinHostPort(host, strconv.Itoa(port)) + config := gofish.ClientConfig{ + Endpoint: fmt.Sprintf("https://%s", hostAndPort), + Username: creds.Username, + Password: creds.Password, + Insecure: true, + } + c, err := gofish.Connect(config) + if err != nil { + return nil, fmt.Errorf("cannot connect: %w", err) + } + return c, nil +} + +func redfishGetUserIdFromError(rerr *common.Error) string { + for _, info := range rerr.ExtendedInfos { + for _, arg := range info.MessageArgs { + match := userIdRegex.FindStringSubmatch(arg) + if match != nil { + return match[1] + } + } + } + return "" +} + +func redfishFindWorkingCredentials(ctx context.Context, host string, port int, defaultCreds []Credentials, tempPassword string) (Credentials, string, error) { + if len(defaultCreds) == 0 { + return Credentials{}, "", fmt.Errorf("no default credentials to try") + } + + var merr error + var rerr *common.Error + for _, creds := range defaultCreds { + c, err := redfishConnect(ctx, host, port, creds) + if err == nil { + // TODO: optimize this + // try now to get service accounts + _, err := c.Service.AccountService() + if err == nil { + c.Logout() + return creds, "", nil + } else if errors.As(err, &rerr) && rerr.HTTPReturnedStatusCode == 403 { + return creds, redfishGetUserIdFromError(rerr), nil + } + } else if errors.As(err, &rerr) && rerr.HTTPReturnedStatusCode == 403 { + return creds, redfishGetUserIdFromError(rerr), nil + } + merr = multierror.Append(merr, err) + } + for _, creds := range defaultCreds { + c, err := redfishConnect(ctx, host, port, Credentials{creds.Username, tempPassword}) + if err == nil { + c.Logout() + return creds, "", nil + } else if errors.As(err, &rerr) && rerr.HTTPReturnedStatusCode == 403 { + return creds, redfishGetUserIdFromError(rerr), nil + } + merr = multierror.Append(merr, err) + } + return Credentials{}, "", fmt.Errorf("cannot connect using any predefined credentials: %w", merr) +} + +func redfishChangePasswordRaw(ctx context.Context, host string, port int, id, user, oldPassword, newPassword string) error { + if port == 0 { + port = 443 + } + endpoint := fmt.Sprintf("https://%s:%d/redfish/v1/AccountService/Accounts/%s", host, port, id) + log.Debug(ctx, "Changing password", "endpoint", endpoint) + + body, err := json.Marshal(map[string]interface{}{"Password": newPassword}) + if err != nil { + return fmt.Errorf("cannot encode JSON: %w", err) + } + + req, err := http.NewRequest("PATCH", endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("cannot create PATCH request: %w", err) + } + req.SetBasicAuth(user, oldPassword) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "*/*") + req.Header.Set("User-Agent", "gofish/1.0") + + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + response, err := client.Do(req) + if err != nil { + return fmt.Errorf("cannot perform PATCH request: %w", err) + } + defer func() { must(ctx, response.Body.Close()) }() + if response.StatusCode != 200 { + return fmt.Errorf("received HTTP status %d: %s", response.StatusCode, response.Status) + } + + return nil +} + +func (b *RedfishBMC) EnsureInitialCredentials(ctx context.Context, defaultCreds []Credentials, tempPassword string) error { + creds, pwChangeID, err := redfishFindWorkingCredentials(ctx, b.host, b.port, defaultCreds, tempPassword) + if err != nil { + return fmt.Errorf("cannot obtain initial credentials: %w", err) + } + + if pwChangeID != "" { + log.Debug(ctx, "Initial password change required", "user", creds.Username) + err := redfishChangePasswordRaw(ctx, b.host, b.port, pwChangeID, creds.Username, creds.Password, tempPassword) + if err != nil { + return fmt.Errorf("cannot change password for user %s: %w", creds.Username, err) + } + + creds.Password = tempPassword + } + + b.creds = creds + return nil +} + +func (b *RedfishBMC) Connect(ctx context.Context) error { + c, err := redfishConnect(ctx, b.host, b.port, b.creds) + if err != nil { + return err + } + c.Logout() + return nil +} + +func redfishGetAccounts(c *gofish.APIClient) ([]*redfish.ManagerAccount, error) { + svc, err := c.Service.AccountService() + if err != nil { + return nil, fmt.Errorf("cannot get account service: %w", err) + } + + accounts, err := svc.Accounts() + if err != nil { + return nil, fmt.Errorf("cannot get accounts: %w", err) + } + + return accounts, nil +} + +func redfishGetUserID(accounts []*redfish.ManagerAccount, user string) string { + for _, account := range accounts { + if account.UserName == user { + return account.ID + } + } + return "" +} + +func redfishWaitForUser(ctx context.Context, c *gofish.APIClient, host string, port int, creds Credentials, id string) (string, error) { + var sCreds []Credentials + sCreds = append(sCreds, creds) + + log.Debug(ctx, "Waiting for account to be ready", "username", creds.Username) + + TIMEOUT := 60 + + // TODO: find a nicer way to wait for user to be available + found := false + + for t := 0; t < TIMEOUT; t = t + 5 { + accounts, err := redfishGetAccounts(c) + if err != nil { + return "", fmt.Errorf("waiting for user, cannot get users: %w", err) + } + for _, account := range accounts { + if account.ID == id && account.UserName == creds.Username { + found = true + break + } + } + if found { + break + } + time.Sleep(5 * time.Second) + } + + if !found { + return "", fmt.Errorf("reached timeout and cannot find the created user: %s", creds.Username) + } + + for t := 0; t < TIMEOUT; t = t + 5 { + _, pwChangeID, err := redfishFindWorkingCredentials(ctx, host, port, sCreds, creds.Password) + if err != nil { + time.Sleep(5 * time.Second) + continue + } + return pwChangeID, nil + } + return "", fmt.Errorf("reached timeout and user is not usable") +} + +func redfishGetFreeID(accounts []*redfish.ManagerAccount) (string, error) { + for _, account := range accounts { + // modifying the user configuration t index 1 is not allowed on certain systems + if account.UserName == "" && account.ID != "1" { + return account.ID, nil + } + } + return "", fmt.Errorf("no available free id") +} + +func redfishCreateUserPost(ctx context.Context, c *gofish.APIClient, creds Credentials) (string, error) { + log.Debug(ctx, "Creating user", "type", "POST", "user", creds.Username) + + systems, err := c.Service.Systems() + if err != nil { + return "", fmt.Errorf("cannot get systems information: %w", err) + } + + mnf := "" + for _, s := range systems { + mnf = s.Manufacturer + if mnf != "" { + break + } + } + + u := redfishUser{UserName: creds.Username, Password: creds.Password} + // TODO: add specific code for Dell and Lenovo + if mnf == "HPE" { + u.Oem = &redfishUserOEM{} + u.Oem.Hp.LoginName = creds.Username + u.Oem.Hp.Privileges.LoginPriv = true + u.Oem.Hp.Privileges.RemoteConsolePriv = true + u.Oem.Hp.Privileges.UserConfigPriv = true + u.Oem.Hp.Privileges.VirtualMediaPriv = true + u.Oem.Hp.Privileges.VirtualPowerAndResetPriv = true + u.Oem.Hp.Privileges.ILOConfigPriv = true + } else { + u.RoleID = "Administrator" + u.Enabled = true + } + + response, err := c.Post("/redfish/v1/AccountService/Accounts", u) + if err != nil { + return "", fmt.Errorf("cannot perform POST request: %w", err) + } + defer func() { must(ctx, response.Body.Close()) }() + + if response.StatusCode != 201 { + return "", fmt.Errorf("received HTTP status %d: %s", response.StatusCode, response.Status) + } + + m := make(map[string]interface{}) + err = json.NewDecoder(response.Body).Decode(&m) + if err != nil { + return "", fmt.Errorf("cannot decode response body: %w", err) + } + + id, ok := m["Id"] + if !ok { + return "", fmt.Errorf("user has no Id attribute") + } + + return fmt.Sprintf("%v", id), nil +} + +func redfishCreateUserPatch(ctx context.Context, c *gofish.APIClient, slot string, creds Credentials) (string, error) { + log.Debug(ctx, "Creating user", "type", "PATCH", "user", creds.Username) + + u := redfishUser{UserName: creds.Username, RoleID: "Administrator", Password: creds.Password, Enabled: true} + response, err := c.Patch(fmt.Sprintf("/redfish/v1/AccountService/Accounts/%v", slot), u) + if err != nil { + return "", fmt.Errorf("cannot perform PATCH request: %w", err) + } + defer func() { must(ctx, response.Body.Close()) }() + + if response.StatusCode != 200 { + return "", fmt.Errorf("received HTTP status %d: %s", response.StatusCode, response.Status) + } + + m := make(map[string]interface{}) + err = json.NewDecoder(response.Body).Decode(&m) + if err != nil { + return "", fmt.Errorf("cannot decode response body: %w", err) + } + + return fmt.Sprintf("%v", slot), nil +} + +//func redfishChangePassword(ctx context.Context, c *gofish.APIClient, user, newPassword string) error { +// svc, err := c.Service.AccountService() +// if err != nil { +// return fmt.Errorf("cannot get account service: %w", err) +// } +// +// accounts, err := svc.Accounts() +// if err != nil { +// return fmt.Errorf("cannot get accounts: %w", err) +// } +// +// for _, account := range accounts { +// if account.UserName == user { +// log.Debug(ctx, "Changing password", "user", user) +// account.Password = newPassword +// err = account.Update() +// if err != nil { +// return fmt.Errorf("cannot update account for user %s: %w", user, err) +// } +// +// return nil +// } +// } +// +// return fmt.Errorf("user %s does not exist", user) +//} + +func redfishGetPasswordExpirationRaw(ctx context.Context, host string, port int, creds Credentials, id string) (time.Time, error) { + c, err := redfishConnect(ctx, host, port, creds) + if err != nil { + return time.Time{}, fmt.Errorf("cannot connect: %w", err) + } + defer c.Logout() + + endpoint := fmt.Sprintf("/redfish/v1/AccountService/Accounts/%s", id) + log.Debug(ctx, "Getting password expiration", "endpoint", endpoint) + + t := time.Now() + + response, err := c.Get(endpoint) + if err != nil { + return time.Time{}, fmt.Errorf("cannot perform GET request: %w", err) + } + defer func() { must(ctx, response.Body.Close()) }() + + var expUser struct { + PasswordExpiration *string + } + err = json.NewDecoder(response.Body).Decode(&expUser) + if err != nil { + return time.Time{}, fmt.Errorf("cannot decode response body: %w", err) + } + + if expUser.PasswordExpiration != nil { + t, err := time.Parse(time.RFC3339, *expUser.PasswordExpiration) + if err != nil { + return time.Time{}, fmt.Errorf("implement parsing of password expiration and remove this error: %s", *expUser.PasswordExpiration) + } + return t, nil + } + + // check if there is a general expiration set that can't be read from the user itself + endpoint = "/redfish/v1/AccountService" + log.Debug(ctx, "Getting password expiration", "endpoint", endpoint) + response, err = c.Get(endpoint) + if err != nil { + return time.Time{}, fmt.Errorf("cannot perform GET request: %w", err) + } + defer func() { must(ctx, response.Body.Close()) }() + + var expOem struct { + Oem struct { + Lenovo struct { + PasswordExpirationPeriodDays *int + } + } + } + err = json.NewDecoder(response.Body).Decode(&expOem) + if err != nil { + return time.Time{}, fmt.Errorf("cannot decode response body: %w", err) + } + + if expOem.Oem.Lenovo.PasswordExpirationPeriodDays != nil { + if *expOem.Oem.Lenovo.PasswordExpirationPeriodDays == 0 { + return time.Time{}, nil + } + return t.AddDate(0, 0, *expOem.Oem.Lenovo.PasswordExpirationPeriodDays), nil + } + + return time.Time{}, nil +} + +//func redfishGetPasswordExpiration(ctx context.Context, c *gofish.APIClient, id string) (time.Time, error) { +// endpoint := fmt.Sprintf("/redfish/v1/AccountService/Accounts/%s", id) +// log.Debug(ctx, "Getting password expiration", "endpoint", endpoint) +// +// t := time.Now() +// +// response, err := c.Get(endpoint) +// if err != nil { +// return time.Time{}, fmt.Errorf("cannot perform GET request: %w", err) +// } +// defer func() { must(ctx, response.Body.Close()) }() +// +// var expUser struct { +// PasswordExpiration *string +// } +// err = json.NewDecoder(response.Body).Decode(&expUser) +// if err != nil { +// return time.Time{}, fmt.Errorf("cannot decode response body: %w", err) +// } +// +// if expUser.PasswordExpiration != nil { +// return time.Time{}, fmt.Errorf("implement parsing of password expiration and remove this error: %s", *expUser.PasswordExpiration) +// } +// +// // check if there is a general expiration set that can't be read from the user itself +// endpoint = "/redfish/v1/AccountService" +// log.Debug(ctx, "Getting password expiration", "endpoint", endpoint) +// response, err = c.Get(endpoint) +// if err != nil { +// return time.Time{}, fmt.Errorf("cannot perform GET request: %w", err) +// } +// defer func() { must(ctx, response.Body.Close()) }() +// +// var expOem struct { +// Oem struct { +// Lenovo struct { +// PasswordExpirationPeriodDays *int +// } +// } +// } +// err = json.NewDecoder(response.Body).Decode(&expOem) +// if err != nil { +// return time.Time{}, fmt.Errorf("cannot decode response body: %w", err) +// } +// +// if expOem.Oem.Lenovo.PasswordExpirationPeriodDays != nil { +// if *expOem.Oem.Lenovo.PasswordExpirationPeriodDays == 0 { +// return time.Time{}, nil +// } +// return t.AddDate(0, 0, *expOem.Oem.Lenovo.PasswordExpirationPeriodDays), nil +// } +// +// return time.Time{}, nil +//} + +func (b *RedfishBMC) CreateUser(ctx context.Context, creds Credentials, tempPassword string) error { + c, err := redfishConnect(ctx, b.host, b.port, b.creds) + if err != nil { + return fmt.Errorf("cannot connect: %w", err) + } + defer c.Logout() + + accounts, err := redfishGetAccounts(c) + if err != nil { + return fmt.Errorf("cannot generate the list of accounts: %w", err) + } + + if redfishGetUserID(accounts, creds.Username) != "" { + return fmt.Errorf("user %s already exists", creds.Username) + } + + id, err := redfishCreateUserPost(ctx, c, Credentials{creds.Username, creds.Password}) + if err != nil { + var merr error + merr = multierror.Append(merr, err) + + slot, err := redfishGetFreeID(accounts) + if err != nil { + merr = multierror.Append(merr, err) + return fmt.Errorf("cannot create user with a POST request or get a free id: %w", merr) + } + + id, err = redfishCreateUserPatch(ctx, c, slot, Credentials{creds.Username, creds.Password}) + if err != nil { + merr = multierror.Append(merr, err) + return fmt.Errorf("cannot create user with a POST request or with a PATCH request: %w", merr) + } + } + + pwChangeID, err := redfishWaitForUser(ctx, c, b.host, b.port, creds, id) + if err != nil { + var merr error + merr = multierror.Append(merr, err) + return fmt.Errorf("created user is not available: %w", merr) + } + + if pwChangeID != "" { + err := redfishChangePasswordRaw(ctx, b.host, b.port, pwChangeID, creds.Username, creds.Password, tempPassword) + if err != nil { + return fmt.Errorf("cannot change password for user %s: %w", creds.Username, err) + } + + creds.Password = tempPassword + } + + exp, err := redfishGetPasswordExpirationRaw(ctx, b.host, b.port, creds, id) + if err != nil { + return fmt.Errorf("cannot determine password expiration: %w", err) + } + + b.creds = creds + b.exp = exp + return nil +} + +func (b *RedfishBMC) ReadInfo(ctx context.Context) (Info, error) { + c, err := redfishConnect(ctx, b.host, b.port, b.creds) + if err != nil { + return Info{}, fmt.Errorf("cannot connect: %w", err) + } + defer c.Logout() + + log.Debug(ctx, "Reading BMC info") + systems, err := c.Service.Systems() + if err != nil { + return Info{}, fmt.Errorf("cannot get systems information: %w", err) + } + if len(systems) == 0 { + return Info{}, fmt.Errorf("cannot get systems information") + } + + uuid := systems[0].UUID + if uuid == "" { + return Info{}, fmt.Errorf("BMC has no UUID attribute") + } + uuid = strings.ToLower(uuid) + + var led string + switch led = string(systems[0].IndicatorLED); led { + case "Blinking": + led = "Blinking" + case "Lit": + led = "On" + case "Off": + led = "Off" + default: + led = "" + } + + // Reading the OS state is supported only on Lenovo hardware + var os, osReason string + if len(systems) > 0 { + sys := systems[0] + sysRaw, err := c.Get(sys.ODataID) + if err != nil { + return Info{}, fmt.Errorf("cannot get systems (raw): %w", err) + } + osStatus := struct { + OEM struct { + Lenovo struct { + SystemStatus *string `json:"SystemStatus"` + } `json:"Lenovo"` + } `json:"OEM"` + }{} + + decoder := json.NewDecoder(sysRaw.Body) + err = decoder.Decode(&osStatus) + if err != nil { + return Info{}, fmt.Errorf("cannot decode information for OS status: %w", err) + } + + if osStatus.OEM.Lenovo.SystemStatus != nil { + if sys.PowerState == redfish.OffPowerState { + osReason = "PoweredOff" + } else if state := *osStatus.OEM.Lenovo.SystemStatus; state == "OSBooted" || state == "BootingOSOrInUndetectedOS" { + os = "Ok" + osReason = state + } else { + osReason = state + } + } + } + + manufacturer := systems[0].Manufacturer + capabilities := []string{"credentials", "power", "led"} + console := "" + fw := "" + + mgr, err := c.Service.Managers() + if err != nil { + return Info{}, fmt.Errorf("cannot get managers: %w", err) + } + if len(mgr) > 0 { + consoleList := mgr[0].SerialConsole.ConnectTypesSupported + if mgr[0].SerialConsole.ServiceEnabled { + if strings.ToLower(manufacturer) == "lenovo" && isConsoleTypeSupported(consoleList, redfish.SSHSerialConnectTypesSupported) { + capabilities = append(capabilities, "console") + console = "ssh-lenovo" + } else if isConsoleTypeSupported(consoleList, redfish.IPMISerialConnectTypesSupported) { + capabilities = append(capabilities, "console") + console = "ipmi" + } + fw = mgr[0].FirmwareVersion + } + } + + return Info{ + UUID: uuid, + Type: "BMC", + Capabilities: capabilities, + SerialNumber: systems[0].SerialNumber, + SKU: systems[0].SKU, + Manufacturer: manufacturer, + LocatorLED: led, + Power: fmt.Sprintf("%v", systems[0].PowerState), + OS: os, + OSReason: osReason, + Console: console, + FWVersion: fw, + }, nil +} + +func isConsoleTypeSupported(consoleList []redfish.SerialConnectTypesSupported, console redfish.SerialConnectTypesSupported) bool { + for _, sc := range consoleList { + if sc == console { + return true + } + } + return false +} + +func (b *RedfishBMC) SetLocatorLED(ctx context.Context, state string) (string, error) { + c, err := redfishConnect(ctx, b.host, b.port, b.creds) + if err != nil { + return "", fmt.Errorf("cannot connect: %w", err) + } + defer c.Logout() + + log.Debug(ctx, "Setting the LED on the machine") + + systems, err := c.Service.Systems() + + if err != nil { + return "", fmt.Errorf("unable to get the systems: %w", err) + } + + var ledState common.IndicatorLED + switch state { + case "On": + ledState = common.LitIndicatorLED + case "Blinking": + ledState = common.BlinkingIndicatorLED + case "Off": + ledState = common.OffIndicatorLED + default: + return "", fmt.Errorf("unable to set LED state to unknown") + } + + systems[0].IndicatorLED = ledState + err = systems[0].Update() + + if err != nil { + return "", fmt.Errorf("unable to set the LED: %w", err) + } + + return state, nil +} + +func (b *RedfishBMC) PowerOn(ctx context.Context) error { + c, err := redfishConnect(ctx, b.host, b.port, b.creds) + if err != nil { + return fmt.Errorf("cannot connect: %w", err) + } + defer c.Logout() + + log.Debug(ctx, "Powering on the machine") + + systems, err := c.Service.Systems() + + if err != nil { + return fmt.Errorf("unable to get the systems: %w", err) + } + + err = systems[0].Reset(redfish.OnResetType) + if err != nil { + return fmt.Errorf("unable to power on the system: %w", err) + } + + return nil +} + +func (b *RedfishBMC) Reset(ctx context.Context, immediate bool) error { + c, err := redfishConnect(ctx, b.host, b.port, b.creds) + if err != nil { + return fmt.Errorf("cannot connect: %w", err) + } + defer c.Logout() + + log.Debug(ctx, "Resetting the machine") + + systems, err := c.Service.Systems() + + if err != nil { + return fmt.Errorf("unable to get the systems: %w", err) + } + + if immediate { + err = systems[0].Reset(redfish.ForceRestartResetType) + } else { + err = systems[0].Reset(redfish.GracefulRestartResetType) + } + if err != nil { + return fmt.Errorf("unable to reset the system: %w", err) + } + + return nil +} + +func (b *RedfishBMC) PowerOff(ctx context.Context, immediate bool) error { + c, err := redfishConnect(ctx, b.host, b.port, b.creds) + if err != nil { + return fmt.Errorf("cannot connect: %w", err) + } + defer c.Logout() + + log.Debug(ctx, "Powering off the machine") + + systems, err := c.Service.Systems() + + if err != nil { + return fmt.Errorf("unable to get the systems: %w", err) + } + + if immediate { + err = systems[0].Reset(redfish.ForceOffResetType) + } else { + err = systems[0].Reset(redfish.GracefulShutdownResetType) + } + if err != nil { + return fmt.Errorf("unable to power off the system: %w", err) + } + + return nil +} + +func (b *RedfishBMC) DeleteUsers(ctx context.Context, regex *regexp.Regexp) error { + c, err := redfishConnect(ctx, b.host, b.port, b.creds) + if err != nil { + return fmt.Errorf("cannot connect: %w", err) + } + defer c.Logout() + svc, err := c.Service.AccountService() + if err != nil { + return fmt.Errorf("cannot get account service: %w", err) + } + + accounts, err := svc.Accounts() + if err != nil { + return fmt.Errorf("cannot get accounts: %w", err) + } + + for _, account := range accounts { + if account.UserName != b.creds.Username && regex.MatchString(account.UserName) { + log.Debug(ctx, "Deleting user", "user", account.UserName) + _, err := c.Delete(account.ODataID) + if err != nil { + account.Enabled = false + account.UserName = "" + err = account.Update() + if err != nil { + return fmt.Errorf("cannot delete user %s: %w", account.UserName, err) + } + } + } + } + return nil +}